Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!
HTTP Server Releases
1.0.0 TBD
Section titled “1.0.0 TBD”Feature
Route constraints gained the types that were missing and a way to bound a value:
byte, short, float, datetime, dateonly, timeonly, timespan, plus min(n), max(n) and range(a,b). The integer widths are real filters rather than synonyms for int — {id:byte} does not match 300 — and the temporal ones parse with the invariant culture so a route means the same thing wherever the server runs. min, max and range compare the value, where the existing minlength/maxlength/length count characters, and their arguments may be negative. Both the runtime matcher and the compile-time validator in the endpoint generator learned the new vocabulary together, and the parity tests that hold those two implementations in step now cover every name. Worth restating, because the new names invite the confusion: a constraint decides whether a route matches and never converts anything — binding a segment to the handler’s type is the binder’s job and already handles every IParsable<T>, so a refused segment is a 404 while one that matched but will not parse is a 400.Feature
New package: Shiny.Net.HttpServer.Mediator publishes Shiny.Mediator requests, commands and stream requests as HTTP endpoints. It is the
Shiny.Mediator.AspNet shape — [MediatorHttpGroup] on the handler, [MediatorHttpGet/Post/Put/Patch/Delete] on Handle — without ASP.NET Core, so it runs inside a .NET MAUI app. 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 is annotated RequiresDynamicCode. Here a source generator writes the binding out member by member at compile time, so a contract that cannot be bound is a build error rather than a 500, and the whole path survives trimming. GET and DELETE bind each member from a route token or the query string; POST, PUT and PATCH read the contract from the JSON body and then apply any route token over the top, so PUT /widgets/{id} behaves as expected. An ICommand answers with a status code and no body (204 by default, configurable per endpoint); an IStreamRequest<T> becomes a Server-Sent Events response. Per-endpoint authorization, CORS, rate limiting, IP filtering and OpenAPI metadata are all declarable on the group or the endpoint. The generator ships inside the package rather than beside it, so there is no second reference to forget.BREAKING
QuickTunnel now defaults to QuickTunnelHost.Pinggy instead of QuickTunnelHost.LocalhostRun, because none of the previous presets could actually produce a working link unattended. localhost.run never confirms the SSH session request that carries the assigned URL — the ssh binary does not wait for that confirmation, SSH.NET does, and the channel types needed to work around it are internal to SSH.NET. tuns.sh refuses any key not enrolled at pico.sh, and the preset sent no key at all. serveo.net is frequently unreachable. Pinggy needs no account and reports its address in about two seconds; the new SshTunnelOptions.UseEphemeralKey generates the key it asks for in memory, so nothing has to be provisioned. LocalhostRun remains as a preset for accounts with a known custom domain, set through PublicUrl. Calls that named a host explicitly are unaffected.Fix
A quick tunnel no longer reports a URL it read out of the provider’s welcome banner.
UrlPattern defaulted to the first https:// address in the session output, and every hosted provider greets you with links to its own documentation, dashboard and social media before announcing your address — on localhost.run that greeting arrives on the channel’s error stream, ahead of the address and in the same read. The tunnel would come up reporting something like https://admin.localhost.run/, which an app would display as the link to share. Each preset now carries a pattern anchored to its own tunnel domain, and the captured value has surrounding punctuation stripped rather than just trailing sentence marks.Fix
SshTunnelProvider.BindAsync no longer stalls for the whole connect timeout when the endpoint does not answer the session request. Opening the session channel is a blocking call inside SSH.NET that waits for the server to confirm it, and it sat outside the UrlCaptureTimeout window that was supposed to bound the capture — so an endpoint that never confirms one (localhost.run does not) held the caller for ~30 seconds instead of 15. UrlCaptureTimeout now bounds the entire capture, and the warning names the cause rather than reporting a generic timeout.Fix
A tunnel that connects without learning a public address now says so instead of inventing one.
PublicUrl fell back to http://{host}:{remotePort} whenever capture failed, which is right for a forward whose public face you already know and, for a hosted tunnel, is a link to the provider’s own front page — QuickTunnel then reported State = Connected against it and a UI showed it as the address to share. When CaptureUrlFromSession is on and nothing is captured, PublicUrl stays null; QuickTunnel.StartAsync returns null, sets State = Failed, and puts the reason in LastError.Enhancement
Shiny.Net.HttpServer.Mcp is now trim- and AOT-clean, like every other package in the repo. The trim and AOT analyzers are enabled on it and it publishes clean under PublishAot, so an MCP server can be hosted inside a .NET MAUI app on iOS and Mac Catalyst where trimming is not optional. The package previously opted out on the grounds that the MCP SDK discovered tools by reflection; as of MCP SDK 2.0 and Microsoft.Extensions.AI 10.8 nothing in the dependency graph requires dynamic code, and the generic
WithTools<T>() overload is annotated for trimming rather than excluded from it.Enhancement
MapMcp() now explains what to do when a tool’s parameter or return types cannot be described in a trimmed app. Building a tool’s JSON schema by reflection does not survive trimming, and the underlying failure was a bare NotSupportedException thrown from deep inside the container. It is now an InvalidOperationException naming the type and showing the JsonSerializerContext to add, with the original exception kept as the inner. Tools whose parameters and results are only primitives are unaffected and need no context.

