mini-serve 0.13.12

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation

mini-serve

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio, nothing else required.

[dependencies]
mini-serve = "0.13"

TLS is not here. It plugs into the transport seam as mini-tls, so this crate has one configuration rather than two:

mini-serve = "0.13"
mini-tls = "0.1"          # only if you terminate TLS yourself

Design

An App<S> holds shared state (Arc<S>), a route trie, and middleware. Routes are registered per method; path params are typed extractors, not stringly-typed lookups. Build with RouteBuilder, finish with .seal(), then run() it on a listener.

use hyper::StatusCode;
use mini_serve::{handler, json, RouteBuilder};

# #[tokio::main(flavor = "current_thread")]
# async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = RouteBuilder::stateless()
    .with_request_logging()
    .get("/health", handler(|_req, _state| async {
        json(StatusCode::OK, &serde_json::json!({"ok": true}))
    }))
    .seal();

let port = app.bind_ephemeral().await?;
# Ok(())
# }

GET, POST, PUT, PATCH and DELETE have route builders. HEAD is served from the matching GET route, and OPTIONS is answered by the CORS preflight path — neither is registered directly.

Connection lifecycle

Every consumable in the accept path has a ceiling, set by default rather than by configuration a caller has to remember:

  • Connections are capped at 1024 by default (with_max_connections), enforced by a semaphore whose permit acquisition is itself a select! arm racing the shutdown signal — so a saturated server can still shut down.
  • Header reads are bounded (with_header_read_timeout), handed to hyper's header_read_timeout, which re-arms per message and therefore bounds every request on a keep-alive connection, not just the first.
  • The transport is bounded (with_connect_timeout, default 10s), so a client that opens TCP and stalls mid-handshake — or a transport that negotiates forever — cannot hold a connection slot indefinitely. This applies to whatever plugs into the transport seam, not to TLS specifically.
  • accept() errors back off rather than busy-looping: fd exhaustion (EMFILE) is a transient resource problem, not a reason to burn a core. The backoff is capped at 1s and is selected against the shutdown signal.
  • Shutdown drains, then stops waiting. In-flight requests get 5 seconds to finish; after that the remaining tasks are aborted. A handler that never returns delays exit by that bound, not forever.
  • Ephemeral binds are loopback-only. bind_ephemeral binds 127.0.0.1:0. An external bind is a decision made at the call site via bind()/run() with a real address, never a side effect of "ephemeral."

Request paths over 8 KiB and query strings over 4 KiB are rejected with a 400 before routing.

Response headers

Every response — including 404s, 405s, 400s from the path-length guard, error-handler output, and CORS preflights — passes through a single funnel that applies X-Content-Type-Options: nosniff and any headers registered with with_response_header. A policy header that is present on 200s and missing on error responses is worse than none, since the error paths are the ones being probed.

# use mini_serve::{RouteBuilder, ServeError};
# fn main() -> Result<(), ServeError> {
let app = RouteBuilder::stateless()
    .with_response_header("Strict-Transport-Security", "max-age=63072000")?
    .with_response_header("Referrer-Policy", "no-referrer")?;
# Ok(())
# }

Headers are applied if absent: a handler that sets the header itself wins, so a route can vary a policy the app sets app-wide. Invalid names and values are rejected when the app is built, not on a request months later, and headers describing connection framing (Content-Length, Connection, Transfer-Encoding) are refused outright — this crate does not choose those.

Connection upgrades

A handler can stop speaking HTTP and take the raw connection — for WebSockets, a CONNECT tunnel, or anything negotiated over an HTTP handshake. Return a 101 carrying an [OnUpgrade] callback, and enable it with with_upgrades(); it is off by default so an app with no upgrade route does not pay for the capability.

The callback runs inside the connection's own task, which is the point: an upgraded connection still counts against with_max_connections and is still ended by the shutdown drain. Servicing the stream from a detached task — the usual hyper pattern — escapes both. No protocol is implemented here; framing and masking belong in a crate built on this seam.

Logging

Opt in with with_request_logging() (stderr) or with_request_logging_to(writer). A library that writes to its host's output uninvited is a surprise, so nothing is logged without one of those calls.

Logged: a request line per response (method, raw path, status, duration), the internal message behind every 5xx, and any handler panic. The path is logged exactly as received — a probe is the line an operator most needs verbatim.

Not logged: 4xx messages, which already reach the client, and would only be noise.

Error responses

5xx responses never echo the handler's internal error message to the client; internal server error is the wire body while the real message goes to the log sink. 4xx messages are authored for the client and pass through unchanged. Full control is available via with_error_handler.

A handler that panics drops the connection — the client sees a transport error rather than a 500 — but the panic is reported to the log sink rather than vanishing.

CORS

The credentialed-wildcard-reflection bypass — allow-credentials: true combined with reflecting any request Origin, which grants every website scripted access to authenticated responses — is unrepresentable. CorsConfigBuilder::build() returns Result<CorsConfig, CorsConfigError> and rejects the combination at construction time, identically in debug and release. There is nothing to remember not to do.

A preflight for a path with no registered route falls through to 404 rather than masking it with a 204.

Request bodies

json_body::<T>() enforces a 2 MiB ceiling (with_max_body_size) two ways: a Content-Length above the limit is rejected before reading, and the read itself runs under Limited, so a lying or absent Content-Length cannot get past it. Both return 413.

The limit binds the helper, not the connection: a handler that consumes Incoming itself is responsible for its own bound.

Routing

Percent-encoded path segments are decoded before matching (GET /api/%69tems matches a route registered as /api/items); + in query strings decodes as space, matching application/x-www-form-urlencoded semantics; a 405 carries the Allow header listing the methods that do match, per RFC 9110.

Routing is a single trie traversal per request — method dispatch, 405 detection, the Allow header, and HEAD-via-GET all read off the one node the traversal finds. Static-segment matching does not clone the path-param map at nodes it merely passes through; only an actual param match copies anything. A HEAD response carries the same Content-Length a GET to that route would (RFC 9110 §9.3.2).

Typed path-param extraction deserializes directly from the matched segment map via serde's MapDeserializer, with no round trip through a synthetic query string.

Example

See examples/mini-serve.rs for a minimal working application:

# Run locally
cargo run --example mini-serve -p mini-serve

# Run in Docker
docker compose up

The example wires up a /health endpoint (used by the container's HEALTHCHECK) and a /hello/:name endpoint that demonstrates typed path-param extraction and JSON response building. It binds to 0.0.0.0:$PORT (default 8080) and responds to SIGINT and SIGTERM for graceful shutdown.

Principles

PRINCIPLES.md states what this crate optimises for, the seams extensions plug into, the performance budget it holds itself to, and the rule for when a cost is worth keeping.

Security

THREAT_MODEL.md states what this crate defends against, what it deliberately does not, and — for every defence — the test that fails when it is removed. Those mutations are executable: ./verify-guarantees.sh removes each guarantee in turn and asserts its test catches it.

Two further properties, both stated narrowly on purpose:

  • This crate's own code contains no unsafe, enforced by #![forbid(unsafe_code)] — which, unlike deny, cannot be switched off by an inner allow. This says nothing about the dependency tree: tokio, hyper, bytes and mio all contain unsafe, as any async runtime must.
  • A small dependency footprint: 33 crates in the default tree against axum's 53, before an axum application adds tower-http for the CORS this crate ships built in. Measured 2026-08-15 with cargo tree --edges normal --prefix none | awk '{print $1}' | sort -u | wc -l; it moves with dependency releases, so run it rather than trusting the number.

Non-goals

  • No built-in templating, no ORM, no background job runner — this is a router and a connection lifecycle, nothing else.
  • No HTTP/2 server push (deprecated by browsers; not worth the surface area).