- name
- shiny-httpserver
- description
- Generate code using Shiny.Net.HttpServer — a dependency-light, AOT/trim-clean HTTP/1.1, HTTP/2 & HTTP/3 server that runs anywhere .NET runs, including .NET MAUI and native tvOS, where ASP.NET Core cannot. Covers routing, middleware, source-generated typed endpoints, results and JSON, content negotiation with XML/MessagePack/protobuf formatters in both directions, static files and Blazor WASM, uploads/downloads, WebSockets, SSE, SignalR-style switchboards (real-time two-way calls over SSE with groups, an operator, client results, resumable lines, and a generated typed .NET client), sessions, OpenAPI, authentication (Basic/API key/cookie/JWT), authorization, CORS, rate limiting, IP filtering, TLS and self-signed certificates, tunnelling (relay, SSH, quick tunnels, Azure Relay, and supervised cloudflared/ngrok/tailscale agents), serving a directory over WebDAV, serving gRPC and gRPC-Web, hosting an MCP server with RFC 9728 OAuth discovery, receiving the OAuth/OIDC loopback redirect so a desktop app or CLI can sign in through the system browser (RFC 8252), health checks, OpenTelemetry-shaped metrics and tracing, W3C access logs, request timeouts, output caching and conditional requests, request decompression, antiforgery and browser security headers, a reverse proxy with destination clusters, load balancing, health checks, session affinity, transforms, WebSocket forwarding and IConfiguration-driven routes, mDNS/Bonjour advertising and discovery, MAUI lifecycle (background/foreground, Android foreground service, network rebinding), host filtering against DNS rebinding, request localization, webhook signature verification (GitHub/Stripe/Slack/Standard Webhooks), idempotency keys, RFC 9530 content digests, 103 Early Hints, the PROXY protocol, RFC 6902 JSON Patch, API versioning with per-version OpenAPI, a Scalar API reference page, tus resumable uploads, CalDAV/CardDAV, a private NuGet feed (V3 protocol with push), a private npm registry (publish/install/dist-tags/deprecate/unpublish with npmjs.org fall-through), Dropbox-style two-way folder sync with content-defined chunking and a Shiny.Net.Http background-transfer client, automatic HTTPS via ACME (Let's Encrypt/ZeroSSL), and an in-memory test harness.
- auto_invoke
- true
- triggers
- ["Shiny.Net.HttpServer","HttpServer","embedded http server","http server in MAUI","http server on tvOS","tvos","Apple TV","web server on device","HttpServerOptions","ShinyHttpServerBuilder","AddShinyHttpServer","AddHttpServer","MapGet","MapPost","OnRequest","IHttpMiddleware","IResponseBodyControl","request logging","traffic recorder","capture request body","capture response body","read body twice","RequestDelegate","HttpContext","RouteAttribute","IHttpEndpoint","IEndpointModule","MapMyAppEndpoints","FromRoute","FromQuery","FromBody","FromServices","[Truncated]"]
# Shiny HTTP Server Skill
## Triggers
- Shiny.Net.HttpServer
- embedded / in-app HTTP server
- HTTP server in a .NET MAUI app
- serving a web UI or API from a device
- tunnelling a device server to the internet
- hosting an MCP server without ASP.NET Core
You are an expert in `Shiny.Net.HttpServer`, a dependency-light HTTP server for .NET.
## When to Use This Skill
Invoke this skill when the user wants to:
- Serve HTTP from an app that cannot use ASP.NET Core (.NET MAUI, single-file, embedded, AOT)
- Add routes, middleware or typed endpoints to a `Shiny.Net.HttpServer` app
- Serve static files, a Blazor WebAssembly app, or a device's file system over HTTP
- Expose a folder as a WebDAV mount so it appears as a drive in Finder or Windows Explorer
- Read or write bodies in XML, MessagePack, protobuf or a format of their own instead of JSON
- Add authentication, authorization, CORS, rate limiting or IP filtering to that server
- Make a device-local server reachable from the internet through a tunnel
- Serve gRPC or gRPC-Web from an app that cannot run ASP.NET Core
- Host a Model Context Protocol server inside a non-ASP.NET app, including one a remote client must
authenticate to
- Find or be found by another device on the same network without anyone typing an IP address
- Keep an embedded server working as a phone is backgrounded, resumed, or moved between networks
- Push to clients and let them call back, SignalR-style (a switchboard), including from a MAUI app,
with a .NET client that resumes after a drop
- Report health, metrics or traces from an embedded server
- Cache responses, honour conditional requests, bound how long a handler may take, or forward a
route to another server
- Test endpoints without binding a port
- Host a private NuGet feed that `dotnet nuget push` and `dotnet restore` talk to
- Host a private npm registry that `npm publish` / `npm install` (and pnpm, Yarn) talk to
- Sync a folder between devices and a server, Dropbox/OneDrive-style, moving only changed bytes
- Sign a desktop app or CLI in through the system browser (the OAuth/OIDC loopback redirect,
what VS Code and `gh auth login` do) without `HttpListener`
**Do not** use this skill for ASP.NET Core / Kestrel / minimal APIs. Those are a different library
with similar-looking names.
## Library Overview
**Documentation**: https://shinylib.net/httpserver
Only `Microsoft.Extensions.*` abstractions are taken as dependencies. Everything else — JSON, crypto,
JWT, OpenAPI, HPACK, QPACK — is in the box. Everything targets `net10.0` with the trim, AOT and
single-file analyzers on.
**The single hard rule: nothing is discovered by reflection.** Routes and binders are generated at
compile time; JSON goes through `JsonTypeInfo` from a `JsonSerializerContext`. Any code you generate
must hold that line, or it fails on a trimmed device build.
### Packages
```bash
dotnet add package Shiny.Net.HttpServer # the server + the typed-endpoint generator
dotnet add package Shiny.Net.HttpServer.Jwt # JWT auth
dotnet add package Shiny.Net.HttpServer.Proxy # reverse proxy: clusters, LB, health, transforms
dotnet add package Shiny.Net.HttpServer.Ssh # SSH + quick tunnels
dotnet add package Shiny.Net.HttpServer.AzureRelay # Azure Relay tunnel (NOT AOT-clean)
dotnet add package Shiny.Net.HttpServer.Mcp # Model Context Protocol transport
dotnet add package Shiny.Net.HttpServer.Mediator # Shiny.Mediator handlers as endpoints
dotnet add package Shiny.Net.HttpServer.DocumentDb # Shiny.DocumentDb types as REST resources
dotnet add package Shiny.Net.HttpServer.WebDav # a directory as a WebDAV mount (RFC 4918)
dotnet add package Shiny.Net.HttpServer.Grpc # gRPC + gRPC-Web services
dotnet add package Shiny.Net.HttpServer.Discovery # mDNS/Bonjour advertise + browse
dotnet add package Shiny.Net.HttpServer.Mobile # mobile lifecycle (multi-targets android/ios/maccatalyst; not tvOS)
dotnet add package Shiny.Net.HttpServer.Testing # in-memory HttpClient for tests
dotnet add package Shiny.Net.HttpServer.Tunnels # cloudflared / ngrok / tailscale agents (desktop + CLI only)
dotnet add package Shiny.Net.HttpServer.OAuthLoopback # OAuth/OIDC loopback redirect receiver (desktop + CLI only)
dotnet add package Shiny.Net.HttpServer.Tus # tus 1.0.0 resumable uploads
dotnet add package Shiny.Net.HttpServer.NuGet # a private NuGet V3 feed (push, restore, search)
dotnet add package Shiny.Net.HttpServer.Npm # a private npm registry (publish, install, npmjs fall-through)
dotnet add package Shiny.Net.HttpServer.FileSync # Dropbox-style sync server (chunk store + change feed)
dotnet add package Shiny.Net.HttpServer.FileSync.Client # its client: two-way folder sync over Shiny.Net.Http
dotnet add package Shiny.Net.HttpServer.CalDav # calendars & contacts over CalDAV/CardDAV
dotnet add package Shiny.Net.HttpServer.Acme # automatic HTTPS via Let's Encrypt/ZeroSSL (public hosts, not phones)
dotnet tool install -g Shiny.Net.HttpServer.CommandLine # `shinyhttpserver`, not a library reference
```
`Shiny.Net.HttpServer.CommandLine` is a .NET tool, not something an app references: it serves a
directory over HTTP from a terminal (`shinyhttpserver [path] -m read|create|update|delete|all
-u user:password`). Reach for it when the ask is "serve this folder", not "add a server to my app".
It mounts the directory over **WebDAV**, so one address is both a browser file manager (browse,
upload, rename, delete — whatever `-m` allows) and a drive Finder, Explorer or a Linux file manager
can mount. Scripting it is WebDAV: `GET`/`PUT`/`DELETE` as usual, `MKCOL` for a directory, `MOVE`
for a rename, and `PROPFIND` with `Depth: 1` for a machine-readable listing — a `GET` on a directory
returns the manager's HTML, not JSON. `-m` is enforced before the handler runs, across `MKCOL`,
`COPY` and `MOVE` as well as `PUT`; a `MOVE` is judged by where it lands, and renaming needs
`update` **and** `delete`.
It listens on every interface by default and ends its banner with a scannable QR code of the LAN
address plus the URL in full, so "get this folder onto my phone" is the tool answer, not code —
`-a localhost` keeps it to the machine, `--no-qr` drops the code. Basic auth (`-u`) over plain HTTP
refuses to start on a non-loopback address, which the default now is: pair it with `--https`.
`--tunnel` is the answer to "share this folder with someone not on my network": it opens a
`QuickTunnel` to pinggy.io and the QR code carries the public HTTPS address instead of the LAN one.
Because the tunnel feeds `HttpServer.ServeAsync` directly, `--tunnel -a localhost` binds nothing on
the LAN and is reachable only through the tunnel — and a tunnelled connection counts as encrypted
transport, so `-u` works over it without `--https` or `--allow-insecure-auth`. Anonymous tunnels stop
after 60 minutes; `--tunnel-token <token>` lifts that and implies `--tunnel`. Always say that the
address is public when you suggest it.
In a terminal, `shinyhttpserver` opens a full-screen **dashboard** rather than printing the banner:
Overview (addresses, tunnel, warnings, QR code), Requests (every request, newest first, including
`401`s and tunneled clients; `Enter` for headers, `Ctrl+L` clears), Transfers (uploads and downloads
in flight with progress, speed and time left), Settings (every flag as a form, applied live with
`Ctrl+S`), and Log. So "watch who is hitting this folder" or "see how far the upload got" is the tool
answer too. Settings changes to the tunnel, its token, the QR code or the log level apply in place;
anything else rebuilds the server, and the dashboard asks first when that would cut off a transfer
and rolls back if the new settings will not start. `--no-tui` keeps the plain banner, and a
redirected run (a script, a pipe, a service) gets the banner whatever the flags say, so suggest
`--no-tui` only when a user wants plain output in an interactive terminal. The dashboard is part of
the tool, not a library API, so it sits outside the four tiers below. An app that wants the same
request log writes a middleware (tier 3) that wraps `Request.Body` and `Response.BodyControl`, the
way the tool does.
## The four tiers — the spine of this library
Every new API belongs to one of these. Say which when you introduce one. They compose in one app.
| Tier | What it is | Use when |
| --- | --- | --- |
| 0 | `OnRequest(ctx => …)` — one delegate, no routing | A single handler, a test fixture, a fallback |
| 1 | `MapGet`/`MapPost`/… — raw handlers behind a route template | A handful of routes, no binding wanted |
| 2 | `Use(...)` / `IHttpMiddleware` — the pipeline | Cross-cutting work |
| 3 | `[Route]` classes + the source generator | Anything real: typed parameters, DI, OpenAPI |
**Default to tier 3** when the user has more than a couple of endpoints or wants typed parameters.
Default to tier 1 for small, script-like servers. Never suggest reflection-based alternatives.
## Setup
Choose the host shape from who owns the container:
```csharp
// (a) No container — smallest possible
var server = new HttpServer(new HttpServerOptions { Port = 8080 });
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
await server.RunAsync();
// (b) The builder — a console app or service that wants DI
var builder = HttpServer.CreateBuilder();
builder.Options.Port = 8080;
builder.AddAuthentication().AddJwtBearer(o => { o.Issuer = "app"; o.Audience = "app"; o.SigningKey = key; }); // server features: on the builder
builder.Services.AddSingleton<IWidgetStore, WidgetStore>(); // your own: on Services
var app = builder.Build();
app.MapMyAppEndpoints();
await app.RunAsync();
// (c) An existing container — MAUI, generic host. Same builder, same calls.
services.AddShinyHttpServer(
http =>
{
http.Options.Address = IPAddress.Any;
http.Options.Port = 0;
http.AddAuthentication().AddBasic<CredentialStore>(o => o.Realm = "Device");
http.Configure(server => server.MapMyAppEndpoints());
},
autoStart: false // for an app with a "share" toggle
);
```
**Every registration in this library is an extension on `ShinyHttpServerBuilder`, not on
`IServiceCollection`** — `AddAuthentication`, `AddCors`, `AddRateLimiter`, `AddHealthChecks`,
`AddOutputCache`, `AddSessions`, the tunnels, discovery, the mobile lifecycle. Generate
`builder.AddX(...)`, never `builder.Services.AddX(...)`, for anything this library owns;
`builder.Services` is for the app's own registrations. The one exception is
`services.AddHttpServerLocator()`, which is the client half of mDNS and has no server to hang off.
`http.Configure(server => ...)` is where routes and middleware go in shape (c) — it runs when the
server is first resolved, so it can pull things out of the container.
`autoStart: false` + `server.StartAsync()` from the UI is the right shape for MAUI. `Port = 0` lets
the OS pick; read it back from `server.ListenUrl`.
### Defaults worth knowing
- Binds **loopback** by default. Set `Address = IPAddress.Any` for LAN access — deliberately.
- `Limits.MaxRequestBodySize` is 30 MB, the default for every route. For uploads, raise it **on the
upload route** (`.WithRequestSizeLimit(bytes)` / `[RequestSizeLimit(bytes)]`), not server-wide —
see Content. HTTP/3 is always held to the server-wide value.
- `HideExceptionDetails` is on; turn it off in development only.
### Behind a TCP load balancer: PROXY protocol
Listener configuration, underneath all four tiers — handler code only reads `ctx.Connection`.
```csharp
builder.Options.Listen(IPAddress.Any, 8080)
.UseProxyProtocol(p => p.Trust("10.0.0.0/8")); // the BALANCER's addresses, not clients'
// single endpoint: builder.Options.ProxyProtocol = new ProxyProtocolOptions { Mode = ProxyProtocolMode.Required }.Trust("10.0.0.0/8");
```
- v1 and v2 are both accepted. The header is read before TLS, so TLS passthrough works. `ctx.Connection.RemoteIpAddress`/`RemotePort` become the client's, and the IP filter, rate limiter and W3C logs need nothing else.
- `TrustedProxies` must not be empty, or the server refuses to start. Only generate `.Trust("0.0.0.0/0").Trust("::/0")` when the user says the port is firewalled so only the balancer can reach it.
- `Required` (the default for `UseProxyProtocol`) closes connections that have no header. Use `Optional` only while migrating.
- `ctx.Connection.ProxyProtocol` (null if none): `ProxyEndPoint`, `DestinationEndPoint`, `Alpn`, `Authority`, `UniqueId`, `Ssl`, `GetTlv(0xEA)`.
- Prefer this over `UseForwardedHeaders`: it can't be spoofed by clients and it works with TLS passthrough. `UseForwardedHeaders` trusts **any** sender (no trusted-proxy list) and applies to HTTP/1.1 only — never generate it for a server that faces the internet directly. Don't enable both unless the balancer overwrites `X-Forwarded-For`, because that header overrides the PROXY address on HTTP/1.1.
- TCP endpoints only: not HTTP/3, not tunnel providers. The reverse proxy does not send a PROXY header.
## Lifecycle — and knowing *why* the server stopped
Start and stop are runtime operations, not just process startup and shutdown. An app with a toggle
flips this switch repeatedly, so the transitions are serialized, idempotent and restartable.
```csharp
await server.StartAsync(); // binds and begins accepting
await server.StopAsync(); // unbinds, then drains in-flight requests
await server.RestartAsync(); // both as one operation, re-reading Options
```
`StateChanged` gives a UI its transitions. **`StateTransitioned` is what to generate whenever the app
needs to diagnose a server that went down** — it carries an `HttpServerStateChange` with the reason
and the exception:
```csharp
server.StateTransitioned += (_, change) =>
{
// Restarting is the stop half of a rebind — a start is coming, so leave the UI alone.
if (change is { State: HttpServerState.Stopped, Reason: not HttpServerStateReason.Requested and not HttpServerStateReason.Restarting })
logger.LogError(change.Exception, "The server went down: {Reason}", change.Reason);
};
```
| `HttpServerStateReason` | Meaning |
| --- | --- |
| `Requested` | The app called `StartAsync`/`StopAsync`. The only reason that is never a fault |
| `Restarting` | Part of a `RestartAsync` — **including the stop half**, so a subscriber knows a start is coming |
| `NetworkChanged` | A rebind driven by the addresses changing |
| `BindFailed` | The bind was refused and the retries are spent; `Exception` says why |
| `ListenerFaulted` | The listener stopped accepting while the server believed it was running |
| `Disposed` | The server was disposed |
`server.LastStateChange` is the same record, for code that was not subscribed at the time — a crash
report, a diagnostics screen, a background task that woke up to find the server down.
**Resilience is on by default; do not generate an opt-in for it.** A transient accept failure is
retried with backoff; a listener that dies underneath a running server is logged at error, reported
as `ListenerFaulted`, and rebound; the start half of a restart or a network rebind retries
(`StartRetryAttempts`, default 5). A plain `StartAsync` is deliberately **not** retried — its caller
gets the exception. Tune with `RecoverFromListenerFaults`, `StartRetry*` and `AcceptRetry*` only when
asked.
Handlers on `StateChanged`, `StateTransitioned` and `NetworkAddressesChanged` are isolated: one that
throws is logged and the rest still run. Do not wrap them in defensive try/catch of your own.
## Tier 3: typed endpoints (preferred)
```csharp
[Route("/api/widgets")]
public class WidgetEndpoints(IWidgetStore store, ILogger<WidgetEndpoints> logger)
{
/// <summary>Fetches a widget.</summary> // becomes the OpenAPI summary
[Get("/{id:int}")]
[Produces(200, typeof(Widget))]
[Produces(404)]
public async Task<IActionResult> GetWidget(int id, CancellationToken ct)
=> await store.FindAsync(id, ct) is { } w ? new OkObjectResult(w) : new NotFoundResult();
[Get]
public async Task<IReadOnlyList<Widget>> List(int take = 10, string? search = null,
CancellationToken ct = default) => await store.ListAsync(take, search, ct);
[Post]
public async Task<IActionResult> Create(CreateWidget request, CancellationToken ct)
=> new CreatedResult($"/api/widgets/{(await store.AddAsync(request.Name, ct)).Id}");
}
app.MapWidgetEndpoints(); // one class
app.MapMyAppEndpoints(); // every [Route] class in the assembly (name = assembly name)
```
Rules to follow when generating endpoint classes:
1. Use **primary constructors** for dependencies — they are resolved from the request scope.
2. Class must be `public` or `internal`, non-static, non-abstract, non-generic (SWS004).
3. Verb attributes: `[Get]`, `[Post]`, `[Put]`, `[Delete]`, `[Patch]`, or `[HttpMethod("VERB", "/t")]`.
4. `[NonEndpoint]` excludes a public method.
5. Always accept and pass `CancellationToken`.
### Binding conventions (do not add attributes when the convention already fits)
In order: ambient types → route token → query → JSON body → container.
- `HttpContext`, `HttpRequest`, `HttpResponse`, `CancellationToken` are handed over directly.
- A parameter whose name matches a route token **and** whose type is `IParsable` binds from the route.
- Anything else `IParsable` (plus enums, nullables, and arrays of those) binds from the query.
- A complex type on a body-carrying verb binds from JSON — **at most one per method** (SWS007).
- Everything else comes from the container.
Overrides: `[FromRoute]`, `[FromQuery]`, `[FromHeader]`, `[FromBody]`, `[FromServices]`, each with an
optional `Name`.
A default value makes a parameter optional. Bind failures are 400s naming the parameter and type,
raised before the method is called.
### JSON Patch (RFC 6902)
Tier 3 (parameter binding); `ReadJsonPatchAsync` serves tier 1/2 raw handlers.
A `JsonPatchDocument` parameter binds from an `application/json-patch+json` body. Anything else is a
415 with `Accept-Patch`, and a malformed patch is a 400. No `[JsonSerializable]` entry is needed for it.
Apply it with the target type's `JsonTypeInfo`, never by reflection:
```csharp
View on GitHub