Hosting & Lifecycle
There are three ways to get a server, and which one you want depends on who owns the container.
Without a container
Section titled “Without a container”var server = new HttpServer(new HttpServerOptions { Port = 8080 });
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
await server.RunAsync();Everything works except ctx.RequestServices, which resolves nothing. This is the right shape for a
console tool, a test fixture, or anything small enough that a container would be ceremony.
RunAsync starts the server and waits until its cancellation token fires, then shuts down
gracefully. It is the one-liner for a console host; StartAsync/StopAsync are what an app with a
UI uses.
With the builder
Section titled “With the builder”var builder = HttpServer.CreateBuilder();
builder.Configure(o =>{ o.Port = 8080; o.HideExceptionDetails = false; // development});
// The server's own features hang off the builder…builder.AddAuthentication().AddJwtBearer(o => o.SigningKey = key);builder.AddRateLimiter(o => o.GlobalPolicy = new FixedWindowRateLimitPolicy(100, TimeSpan.FromMinutes(1)));builder.AddHealthChecks().AddServerCheck();
// …and everything else goes on the container underneath it.builder.Services.AddLogging(l => l.AddSimpleConsole());builder.Services.AddSingleton<IWidgetStore, InMemoryWidgetStore>();builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
var app = builder.Build();
app.MapMyAppEndpoints();await app.RunAsync();builder.Options is the same object Configure hands you, so either spelling works. Build() can
only be called once — it builds the provider and hands the server back.
ShinyHttpServerBuilder is where every registration in this library lives: AddAuthentication,
AddCors, AddRateLimiter, AddHealthChecks, AddOutputCache, AddSessions, the tunnels, the
mDNS advertisement, the mobile lifecycle — all of them
extend the builder rather than IServiceCollection. Typing builder. lists what this server can do;
typing services. lists what every library in the app has ever registered, and an embedded server’s
features are exactly the ones nobody knows to go looking for.
builder.Services is still right there for your own registrations, and for anything the builder does
not cover.
Logging is optional. An app that never called AddLogging gets a no-op factory rather than a
resolution failure at startup.
Inside an app that already has a container
Section titled “Inside an app that already has a container”A MAUI app, a generic host, a Shiny host — anything with an existing IServiceCollection:
builder.Services.AddShinyHttpServer(http =>{ http.Options.Port = 8080;
http.AddAuthentication().AddBasic(o => o.AddUser("ada", pw)); http.AddHealthChecks().AddServerCheck();
http.Configure(server => { server.UseAuthentication(); server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong")); server.MapMyAppEndpoints(); });});Same builder, same calls as the standalone form above — the only difference is where the container comes from. That is the point of it: there is one way to configure this server, not one for a console app and another for MAUI.
The callback runs immediately; it is registration. The Configure(server => …) callbacks inside
it run once, when the server is first resolved, so they can take anything out of the container while
registering routes.
By default the server is registered as a singleton and started with the host through an
IHostedService. Pass autoStart: false when the app should decide:
builder.Services.AddShinyHttpServer(http => http.Options.Port = 8080, autoStart: false);The server is still registered and fully configured, just not listening — which is exactly what an
app with a “share over Wi-Fi” toggle wants. Resolve it and call StartAsync when the user says
so.
Per-request dependency injection
Section titled “Per-request dependency injection”A fresh IServiceScope is created before the pipeline runs and disposed after it completes,
including IAsyncDisposable registrations. Scoped services therefore behave exactly as they do in
ASP.NET Core: one instance per request/response exchange, shared by every middleware, endpoint class
and handler involved in it.
app.MapGet("/scope", ctx =>{ var a = ctx.GetRequiredService<RequestId>(); var b = ctx.GetRequiredService<RequestId>();
// same-instance=True — and a different instance on the next request return ctx.Response.WriteAsync($"same-instance={ReferenceEquals(a, b)}");});ctx.GetRequiredService<T>() and ctx.GetService<T>() are shorthand for ctx.RequestServices.
Generated endpoint classes get constructor injection from the same scope, and middleware registered
as a type (app.Use<TMiddleware>()) is resolved per request from it too.
Lifecycle
Section titled “Lifecycle”Start and stop are ordinary runtime operations here, not just process startup and shutdown. An app with a toggle flips this switch repeatedly over one process lifetime, so the transitions are serialized against each other, idempotent, and leave the server genuinely restartable.
await app.StartAsync(); // binds and begins accepting; returns once listeningawait app.StopAsync(); // unbinds, then waits for in-flight requestsawait app.RestartAsync(); // both, as one operation, re-reading Options| Member | What it gives you |
|---|---|
State |
Stopped, Starting, Running or Stopping |
StateChanged |
Raised on every transition, on the thread that caused it |
StateTransitioned |
The same transitions, each carrying why and the exception behind it |
LastStateChange |
The transition that produced the current State, for whoever was not subscribed at the time |
IsRunning |
State == Running |
ListenUrl |
The URL being served — the real port when Port was 0 |
ListenUrls |
Every URL, when several endpoints are configured |
ActiveConnections |
Connections currently being served, tunnelled ones included — one keep-alive connection counts once however many requests it carries |
NetworkAddressesChanged |
Raised when the machine’s addresses change while running. See configuration |
Starting an already-running server does nothing rather than throwing, because the caller is often a
button and a double tap is not a bug. A failed bind returns to Stopped rather than sticking in
Starting.
app.StateChanged += (_, state) => this.Status = state.ToString();
// Port 0 lets the OS choose; read it back once running.await app.StartAsync();Console.WriteLine($"Serving on {app.ListenUrl}");A handler that throws is caught and logged, and every other subscriber is still told. A UI binding raising on the wrong thread is not allowed to be the reason a server that started successfully goes back down.
Why the server stopped
Section titled “Why the server stopped”State answers “is it up”, which is enough to draw a toggle and nothing else. The question an app
actually gets asked — it stopped, why? — is StateTransitioned:
app.StateTransitioned += (_, change) =>{ if (change is { State: HttpServerState.Stopped, Reason: not HttpServerStateReason.Requested }) logger.LogError(change.Exception, "The server went down: {Reason}", change.Reason);};HttpServerStateReason |
What happened |
|---|---|
Requested |
The app called StartAsync / StopAsync. The only reason that is never a fault |
Restarting |
Part of a RestartAsync — present on the stop half too, so a handler knows a start is coming |
NetworkChanged |
A rebind driven by the machine’s addresses changing |
BindFailed |
The bind was refused and the retries are spent. Exception carries the failure |
ListenerFaulted |
The listener stopped accepting while the server believed it was running. Exception carries the cause |
Disposed |
The server was disposed. Nothing follows it |
Restarting is the one worth acting on even when nothing is wrong: a subscriber that tears down a
notification, an mDNS advertisement or a “reachable at” line on every Stopped will rebuild it a
moment later for every rebind. Reason == Restarting means down for a moment, not down.
LastStateChange holds the same record for code that arrived late — a crash reporter assembling
context, a diagnostics screen the user opens after the fact, a background task that woke up to find
the server down.
When the listener dies underneath it
Section titled “When the listener dies underneath it”A listening socket can go away without the server being told: the OS reclaims it, an interface is
torn down mid-accept, a platform suspends the process and takes the descriptor with it. Left alone,
that produces the worst possible failure — a server that goes on reporting Running with nothing
behind it, refusing every connection, which the user cannot fix by toggling it off and on because it
already thinks it is on.
The accept loop no longer ends quietly for any reason other than a stop:
- A transient accept failure is retried with a bounded backoff. The counter resets on every connection accepted, so a flaky minute costs nothing.
- Failures that keep coming, or a listener that stops accepting outright, are a fault: logged at
error level, reported as
ListenerFaultedwith the cause attached, and — by default — rebound.
Both are tuned by resilience options. Set
RecoverFromListenerFaults = false to have the fault stop the server instead; it is still reported
either way. What that setting chooses is whether the server tries to come back, not whether anyone
finds out.
RestartAsync re-reads Options, so a changed port or TLS configured after the fact takes effect.
Routes and middleware are not re-read — the middleware pipeline is composed once and stays
composed. Routes, however, can be changed at any time without a restart; see
Routing.
The start half of a restart retries. A restart is the one operation where failing leaves the
server worse off than not having tried: it was running a moment ago, and a bind refused because the
network is half up or the old port is still in TIME_WAIT would otherwise leave it stopped for good.
If the retries are spent, RestartAsync still throws and the transition to Stopped carries
BindFailed with the exception. A start the app asked for — plain StartAsync — is never retried:
the caller gets the failure immediately and decides, which is louder than a button that looks stuck.
Shutdown
Section titled “Shutdown”StopAsync unbinds the listener first so nothing new arrives, then waits for in-flight requests to
finish. Connections still running when the cancellation token fires are aborted.
DisposeAsync stops the server and releases everything. In a console host the usual shape is:
using var cts = new CancellationTokenSource();Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
await app.RunAsync(cts.Token);Serving a connection you already have
Section titled “Serving a connection you already have”ServeAsync(IConnection) runs the whole pipeline over a connection the server did not accept
itself. That is how tunnelling works — a tunnel provider dials out, unpacks inbound streams, and
hands each one to the server — and it is available to anything else that can produce an
IConnection, including in-memory pipes for tests.
A server that is Stopped still serves tunnelled connections: not listening is not the same as not
running.


