From d9c0a6fbdd72d7a6d0a9a8a9f54504cd2037638e Mon Sep 17 00:00:00 2001 From: Frank Wagner Date: Tue, 21 Jul 2026 22:49:17 +0200 Subject: [PATCH 1/2] Serve module static web assets on the minimal-API host Resolve the follow-up left by #43: a module hosted via UseAspNetCoreMinimal now serves its own static web assets (wwwroot / _content), on net6/8/9/10, in both dev (dotnet run) and published output. Each module's inner WebApplication has ApplicationName = the module assembly, so the module's own static web assets manifest is already root-mapped. After Build() the handler composes those assets onto the module's WebRootFileProvider via StaticWebAssetsLoader.UseStaticWebAssets (dev) and ModuleWebAssetsLoader.UseModuleAssets (published) - no ".modules/{module}" prefix stripping, and each module host serves only its own assets. Assets are served through the classic UseStaticFiles pipeline (WebRootFileProvider resolved lazily by the middleware after build). Verified against samples/dotnet/minimal: GET /css/site.css -> 200 in dev and publish. --- samples/dotnet/minimal/README.md | 24 +++++++++---------- .../WebApplicationModuleHostHandler.cs | 21 ++++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/samples/dotnet/minimal/README.md b/samples/dotnet/minimal/README.md index 344d5b5..7356b1e 100644 --- a/samples/dotnet/minimal/README.md +++ b/samples/dotnet/minimal/README.md @@ -14,20 +14,20 @@ dotnet run --project App The host starts the module on an ephemeral port, then probes two URLs and prints the results. -## What works +## What it shows - `GET /` → **200** — Razor Pages are discovered and rendered on the minimal-API module host. +- `GET /css/site.css` → **200** — the module's **static web assets** are served by the module host, + in both `dotnet run` (dev) and `dotnet publish` output. - Dependency injection / SimpleInjector module container (`ConfigureContainer`) works post-build. +- The module is authored with the three module contracts: `IServiceConfiguringModule` (AddRazorPages), + `IApplicationConfiguringModule` (UseStaticFiles middleware) and `IEndpointConfiguringModule` + (MapRazorPages). -## Known limitation (tracked as a follow-up) +## How module static web assets are served -- `GET /css/site.css` → **404** — a module's **static web assets are not yet mapped** on the - minimal-API host. - - The classic host wires module assets via `ModuleWebAssetsLoader`, which is **file-provider / - XML-manifest** based (`{app}.StaticWebAssets.xml`) and manipulates `WebRootFileProvider`. Since - .NET 9 static web assets are **endpoint-based** (`MapStaticAssets`, - `{app}.staticwebassets.*.json`), that mechanism no longer applies. Mapping a module's static web - assets on net9/10 (filtering the host manifest's `.modules/{module}` entries into the module host) - is a separate piece of work and is intentionally **not** part of the initial `UseAspNetCoreMinimal` - handler. +Each module's inner `WebApplication` uses `ApplicationName` = the module assembly, so the module's +own static web assets manifest is already root-mapped. The host composes those assets onto the +module's `WebRootFileProvider` after build (`StaticWebAssetsLoader.UseStaticWebAssets` for dev + +`ModuleWebAssetsLoader.UseModuleAssets` for published output) — no `.modules/{module}` prefix +stripping is needed, and each module host serves only its own assets. diff --git a/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs b/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs index 089a381..51b1634 100644 --- a/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs +++ b/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs @@ -1,8 +1,10 @@ #if NET6_0_OR_GREATER +using System; using System.Collections.Generic; using Dbosoft.Hosuto.Modules.Hosting.Internal; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -94,9 +96,28 @@ public override void BootstrapHost(BootstrapModuleHostCommand command) command.Options.ConfigureBuilderAction?.Invoke(builder.Host); command.Host = builder.Build(); + MapModuleStaticWebAssets(command.Host.Services); command.ModuleContext = CreateModuleContext(command.Host.Services); } + // Makes the module's own static web assets available at the module host's web root. Each + // module's inner WebApplication has ApplicationName = the module assembly, so the module's + // OWN static web assets manifest is already root-mapped - no ".modules/{module}" stripping is + // needed and each module host serves only its own assets. Serves via the WebRootFileProvider + // (classic UseStaticFiles), resolved lazily by the middleware after Build(). + private static void MapModuleStaticWebAssets(IServiceProvider hostServices) + { + var environment = hostServices.GetService(); + var configuration = hostServices.GetService(); + if (environment == null || configuration == null) + return; + + // dev: reads {module}.staticwebassets.runtime.json (assets mapped at the web root). + StaticWebAssetsLoader.UseStaticWebAssets(environment, configuration); + // published: composes {contentRoot}/wwwroot/.modules/{module} physically. + ModuleWebAssets.ModuleWebAssetsLoader.UseModuleAssets(environment, configuration); + } + private void ConfigureApp(BootstrapModuleHostCommand command, IApplicationBuilder app) { // middleware: the module's Configure (interface or convention). From 2d776eb0f2e3a5d14592d515f4ffb3195d8eea53 Mon Sep 17 00:00:00 2001 From: Frank Wagner Date: Tue, 21 Jul 2026 23:12:31 +0200 Subject: [PATCH 2/2] Add static-web-assets test; fail-fast + document env gating (review) - Add an integration test that serves a module's static web asset over HTTP (published .modules/{module} layout in a temp content root) - verified to fail if the asset composition is removed. - Fail fast (GetRequiredService) instead of silently no-op'ing when the host env or configuration is missing. - Document why UseStaticWebAssets is deliberately NOT gated on IsDevelopment: a modules host defaults to Production under `dotnet run`, so an env gate would never load the dev manifest and module assets would 404; both loaders are no-ops when their input is absent. --- .../WebApplicationModuleHostHandler.cs | 22 ++++++---- .../Hosting/WebApplicationHostTests.cs | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs b/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs index 51b1634..09864a8 100644 --- a/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs +++ b/src/Hosuto.Hosting.AspNetCore/Modules/Hosting/WebApplicationModuleHostHandler.cs @@ -103,18 +103,24 @@ public override void BootstrapHost(BootstrapModuleHostCommand command) // Makes the module's own static web assets available at the module host's web root. Each // module's inner WebApplication has ApplicationName = the module assembly, so the module's // OWN static web assets manifest is already root-mapped - no ".modules/{module}" stripping is - // needed and each module host serves only its own assets. Serves via the WebRootFileProvider + // needed and each module host serves only its own assets. Served via the WebRootFileProvider // (classic UseStaticFiles), resolved lazily by the middleware after Build(). private static void MapModuleStaticWebAssets(IServiceProvider hostServices) { - var environment = hostServices.GetService(); - var configuration = hostServices.GetService(); - if (environment == null || configuration == null) - return; - - // dev: reads {module}.staticwebassets.runtime.json (assets mapped at the web root). + // both are always registered on a WebApplication host - fail fast if not. + var environment = hostServices.GetRequiredService(); + var configuration = hostServices.GetRequiredService(); + + // Deliberately NOT gated on IsDevelopment (unlike WebApplicationBuilder's own automatic + // UseStaticWebAssets): a modules host defaults to the Production environment under + // `dotnet run`, so an env gate would never load the dev manifest and module assets would + // 404. Both loaders below are no-ops when their input is absent, so calling both + // unconditionally is safe: + // - dev / build output: {module}.staticwebassets.runtime.json exists -> assets mapped at + // the web root (no-op in published output, where that manifest is not present); + // - published output: composes {contentRoot}/wwwroot/.modules/{module} physically + // (no-op in dev, where that folder does not exist). StaticWebAssetsLoader.UseStaticWebAssets(environment, configuration); - // published: composes {contentRoot}/wwwroot/.modules/{module} physically. ModuleWebAssets.ModuleWebAssetsLoader.UseModuleAssets(environment, configuration); } diff --git a/test/Hosuto.SimpleInjector.Tests/Modules/Hosting/WebApplicationHostTests.cs b/test/Hosuto.SimpleInjector.Tests/Modules/Hosting/WebApplicationHostTests.cs index d49a4ea..5a79af4 100644 --- a/test/Hosuto.SimpleInjector.Tests/Modules/Hosting/WebApplicationHostTests.cs +++ b/test/Hosuto.SimpleInjector.Tests/Modules/Hosting/WebApplicationHostTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -82,6 +83,38 @@ public void Minimal_ValidateServiceProvider_can_disable_build_validation() } #endif + // A module's own static web assets (the published ".modules/{module}" physical layout) are + // served by the minimal-API module host via UseStaticFiles. + [Fact] + public async Task WebModule_serves_its_static_web_assets() + { + var moduleAssembly = typeof(StaticAssetModule).Assembly.GetName().Name; + var contentRoot = Path.Combine(Path.GetTempPath(), "hosuto-swa-" + Guid.NewGuid().ToString("N")); + var assetDir = Path.Combine(contentRoot, "wwwroot", ".modules", moduleAssembly); + Directory.CreateDirectory(assetDir); + File.WriteAllText(Path.Combine(assetDir, "hello.txt"), "static-asset-ok"); + + try + { + var builder = ModulesHost.CreateDefaultBuilder(); + builder.UseContentRoot(contentRoot); + builder.UseSimpleInjector(new Container()); + builder.UseAspNetCoreMinimal(app => app.WebHost.UseUrls("http://127.0.0.1:0")); + builder.HostModule(); + + using var host = builder.Build(); + await host.StartAsync(); + var body = await Get(host, BaseUrl(host) + "/hello.txt"); + await host.StopAsync(); + + Assert.Equal("static-asset-ok", body); + } + finally + { + try { Directory.Delete(contentRoot, recursive: true); } catch { /* best effort */ } + } + } + private static async Task StartMinimalHost() where TModule : class { var builder = ModulesHost.CreateDefaultBuilder(); @@ -173,6 +206,16 @@ void IEndpointConfiguringModule.MapEndpoints(IServiceProvider serviceProvider, I } } + public sealed class StaticAssetModule : WebModule, IApplicationConfiguringModule + { + public override string Path { get; } = ""; + + void IApplicationConfiguringModule.Configure(IServiceProvider serviceProvider, IApplicationBuilder app) + { + app.UseStaticFiles(); + } + } + public interface IBus { }