# mini-serve
An HTTP server: trie router, middleware, CORS, optional TLS. Built on `hyper` + `tokio`,
nothing else required.
> Status: **planned**. Previously implemented and published as `small-serve`; being
> rebuilt from this spec with a full security/correctness pass folded in — this crate
> carried the largest share of findings from the prior iteration's review. See
> `DEV_PLAN.md`.
```toml
[dependencies]
mini-serve = { version = "0.1", features = ["err", "log", "tls"] }
```
## 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.
## Connection lifecycle (fixed from the prior iteration)
Three separate DoS classes existed in the connection-accept path; all three are closed
by construction now, not by configuration a caller has to remember to set:
- **TLS handshake timeout.** `header_read_timeout` only started *after* a completed TLS
handshake — a client that opens TCP and stalls mid-handshake held its connection-
semaphore permit forever. Fixed: the handshake itself runs under a bounded
`tokio::time::timeout` (default 10s); a permit is never held past it.
- **`accept()` error handling doesn't busy-loop.** `EMFILE`/`ENFILE` (fd exhaustion) made
`accept()` fail immediately and repeatedly — an unbounded retry loop that turned a
transient resource issue into 100% CPU. Fixed: every accept error is followed by a
short bounded sleep before retrying, selected against the shutdown signal so it never
delays a clean shutdown by more than that sleep.
- **Ephemeral binds are loopback-only.** `bind_ephemeral`/`bind_tls_ephemeral` bind
`127.0.0.1:0`, not `0.0.0.0:0` — a test helper should not expose a real server to the
LAN. Callers who want an explicit external bind use `bind()`/`run()` with a real
address; that is a decision made at the call site, not a side effect of "ephemeral."
- **Graceful shutdown cannot be blocked by a saturated semaphore.** The prior
implementation acquired the connection-limit permit *inside* the `accept()` branch of
the shutdown `select!`, so once every permit was taken the shutdown branch could never
win the race — SIGTERM was ignored indefinitely under exactly the load conditions where
a restart is most needed. Fixed: permit acquisition is itself a `select!` arm, racing
fairly against the shutdown signal.
## CORS (fixed)
The credentialed-wildcard-reflection branch (`allow-credentials: true` combined with
reflecting any request `Origin`) — the classic CORS bypass that grants every website
scripted access to authenticated responses — does not exist in this implementation.
`CorsConfigBuilder::build()` returns `Result<CorsConfig, CorsConfigError>` and rejects
`allow_all_origins + credentials` at construction time, identically in debug and release
builds. There is no configuration that can express the unsafe combination; there is
nothing to "remember not to do."
## Error responses (fixed)
5xx responses never echo the handler's internal error message to the client — `internal
server error` is the wire body; the real message goes to the log (if the `log` feature
is enabled) or stderr. 4xx messages, which are authored for the client, pass through
unchanged. Full control remains available via `with_error_handler` for apps that want it.
## HEAD requests (fixed)
A HEAD response carries the *same* `Content-Length` a GET response to the same route
would carry — RFC 9110 §9.3.2. The prior implementation zeroed it, breaking any client
(`curl -I`, a download manager, a CDN) that uses HEAD to learn a resource's size.
## Routing (fixed + faster)
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 response carries the `Allow` header
listing the methods that *do* match, per RFC 9110; a CORS preflight for a path with no
registered route falls through to 404 rather than masking it with a 204.
Routing is also a single trie traversal per request instead of the prior three (once for
existence, once for the match, once more for HEAD fallback) — method dispatch, 405
detection, the `Allow` header, and HEAD-via-GET all read off the one node the traversal
finds. Static-segment matching no longer clones the path-param map at every node it
merely passes through; only an actual param match copies anything.
## State sharing (fixed)
Application state is shared via the existing `Arc<S>` refcount bump per request, not
deep-cloned and re-wrapped in a fresh `Arc` — the prior implementation paid a real
allocation (and, for any state holding a `HashMap` or connection pool, a real copy) on
every single request for no reason the API required.
## Path-param extraction (simplified)
Typed path-param extraction deserializes directly from the matched segment map via
serde's `MapDeserializer` — no round trip through a synthetic, percent-encoded query
string and a second parser crate. One fewer dependency, one fewer allocation per typed
extraction.
## 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.
## Features
- `err` — `mini_err::Error` → HTTP response conversion.
- `log` — request/response logging middleware.
- `tls` — `rustls`-backed TLS listener.
## 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).