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..09864a8 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,34 @@ 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. Served via the WebRootFileProvider + // (classic UseStaticFiles), resolved lazily by the middleware after Build(). + private static void MapModuleStaticWebAssets(IServiceProvider hostServices) + { + // 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); + ModuleWebAssets.ModuleWebAssetsLoader.UseModuleAssets(environment, configuration); + } + private void ConfigureApp(BootstrapModuleHostCommand command, IApplicationBuilder app) { // middleware: the module's Configure (interface or convention). 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 { }