Skip to content
Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!

Static Files

app.UseStaticFiles("./wwwroot");

The middleware resolves a path and hands over to the same file-serving code downloads use, so byte ranges, ETag, If-None-Match and 304 come free. When no file matches it calls next and the request carries on to routing — which is what lets an app serve a SPA and an API from one pipeline.

IStaticFileSource rather than a directory path, because the interesting case on a phone is not a directory.

app.UseStaticFiles("./wwwroot", o => o.CacheFor(TimeSpan.FromHours(1)));

A MAUI or single-file build has no content directory to point at, so the web assets travel inside the assembly:

<ItemGroup>
<EmbeddedResource Include="wwwroot\**" />
</ItemGroup>
app.UseEmbeddedFiles(typeof(App).Assembly, "MyApp.wwwroot", o => o.FallbackFile = "index.html");

Resource names are flattened at build time (wwwroot/css/site.css becomes MyApp.wwwroot.css.site.css), so the map is built once by reversing that. Because only the separators are ambiguous — site.min.css is indistinguishable from a site directory containing min.css — both readings are registered and either request resolves.

EmbeddedFileSource.Paths lists what it can serve, which is the first thing to check when a file 404s.

The useful arrangement during development: a physical directory in front so an edited file is picked up without a rebuild, embedded resources behind it so the packaged app still works.

app.UseStaticFiles(new CompositeFileSource(
new PhysicalFileSource("./wwwroot"),
new EmbeddedFileSource(typeof(App).Assembly, "MyApp.wwwroot")
));
Property Default Notes
RequestPath "" URL prefix — /assets serves /assets/app.js from app.js
DefaultDocuments index.html, index.htm Tried when a directory is requested
FallbackFile null The SPA index; see below
ServeUnknownFileTypes false See below
DefaultContentType application/octet-stream Used only when the above is on
CacheControl null Or use CacheFor(maxAge, immutable)
ServePrecompressedFiles false Serve a .br/.gz sidecar in place of the original
ContentTypeOverrides Extension → content type, on top of the built-in map
OnPrepareResponse null A last look before the response is written
app.UseStaticFiles("./wwwroot", o => o.FallbackFile = "index.html");

/orders/42 reaches the client-side router instead of a 404. The fallback is only applied to GET and HEAD requests that look like navigations (they accept HTML) and do not look like assets (no file extension) — so a missing script still 404s honestly rather than returning HTML the browser will fail to parse.

o.CacheFor(TimeSpan.FromDays(365), immutable: true);

Leaving CacheControl null sends none, which browsers treat as “revalidate” — and the ETag makes that cheap. Only mark content immutable when its URL changes with its content (a hashed bundle name); otherwise a browser keeps a stale copy for the whole age and no deploy will dislodge it.

OnPrepareResponse is the hook for a per-file policy:

o.OnPrepareResponse = ctx =>
{
if (ctx.File.Name.EndsWith(".html"))
ctx.HttpContext.Response.Headers["Cache-Control"] = "no-cache";
};
o.ServePrecompressedFiles = true; // serves app.wasm.br for app.wasm

Off by default, because a directory containing an unrelated .gz would otherwise start serving it as an encoding of a file it is not. On for a build that publishes precompressed assets it is strictly better: those were compressed once at maximum effort, and recompressing them per request spends CPU to produce a larger result. The content type still describes the file underneath.

This is the part that has to be right, so it is worth stating what the middleware actually does.

  • Paths arrive already percent-decoded, so %2e%2e%2f is a plain ../ by the time anything sees it. That is exactly why the segment check happens on the decoded path: .., null bytes, \, : and invalid filename characters are all refused.
  • Containment is checked after normalization and after resolving links. A symlink is the one way a path that looks contained can leave.
  • Path comparison follows the platform. Treating a case-insensitive file system as case-sensitive is how a containment check passes while the open succeeds on a different file.
  • Dotfiles are refused by default. .env and .git live in content directories.
  • Unknown extensions are refused by default. Guessing a type for an unknown extension is how a user-writable directory turns into a way to serve HTML — and therefore script — from your origin.

A published Blazor app needs the SPA fallback, precompressed sidecars and a cache policy that treats fingerprinted _framework assets as immutable. UseBlazorWebAssembly arranges all three — see Blazor WebAssembly.