Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions samples/dotnet/minimal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -94,9 +96,34 @@ public override void BootstrapHost(BootstrapModuleHostCommand<TModule> command)
command.Options.ConfigureBuilderAction?.Invoke(builder.Host);

command.Host = builder.Build();
MapModuleStaticWebAssets(command.Host.Services);
command.ModuleContext = CreateModuleContext(command.Host.Services);
Comment on lines 98 to 100
}

// 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<IWebHostEnvironment>();
var configuration = hostServices.GetRequiredService<IConfiguration>();

// 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<TModule> command, IApplicationBuilder app)
{
// middleware: the module's Configure (interface or convention).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
Expand Down Expand Up @@ -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<StaticAssetModule>();

using var host = builder.Build();
await host.StartAsync();
var body = await Get(host, BaseUrl<StaticAssetModule>(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<IHost> StartMinimalHost<TModule>() where TModule : class
{
var builder = ModulesHost.CreateDefaultBuilder();
Expand Down Expand Up @@ -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
{
}
Expand Down