From 9ff70494d49e9a2f193b327471381cc687d600df Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 28 Jul 2026 13:35:05 +1200 Subject: [PATCH] fix(web): serve static web assets, and fix a DI error Production was masking (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs, and the second was hiding the first. ## 1. No framework JavaScript, so the UI was inert GET /_framework/blazor.web.js -> HTTP 500, 0 bytes GET /app.css (compressed) -> HTTP 200, 0 bytes System.IO.FileNotFoundException: Could not find file '.../src/Presentation/Web/wwwroot/_framework/blazor.web.js' at StaticAssetDevelopmentRuntimeHandler.AttachRuntimePatching No Blazor circuit could start, so every interactive page — Search, Activity, and the new Settings page — rendered its initial HTML and then did nothing. Compressed CSS came back empty too, so the UI was unstyled. Nothing logged an obvious error; it just looked broken. Root cause: the framework calls UseStaticWebAssets() only in Development, and none of our hosts run as Development — there are no launch profiles, and Aspire does not set one either. Without it the static-web-assets manifest is never loaded, so relative asset paths resolve against wwwroot, where framework assets and the content-hashed compressed files do not exist. The manifest itself was correct all along: blazor.web.js pointed at the microsoft.aspnetcore.app.internal.assets package and app.css.gz at obj//net10.0/compressed/.gz. Fixed by calling builder.WebHost.UseStaticWebAssets() explicitly. It is a no-op when the manifest is absent, i.e. in a published app where assets are copied into wwwroot, so production behaviour is unchanged. Rejected alternative: adding launchSettings.json with ASPNETCORE_ENVIRONMENT=Development to each host. It fixes the standalone case but is the wrong lever — it would also flip Development on for anything that runs the built binary, and an applicationUrl in those profiles interfered with Aspire's own endpoint allocation. Fixing the asset loading directly is narrower and works regardless of environment. ## 2. IArrClient was never registered anywhere Trying to run in Development to test the theory surfaced this: Unable to resolve service for type 'Krautwatch.Domain.Interfaces.IArrClient' while attempting to activate 'TestArrConnectionHandler' AddApplication() registered TestArrConnectionHandler unconditionally, but no host called AddArrClient(), so IArrClient had no registration at all. Every host would fail to start under DI validate-on-build, and in Production — where validation is off — the Test button would simply have thrown at the moment a user clicked it. Mine, from #61. Fixed by following the pattern this codebase already uses for RunDownloadHandler ("registered by the Downloader host only — it needs IDownloadProvider"): the handler is registered by the Web host, alongside AddArrClient(), because the two cannot be separated. Worth noting why this went unnoticed: running as Production disables DI validate-on-build, so the very bug that made the UI inert also suppressed the startup error that would have pointed at the missing registration. ## Verified on both run paths Standalone (dotnet run --project src/Presentation/Web): blazor.web.js plain 200/200645b gz 200/55644b app.css plain 200/4798b gz 200/4831b Aspire fleet (dotnet run --project src/Presentation/AppHost), on a clean environment: Web host on :51578 blazor.web.js plain 200/200645b gz 200/55644b app.css plain 200/4798b gz 200/4831b Both were 500/0b and 200/0b before. The Web host also now starts under DI validation. Tests: App 88 / Arch 11 / Infra 102 green, 0 warnings. Closes #63. Co-Authored-By: Claude Opus 5 (1M context) --- src/Application/ApplicationServiceExtensions.cs | 4 +++- src/Presentation/Web/Program.cs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Application/ApplicationServiceExtensions.cs b/src/Application/ApplicationServiceExtensions.cs index a794bd1..e073a8a 100644 --- a/src/Application/ApplicationServiceExtensions.cs +++ b/src/Application/ApplicationServiceExtensions.cs @@ -35,7 +35,9 @@ public static IServiceCollection AddApplication(this IServiceCollection services // Settings services.AddScoped(); services.AddScoped(); - services.AddScoped(); + // NB: TestArrConnectionHandler is registered by the Web host only — it needs IArrClient, which + // only a host that calls AddArrClient() provides. Registering it here made every host fail to + // start under DI validate-on-build (i.e. in Development). // Auth (#48) — SetupToken is a singleton so it survives for the process lifetime and is // logged once at startup; the handlers are scoped like every other use-case. diff --git a/src/Presentation/Web/Program.cs b/src/Presentation/Web/Program.cs index 32a75ce..4bfc410 100644 --- a/src/Presentation/Web/Program.cs +++ b/src/Presentation/Web/Program.cs @@ -1,6 +1,7 @@ using System.Threading.RateLimiting; using Krautwatch.Application; using Krautwatch.Application.Auth; +using Krautwatch.Application.Settings; using Krautwatch.Infrastructure; using Krautwatch.Web; using Krautwatch.Web.Components; @@ -13,6 +14,14 @@ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); + +// Load the static-web-assets manifest explicitly. The framework only does this in Development, and none of +// our hosts run as Development (there are no launch profiles, and Aspire does not set one), so +// _framework/blazor.web.js returned a 500 and compressed assets returned an empty 200 — the UI loaded +// unstyled and completely inert, with no error anywhere obvious. No-op when the manifest is absent, i.e. in +// a published app where the assets are copied into wwwroot. See #63. +builder.WebHost.UseStaticWebAssets(); + builder.Services.AddRazorComponents().AddInteractiveServerComponents(); var connectionString = builder.Configuration.GetConnectionString("krautwatch") @@ -25,6 +34,11 @@ }); builder.Services.AddApplication(); +// Outbound Sonarr/Radarr client + the Action that uses it. Paired deliberately: the handler cannot be +// constructed without the client, so whichever host wants one must wire both (#4). +builder.Services.AddArrClient(); +builder.Services.AddScoped(); + // ────────────────────────────────────────────────────────────── // Authentication (#48) //