Expand description
§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};
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?;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 aselect!arm racing the shutdown signal — so a saturated server can still shut down. - Header reads are bounded (
with_header_read_timeout), handed to hyper’sheader_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_ephemeralbinds127.0.0.1:0. An external bind is a decision made at the call site viabind()/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.
let app = RouteBuilder::stateless()
.with_response_header("Strict-Transport-Security", "max-age=63072000")?
.with_response_header("Referrer-Policy", "no-referrer")?;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 upThe 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, unlikedeny, cannot be switched off by an innerallow. This says nothing about the dependency tree:tokio,hyper,bytesandmioall containunsafe, 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-httpfor the CORS this crate ships built in. Measured 2026-08-15 withcargo 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).
Structs§
- App
- An HTTP server application with typed state, routing, and TLS support.
- Body
Error - An error that can occur while streaming a response body.
- Cors
Config - Cross-Origin Resource Sharing (CORS) configuration for an HTTP server.
- Cors
Config Builder - Builder for constructing a valid
CorsConfig. - MaxBody
Size - Maximum body size limit for a request.
- OnUpgrade
- Path
Params - Contains a map of parameter names to their decoded string values
(e.g.,
id→"42"from a route/items/:id). Populated by the router and stored in request extensions; extract viapath_params::<T>(req). - Path
Segments - Extracted path parameters from a matched route.
- Peer
Addr - The transport-layer peer address a request arrived from. Inserted into
request extensions by
App::route_with_peer— retrieve it withreq.extensions().get::<PeerAddr>(). - Query
Params - Parsed query parameters from the request URL.
- Route
Builder - Builder for configuring routes and settings before creating an
App. - Serve
Error - An HTTP error returned by a handler.
- State
- Shared application state passed to every request handler.
Enums§
- Cors
Config Error - Error type for CORS configuration validation.
Constants§
- DEFAULT_
MAX_ BODY_ SIZE - Default maximum request body size: 2 MiB.
- DEFAULT_
MAX_ HEADER_ BYTES - Largest request header block accepted, in bytes.
Functions§
- body
- Create a response body from raw bytes.
- body_
bytes - Read the whole request body, refusing anything past the configured limit.
- handler
- Wrap an async function to create a handler.
- json
- Build a JSON response with the given status code and serializable value.
- json_
body - Extract a JSON-deserialized body from the request.
- path_
params - Extract path parameters from the request and deserialize into type
T. - query_
params - Read the request’s query parameters, treating a request with no query string as one with no parameters.
- shutdown_
signal - Set up a shutdown future that fires on SIGINT or SIGTERM.
Type Aliases§
- Handler
- A request handler that processes an HTTP request and returns a response or error.
- Middleware
- A middleware transforms a
Handlerinto a newHandler, typically by running logic before and/or after calling the inner handler — or by short-circuiting and never calling it at all (e.g. to block a request). - Response
Body - The response body type used by all handlers.
- Upgraded
Io - The upgraded connection handed to an
OnUpgradecallback.