Introducing Shiny.Net.HttpServer — HTTP/1.1, HTTP/2 & HTTP/3 Anywhere .NET Runs
ASP.NET Core is heavyweight and does not run on .NET MAUI, or in a number of embedded server scenarios. That is the gap this fills.
Shiny.Net.HttpServer is a dependency-light, AOT- and trim-clean HTTP/1.1, HTTP/2 and HTTP/3 server that runs anywhere .NET runs — plus tunnelling, so a server embedded in a phone app is reachable from the public internet. It is available today in beta.
var server = new HttpServer(new HttpServerOptions { Port = 8080 });server.OnGet("/ping", ctx => ctx.Response.WriteAsync("pong"));await server.RunAsync();Only Microsoft.Extensions.* abstractions are taken as dependencies. Everything else — JSON, crypto,
JWT, OpenAPI, HPACK, QPACK — is built on what is in the box. Every shipping project has the trim, AOT
and single-file analyzers enabled, so “AOT-clean” is enforced by the build rather than claimed in a
readme.
The packages
Section titled “The packages”| Package | Description |
|---|---|
| The server: protocols, routing, middleware, DI scopes, static files, WebSockets, SSE, sessions, OpenAPI, CORS, rate limiting, IP filtering, tunnelling — plus the compile-time typed-endpoint generator | |
JWT authentication on in-box crypto — no Microsoft.IdentityModel dependency |
|
| SSH remote-forwarding tunnel provider, including zero-account quick tunnels | |
| Azure Relay tunnel provider | |
| Model Context Protocol (Streamable HTTP) transport — host an MCP server without ASP.NET Core | |
| gRPC and gRPC-Web — unary, client-, server- and bidirectional streaming. Marshalling is supplied, so nothing reflects over your messages | |
| A directory as an RFC 4918 class 1 & 2 WebDAV mount | |
| Shiny.Mediator requests, commands and streams as endpoints, bound at compile time | |
| A Shiny.DocumentDb document type as a complete HTTP resource |
dotnet add package Shiny.Net.HttpServerThe typed-endpoint generator ships inside that package under analyzers/ — it runs inside the
compiler and never lands in your output.
Four tiers, one app
Section titled “Four tiers, one app”Each tier is built on the one below, and they compose in the same server. Pick the altitude that suits the endpoint rather than the framework’s opinion.
Tier 0 — a single delegate. No routing at all.
server.OnRequest(async ctx => await ctx.Response.WriteAsync("hello"));Tier 1 — routing. Templates with constraints, mutable at runtime.
server.OnGet("/api/users/{id:int}", async ctx => { /* … */ });Tier 2 — middleware. ASP.NET-shaped.
server.UseAuthentication();server.UseEmbeddedFiles(typeof(App).Assembly, "MyApp.wwwroot");Tier 3 — source-generated typed endpoints. Constructor injection, typed parameter binding, and OpenAPI metadata, all emitted at compile time.
[Route("/api/users")]public class UserEndpoints(IUserService users, ILogger<UserEndpoints> logger){ [Get("/{id:int}")] public async Task<IActionResult> GetUser(int id, CancellationToken ct) => await users.FindAsync(id, ct) is { } u ? new OkObjectResult(u) : new NotFoundResult();}
app.MapMyAppEndpoints(); // emitted for every [Route] class in the assemblyResults come in both spellings — Results.* and IActionResult.
What is in the box
Section titled “What is in the box”| Protocols | HTTP/1.1, HTTP/2 (own HPACK), HTTP/3 (own QPACK), WebSockets, SSE. Never guessed — ALPN over TLS, connection preface over cleartext |
| Content | Static files from disk or embedded resources, a published Blazor WASM app, streaming multipart uploads, byte ranges and conditional GETs, a file browser, and brotli/gzip/deflate |
| Security | Authentication and authorization split ASP.NET-style — Basic, API key, cookie and JWT schemes, policies, roles and claims, plus CORS, rate limiting and IP filtering |
| TLS | Per-endpoint TLS, self-signed certificates generated in managed code (iOS and Android included), client certificates, and SPKI pinning for your own HttpClient |
| OpenAPI | An OpenAPI 3.0.3 document built entirely from compile-time metadata and your JsonSerializerContext — no reflection, no document object model |
| Formats | Content negotiation both ways — responses from Accept, request bodies from Content-Type. JSON built in; XML, MessagePack and protobuf one line each |
| Tunnelling | A pluggable ITunnelProvider, the reference relay, SSH remote forwarding, quick tunnels, and Azure Relay |
| Lifecycle | Start, stop and restart at runtime, serialized and idempotent, with observable state |
Route constraints cover byte, short, float, the temporal types and value
bounds ({page:range(1,100)}), and a constraint decides whether a route matches rather than
converting anything — so a refused segment is a 404 and one that matched but will not parse is a 400.
Responses can also send trailing headers on all three protocol versions.
Reaching a device from the internet
Section titled “Reaching a device from the internet”A cellular device sits behind carrier-grade NAT with no routable address and no port to forward, and an agent-based tunnel like ngrok or cloudflared cannot run on iOS at all — there is no process to spawn. So tunnelling is built in, and every provider works the same way: the device opens an outbound connection and asks the far end to forward traffic back down it.
The zero-ceremony version needs no account and nothing installed:
builder.Services.AddHttpServer(autoStart: false, configureServer: s => s.MapGet("/", …));builder.Services.AddQuickTunnel();
// then, from a button:var url = await tunnel.StartAsync();QuickTunnel is INotifyPropertyChanged with PublicUrl, State and LastError, so a view binds
directly to it. Bind to PublicUrl rather than reading it once — a free tunnel assigns a new
address on every reconnect, and a phone reconnects whenever it changes network. See
SSH & Quick Tunnels for the host presets and what each one requires.
MCP without ASP.NET Core
Section titled “MCP without ASP.NET Core”The MCP SDK’s own HTTP transport is an ASP.NET Core package, which makes this the piece that genuinely could not be done any other way — an MCP server hosted inside a .NET MAUI app.
builder.Services .AddMcpServer(o => o.ServerInfo = new() { Name = "thermostat", Version = "1.0.0" }) .WithTools<ThermostatTools>() .WithHttpTransport();
var app = builder.Build();app.MapMcp(); // POST/GET/DELETE/OPTIONS on /mcpOne thing the compiler cannot check for you: a tool’s parameter and return types are published to the
client as a JSON schema, and building that schema by reflection does not survive trimming. Tools that
trade only in primitives need nothing extra — give the rest a source-generated context, and
MapMcp() will name any type you missed at startup.
[JsonSerializable(typeof(Query))][JsonSerializable(typeof(IReadOnlyList<Reading>))]public partial class ToolJson : JsonSerializerContext;
.WithTools<ThermostatTools>(ToolJson.Default.Options)Full detail on MCP.
Formats beyond JSON
Section titled “Formats beyond JSON”JSON is the default and covers most of an HTTP API. For the rest — an integration behind a corporate
gateway that speaks XML, a battery-powered client that would rather not spend 40% more radio time on
braces, a service already generating protobuf from a .proto — formats plug in on both sides.
IOutputFormatter writes responses; IInputFormatter reads request bodies.
builder.Services.AddContentNegotiation(o =>{ o.NegotiateByDefault = true; // Results.Ok(value) honours Accept o.AddXml(); o.AddMessagePack();});That is the whole setup, and no endpoint changes. Request bodies are dispatched on Content-Type,
so every existing [FromBody] parameter, mediator contract and hand-written handler accepts the
registered formats immediately; responses are chosen from Accept.
XmlSerializer could not be used for the XML side at all — it builds its mapping by reflecting over
the type at runtime, which is precisely what a trimmed or AOT-published app has thrown away. The same
goes for MessagePack-CSharp’s default resolver. Instead both read the source-generated JsonTypeInfo
your types already have, so:
- No new dependency, and no
[XmlRoot],[DataMember]or[MessagePackObject]on your DTOs. - A type gets an XML or MessagePack representation on exactly the same terms as JSON — its metadata was registered.
- The representations cannot drift apart, because the property names and converters have one source.
Reading XML is type-directed for the same reason it has to be. XML has no types:
<postalCode>01234</postalCode> is text, and only the target member knows whether that is a string, a
number or an enum ordinal. Guessing from the text is how a postal code arrives as the number 1234.
Protobuf needs a schema, so its codecs are supplied rather than discovered — field numbers live in the
.proto, and the only thing that has them is the code protoc already generated:
o.AddProtobuf(p => p.Add<Reading>(m => m.ToByteArray(), Reading.Parser.ParseFrom));The same BinaryCodecRegistry carries CBOR, Avro, or MessagePack-CSharp’s native codec under whichever
media type you register it as. One behaviour change to note: a body whose Content-Type nothing reads
now answers 415 rather than 400 — the difference between sending a caller to fix a header and
sending them hunting for a syntax error that is not there. Full detail on
Serialization & Formats.
gRPC and gRPC-Web
Section titled “gRPC and gRPC-Web”gRPC runs over the same HTTP/2 stack as everything else, callable from
Grpc.Net.Client, grpcurl or any gRPC client in any language — and therefore from a .NET MAUI app,
where ASP.NET Core cannot run at all.
app.MapGrpcService("greet.Greeter", svc =>{ svc.AddMarshaller<HelloRequest>(m => m.ToByteArray(), HelloRequest.Parser.ParseFrom); svc.MapUnary<HelloRequest, HelloReply>("SayHello", (request, ctx) => …);});All four method shapes are supported, with streams as IAsyncEnumerable<T> in both directions flushed
as each message is yielded, grpc-timeout arriving as the handler’s CancellationToken, per-message
compression negotiated from grpc-accept-encoding, and status in trailers. MaxReceiveMessageSize is
enforced on the decompressed size as well as the length prefix, since a few compressed kilobytes can
otherwise expand into gigabytes.
gRPC-Web is enabled by default in both framings, which is how a browser calls in and how anything on HTTP/1.1 does. It matters more here than on a server: native gRPC needs a tunnel that forwards raw TCP, and most hosted providers terminate HTTP/1.1, so gRPC-Web is generally what reaches a device from the internet.
A directory as a mounted drive
Section titled “A directory as a mounted drive”The file browser is a JSON API you drive with curl.
WebDAV is the protocol every desktop already has a client for.
app.MapWebDav("/dav", o =>{ o.RootPath = FileSystem.AppDataDirectory; o.AllowWrite = true;}).RequireAuthorization();Point Finder or Windows Explorer at that URL and the app’s storage appears as a drive, with no client
code at all. RFC 4918 compliance classes 1 and 2 — PROPFIND, PROPPATCH, MKCOL, COPY, MOVE,
LOCK/UNLOCK and the If header.
Class 2 is on by default and is not really optional: Finder and the Windows redirector both mount a
class 1 server read-only whatever AllowWrite says. Writes and deletes are opt-in, and PROPFIND with
Depth: infinity is refused by default rather than walking a device’s storage into one response — note
that a missing Depth header means infinity per the specification, so it gets the same refusal.
Mediator and DocumentDb as endpoints
Section titled “Mediator and DocumentDb as endpoints”If you already write Shiny.Mediator handlers, they can be endpoints. It is the
Shiny.Mediator.AspNet shape without ASP.NET Core:
[MediatorHttpGroup("/api/gadgets")]public class GadgetHandlers : IRequestHandler<GetGadget, Gadget>{ [MediatorHttpGet("/{id:int}")] public Task<Gadget> Handle(GetGadget request, IMediatorContext ctx, CancellationToken ct) => …;}
app.MapGeneratedMediatorEndpoints();The binding is the part that had to change. The ASP.NET package uses [AsParameters]/[FromBody],
which is reflection over a delegate’s parameters and annotated RequiresDynamicCode; here a source
generator writes it out member by member at compile time, so a contract that cannot be bound is a build
error rather than a 500. An ICommand answers with a status code and no body; an IStreamRequest<T>
becomes a Server-Sent Events response.
And if the data lives in Shiny.DocumentDb, a document type is a complete resource in one line:
app.MapDocuments<Order>("/orders", o =>{ o.TypeInfo = AppJson.Default.Order; o.AllowFilterOn(x => x.Status, x => x.Total); o.Scope<ITenantContext>((tenant, _) => x => x.TenantId == tenant.TenantId);}).RequireAuthorization("orders");List, by-id, count, create, replace, RFC 7396 merge-patch, delete and a live SSE tail, with filtering,
cursor paging, sparse fieldsets and ETag/If-Match — plus a schema-free lane for JSON collections
with no CLR type. It is a port of Shiny.DocumentDb.AspNetCore onto this server running the same
engine, so filters, cursors, scopes and merge semantics are identical on both.
Two differences are deliberate. MapDocuments returns a builder that fans RequireAuthorization, CORS,
rate limiting and IP filtering across every route it registered, because this server attaches metadata
per route rather than per group — so adding an operation later cannot quietly leave one unprotected.
And there is no reflection fallback when TypeInfo is unset, because a fallback that works on a
desktop and throws on a trimmed phone is worse than a clear error in both places. Server-side scopes
answer 404 rather than 403 for a document outside the scope. The package requires Shiny.DocumentDb
13.2.1 or newer.
Trade-offs worth knowing up front
Section titled “Trade-offs worth knowing up front”Two packages are deliberately not AOT- or trim-clean, and they are separate packages for exactly
that reason. Shiny.Net.HttpServer.Ssh carries SSH.NET, which brings BouncyCastle and its own
algorithm registries. Shiny.Net.HttpServer.AzureRelay pulls in Azure.Identity, MSAL and
IdentityModel. Reference either and you accept the weight; leave them out and the core server stays
clean AOT.
The other seven are clean, the four newest included — which is much of why gRPC and protobuf ask for marshalling rather than discovering it, and why XML and MessagePack are written against your existing JSON metadata instead of a reflective serializer.
This is a beta. The API surface is settled and the test suite is substantial — around 1,260 tests, run against a live socket rather than an in-memory harness, since most of what a server can get wrong only exists at that boundary. But it has not yet been run in anger by a large number of people — issues and feedback are very welcome.
Finally, the security note that a tunnel makes urgent: a quick tunnel hands a public HTTPS address to anyone who learns it, pointed at a server whose defaults were chosen for loopback. Put authentication in front of it before you open one.
Getting started
Section titled “Getting started”Full documentation is at shinylib.net/httpserver, and the source — including a .NET MAUI sample that serves a page, exposes a file browser behind a password, hosts an MCP server and publishes the lot through a tunnel — is at github.com/shinyorg/httpserver.


