HTTP
Coming from Python / Node? This is your Flask / Express moment — but instead of decorators or a routes table, you write a handler locus: a locus with a
handle(req) -> Responsemethod.std::http::Serverruns the accept loop and calls your handler per request. Routing is amatchon the path insidehandle. (A fuller router with path params lives in thepondlibrary catalog.)
A server
Section titled “A server”locus Api { params { hits: Int = 0; }
fn handle(req: std::http::Request) -> std::http::Response { if req.path == "/health" { return std::http::Response { status: 200, body: "ok\n", content_type: "text/plain" }; } self.hits = self.hits + 1; return std::http::Response { status: 200, body: f"hello — hit #{self.hits}\n", content_type: "text/plain" }; }}
fn main() { // A statement-position locus literal fires its lifecycle: this // runs the accept loop until the process is stopped. std::http::Server { port: 8080, handler: Api { } };}hale build it, run it, and curl localhost:8080/health. The
handler’s params persist across requests — self.hits counts
them — because the Api locus is alive for the whole run.
The pieces
Section titled “The pieces”std::http::Requestcarriesmethod,path,version,body, and headers (looked up case-insensitively). For a couple of routes youmatch/ifonmethodandpathright in the handler; past that, use the Router (below).std::http::Responseneeds at leaststatusandbody;content_typedefaults totext/plain, and you can add customheaders.std::http::Servertakes aportand ahandler, then owns the listen-accept-parse-dispatch loop.max_accepts: Nbounds it to N requests (handy for tests); the default runs until stopped. POST bodies are reassembled perContent-Length, so clients that write headers and body in separate segments (python’s urllib, for one) work; requests are capped at 1 MiB (oversized declared bodies get a413).
Routing
Section titled “Routing”When the if ladder in a handler grows past a few routes, hand
the routing to std::http::Router. The lightest registration is
a bare function — no handler locus needed:
fn greet(ctx: std::http::Context) -> std::http::Response { let who = std::http::path_param(ctx.params, "name"); return std::http::Response { status: 200, body: "hi " + who };}
router.add_fn("GET", "/hello/:name", greet);A locus handler (add) buys you state — counters, a database
child, config params — and both forms share one list and one
first-match-wins precedence order. Register patterns, get path
captures and query params extracted, and mount the router as the
server’s handler —
locus Hello { fn handle(ctx: std::http::Context) -> std::http::Response { let who = std::http::path_param(ctx.params, "name"); return std::http::Response { status: 200, body: "hi " + who }; }}
fn build_router() -> std::http::Router { let r = std::http::Router { }; r.add("GET", "/hello/:name", Hello { }); return r;}
fn main() { std::http::Server { port: 8080, handler: build_router() };}:name segments capture (path_param), ?k=v pairs are one
query_param(ctx.params, "k") away, the first matching route
wins (register specific patterns before general ones), and
anything unmatched hits an overridable 404. Middleware wraps the
chain in onion order:
locus Cors { fn before(ctx: std::http::Context) -> std::http::Context { return ctx; } fn after(ctx: std::http::Context, resp: std::http::Response) -> std::http::Response { return std::http::Response { status: resp.status, content_type: resp.content_type, headers: "Access-Control-Allow-Origin: *", body: resp.body }; }}// r.use(Cors { });Each route’s handler is its own locus, so per-route state lives
where it belongs (a hits counter on the route that counts, not
on a god-handler).
One locus, many endpoints
Section titled “One locus, many endpoints”Sometimes the opposite shape is right: one API locus owning one
piece of state — a database handle, a session table — with every
endpoint as a method on it. For that, skip the Router and
self-dispatch with std::http::build_context + std::http::is_route:
locus Api { params { hits: Int = 0; }
fn handle(req: std::http::Request) -> std::http::Response { let ctx = std::http::build_context(req); if std::http::is_route(ctx, "GET", "/users") { return self.list(ctx); } if std::http::is_route(ctx, "GET", "/users/:id") { return self.show(ctx); } return std::http::Response { status: 404, body: "not found" }; }
fn list(ctx: std::http::Context) -> std::http::Response { self.hits = self.hits + 1; return std::http::Response { status: 200, body: "users" }; }
fn show(ctx: std::http::Context) -> std::http::Response { let id = std::http::path_param(ctx.params, "id"); return std::http::Response { status: 200, body: "user " + id }; }}
fn main() { std::http::Server { port: 8080, handler: Api { } };}build_context(req) builds the same per-request bundle the Router
would (query string split into ctx.params.qs); is_route
matches method + pattern with the Router’s own matcher — :name
captures land in ctx.params on a hit, and any miss leaves
them cleared, so the if ladder is first-match-wins in written
order with no stale captures. The endpoint methods are plain
direct calls with self in scope: state without ceremony.
Don’t reach for a fancier router on performance instinct: at
service-sized route counts (tens of routes) this linear scan
costs a few hundred nanoseconds against the microseconds the
request spends in parsing and syscalls. The shape also pairs
naturally with replicated placement:
under pinned(..., replicas = K) each replica is its own Api
instance, so params state — that database handle — is
per-thread by construction.
A first taste of interfaces
Section titled “A first taste of interfaces”How does Server know Api is a valid handler? Server’s
handler field has the type std::http::Handler, which is an
interface — a named set of required methods:
// (declared in the standard library)interface Handler { fn handle(req: Request) -> Response;}Any locus that has a matching handle method satisfies
Handler — automatically, with no implements clause to write.
This is structural satisfaction: the shape is the contract. You
declared Api with the right method, so it’s a Handler. (Go
programmers will recognize this; it’s interfaces without the
impl ceremony.)
Taking over the connection
Section titled “Taking over the connection”Some protocols start as HTTP and then stop being HTTP — WebSocket is the famous one. For those, a handler can take over the raw connection instead of finishing the request/response cycle:
locus WsHandler { params { } fn handle(req: std::http::Request) -> std::http::Response { // req.conn_fd is the live socket. Hand it to whoever // will own the session (typically: publish it to a // session locus on its own pool). "ws.conn" <- Conn { fd: req.conn_fd }; return std::http::Response { status: 101, headers: "Upgrade: websocket\r\nConnection: Upgrade", body: "", takeover: true }; }}With takeover: true the server writes just the status line and
your headers — no Content-Length, no Connection: close — and
then leaves the socket open and forgets it. It’s yours: read
and write it through the raw-fd std::io::tcp functions
(send_fd(fd, bytes) to write, recv_into to read, close_fd
to hang up) or a borrowed
Stream { conn_fd: fd, owns_fd: false }, and close it
when the session ends. Three things to remember: the server’s 5s
receive timeout is still set on the fd (clear it with
std::io::tcp::set_recv_timeout(fd, 0) for a long-lived
session); a stalled peer — a half-dead connection, a throttled
browser tab — will block your sends forever unless you bound
them, so set std::io::tcp::set_send_timeout(fd, 200ms) before
entering a push loop (an SSE/WS fan-out stalls its whole loop on
one dead client otherwise); and a takeover response without
stashing req.conn_fd leaks the connection.
And sometimes the handler doesn’t know the answer yet — the response will be produced later, by a different locus (a resolved promise, a bus reply, a job completing). For that, take the connection with nothing written:
return std::http::Response { status: 0, body: "", takeover_raw: true };With takeover_raw: true the server writes no status line and no
headers — the wire is untouched. Whoever ends up owning the fd
writes the entire response, status line included:
std::io::tcp::send_fd(fd, std::bytes::from_string( "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")) or discard;std::io::tcp::close_fd(fd);status, headers, and body on the response are ignored. The
same three caveats as takeover apply — plus one more: until you
write, the client is waiting on a silent connection, so bound the
deferral or arm your own timeout.
Calling out
Section titled “Calling out”Outbound requests are one call:
let resp = std::http::get("https://example.com") or raise;println(std::str::from_bytes(resp.body));
let posted = std::http::post("http://api.local/things", std::bytes::from_string("{\"n\": 1}"), "application/json") or raise;Both are fallible(std::http::HttpError) — address the error with
or raise / a substitute / an error-check fn, and branch on
err.kind (connect_failed, bad_url, …) when it matters.
Response bodies are Bytes (binary-safe; std::str::from_bytes
when you know it’s text). For repeated calls to the same host,
std::http::Client { keep_alive: true } pools connections and
retries with backoff:
let c = std::http::Client { keep_alive: true, max_retries: 2 };let r = c.get("http://api.local/health") or raise;One placement note: https calls block their thread (TLS has no
async_io integration yet) — keep loci that make them on pinned
or an ordinary cooperative pool, not an async_io one.
That import line, the bindings that wire a server across
processes, and the lifecycle that lets a server shut down cleanly
on Ctrl-C are all next-level topics — but the handler you wrote
above doesn’t change when you get there. The server code is
already complete; the surrounding tier just gives it more ways to
be deployed and supervised.
Next: the transports below HTTP — UDP & TLS.