Skip to content
Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!

Hosting & Lifecycle

There are three ways to get a server, and which one you want depends on who owns the container.

var server = new HttpServer(new HttpServerOptions { Port = 8080 });
server.OnGet("/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.

var builder = HttpServer.CreateBuilder();
builder.Configure(o =>
{
o.Port = 8080;
o.HideExceptionDetails = false; // development
});
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.

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.AddHttpServer(
o => o.Port = 8080,
server =>
{
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
server.MapMyAppEndpoints();
}
);

The configureServer callback runs once, immediately before the server starts, so it can resolve anything in 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.AddHttpServer(o => o.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.

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.OnGet("/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.

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 listening
await app.StopAsync(); // unbinds, then waits for in-flight requests
await 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
IsRunning State == Running
ListenUrl The URL being served — the real port when Port was 0
ListenUrls Every URL, when several endpoints are configured

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}");

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.

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);

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.