Skip to content

Commit b36091a

Browse files
committed
Add zip-backed static file source
Introduce `ZipFileSource` to serve static assets from zip archives on disk or embedded in an assembly, including optional precompressed sidecars for Blazor publish output. Generalize precompressed lookups behind `IPrecompressedFileSource` so middleware and composite sources preserve `.br`/`.gz` assets, then add coverage for embedded archives, ranges, fallbacks, concurrency, and composite behavior. Update the README and skill docs to recommend zip sources for packaged web assets, and bump the version to 1.0.5.
1 parent 253ed72 commit b36091a

8 files changed

Lines changed: 702 additions & 7 deletions

File tree

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ is built on the one below and they compose in the same app.
134134
| **Diagnostics** | Health checks with liveness/readiness tags, telemetry on the in-box primitives — one `Activity` per request continuing the caller's `traceparent`, and the OpenTelemetry HTTP metrics an ASP.NET dashboard already reads — and W3C access logs, rolled and pruned, written off the request path |
135135
| **Formats** | Content negotiation in both directions — responses chosen from `Accept`, request bodies from `Content-Type`. JSON out of the box; XML, MessagePack and protobuf are one line each, and a format of your own is an `IOutputFormatter`/`IInputFormatter` pair. XML and MessagePack need no dependency and no attributes on your DTOs: they read the same `JsonTypeInfo` the JSON path reads, which is what keeps them AOT-clean where `XmlSerializer` cannot be |
136136
| **Protocols** | HTTP/1.1, HTTP/2 (own HPACK), HTTP/3 (own QPACK), WebSockets, Server-Sent Events, trailing headers on all three versions. Never guessed — ALPN over TLS, connection preface over cleartext. WebSockets carry permessage-deflate, keepalive pings, and a registry for broadcasting to a group |
137-
| **Content** | Static files from disk *or* embedded resources, a published Blazor WebAssembly app, streaming multipart uploads, downloads with byte ranges and conditional GETs, a file browser over a directory, and brotli/gzip/deflate compression in both directions |
137+
| **Content** | Static files from disk, embedded resources *or* a zip archive (on disk or embedded), a published Blazor WebAssembly app, streaming multipart uploads, downloads with byte ranges and conditional GETs, a file browser over a directory, and brotli/gzip/deflate compression in both directions |
138138
| **Security** | Authentication and authorization split ASP.NET-style, with Basic, API key, cookie and JWT schemes; policies, roles and claims; CORS, rate limiting and IP filtering, all with per-endpoint policies; signed double-submit antiforgery, the browser security headers, HSTS and an HTTPS redirect |
139139
| **TLS** | Several endpoints with per-endpoint TLS, self-signed certificates generated in managed code (iOS and Android included), client certificates, and SPKI pinning for the app's own `HttpClient` |
140140
| **OpenAPI** | An OpenAPI 3.0.3 document built entirely from compile-time metadata and your `JsonSerializerContext` — no reflection, no document object model |

skills/shiny-httpserver/SKILL.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ triggers:
5555
- UseStaticFiles
5656
- UseEmbeddedFiles
5757
- UseBlazorWebAssembly
58+
- ZipFileSource
59+
- serve a zip
60+
- serve static files from a zip
5861
- MapFileBrowser
5962
- FileDownloadResult
6063
- ReadMultipartAsync
@@ -741,6 +744,28 @@ app.UseBlazorWebAssembly("./wwwroot"); // SPA + preco
741744
app.MapFileBrowser("/files", o => o.RootPath = FileSystem.AppDataDirectory).RequireAuthorization();
742745
```
743746

747+
**Four sources**, all `IStaticFileSource` and all usable with any of the above:
748+
`PhysicalFileSource` (a directory), `EmbeddedFileSource` (loose embedded resources),
749+
`ZipFileSource` (a zip on disk or embedded in the assembly), `CompositeFileSource` (tried in order —
750+
a directory in front of the packaged copy is the development arrangement).
751+
752+
```csharp
753+
// A packaged Blazor publish, zipped into the app - one embedded resource instead of thousands,
754+
// and the paths survive instead of being flattened into the resource name.
755+
app.UseBlazorWebAssembly(
756+
new ZipFileSource(typeof(App).Assembly, "MyApp.wwwroot.zip")
757+
{
758+
PrecompressedEncodings = ["br", "gzip"] // a publish zips its .br/.gz sidecars too
759+
}
760+
);
761+
762+
new ZipFileSource("./content/site.zip"); // on disk
763+
new ZipFileSource("./site.zip", "wwwroot"); // zipped with its parent folder
764+
```
765+
766+
- Prefer `ZipFileSource` over `EmbeddedFileSource` for a **publish output**: `EmbeddedFileSource` has
767+
to un-mangle dotted resource names, which is ambiguous for `site.min.css`, and thousands of loose
768+
resources inflate the assembly. A zip keeps real paths and stays compressed.
744769
- Unknown file extensions are **not served** by default. Add `ContentTypeOverrides[".x"]` rather than
745770
turning on `ServeUnknownFileTypes`.
746771
- `MapFileBrowser("/", …)` mounts the browser on the whole site — the shape a "serve this directory"

src/Shiny.Net.HttpServer/StaticFiles/IStaticFileSource.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,34 @@ public interface IStaticFileSource
4242
bool TryGetFile(string relativePath, out StaticFile file);
4343
}
4444

45+
/// <summary>
46+
/// A source that can serve a precompressed sidecar in place of the file that was asked for -
47+
/// <c>app.wasm.br</c> for <c>app.wasm</c>.
48+
/// <para>
49+
/// Separate from <see cref="IStaticFileSource"/> because not every source has the notion: sidecars
50+
/// are a property of how content was published, and a source over content that was not published
51+
/// that way has nothing to offer. The middleware asks for this and falls back to the plain lookup
52+
/// when a source does not implement it.
53+
/// </para>
54+
/// </summary>
55+
public interface IPrecompressedFileSource : IStaticFileSource
56+
{
57+
/// <summary>
58+
/// Resolves a path, preferring a sidecar whose coding appears in
59+
/// <paramref name="acceptedEncodings"/>. Returns the original file when there is no usable one,
60+
/// so a caller never has to try both.
61+
/// </summary>
62+
bool TryGetFile(string relativePath, IReadOnlyList<string>? acceptedEncodings, out StaticFile file);
63+
}
64+
4565
/// <summary>
4666
/// Serves files from a directory on disk.
4767
/// <para>
4868
/// Every resolved path is checked to be inside the root after normalization *and* after following
4969
/// links, because a symlink is the one way a path that looks contained can leave.
5070
/// </para>
5171
/// </summary>
52-
public sealed class PhysicalFileSource : IStaticFileSource
72+
public sealed class PhysicalFileSource : IPrecompressedFileSource
5373
{
5474
readonly string root;
5575

@@ -293,15 +313,27 @@ static IEnumerable<string> CandidatePaths(string resourceSuffix)
293313
/// picked up without a rebuild, embedded resources behind it so the packaged app still works.
294314
/// </para>
295315
/// </summary>
296-
public sealed class CompositeFileSource(params IStaticFileSource[] sources) : IStaticFileSource
316+
public sealed class CompositeFileSource(params IStaticFileSource[] sources) : IPrecompressedFileSource
297317
{
298318
readonly IStaticFileSource[] sources = sources ?? throw new ArgumentNullException(nameof(sources));
299319

300320
public bool TryGetFile(string relativePath, out StaticFile file)
321+
=> this.TryGetFile(relativePath, acceptedEncodings: null, out file);
322+
323+
/// <summary>
324+
/// Asks each source in turn, letting the ones that understand sidecars offer theirs. Without
325+
/// this the composite would flatten every source it holds down to the plain lookup, and putting
326+
/// a directory in front of a published archive would quietly cost the precompressed assets.
327+
/// </summary>
328+
public bool TryGetFile(string relativePath, IReadOnlyList<string>? acceptedEncodings, out StaticFile file)
301329
{
302330
foreach (var source in this.sources)
303331
{
304-
if (source.TryGetFile(relativePath, out file))
332+
var found = acceptedEncodings is { Count: > 0 } && source is IPrecompressedFileSource precompressed
333+
? precompressed.TryGetFile(relativePath, acceptedEncodings, out file)
334+
: source.TryGetFile(relativePath, out file);
335+
336+
if (found)
305337
return true;
306338
}
307339

src/Shiny.Net.HttpServer/StaticFiles/StaticFileMiddleware.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ bool TryResolveFile(string path, IReadOnlyList<string>? accepted, out StaticFile
140140
{
141141
contentType = string.Empty;
142142

143-
var found = accepted is { Count: > 0 } && this.options.Source is PhysicalFileSource physical
144-
? physical.TryGetFile(path, accepted, out file)
143+
var found = accepted is { Count: > 0 } && this.options.Source is IPrecompressedFileSource precompressed
144+
? precompressed.TryGetFile(path, accepted, out file)
145145
: this.options.Source.TryGetFile(path, out file);
146146

147147
if (!found)

0 commit comments

Comments
 (0)