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.

```toml
[dependencies]
mini-serve = "0.13"
```

TLS is not here. It plugs into the transport seam as
[`mini-tls`](https://crates.io/crates/mini-tls), so this crate has one configuration
rather than two:

```toml
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.

```rust,no_run
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.

```rust
# 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:

```bash
# 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`](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`](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).