# 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).