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) -> Response method. std::http::Server runs the accept loop and calls your handler per request. Routing is a match on the path inside handle. (A fuller router with path params lives in the pond library 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”

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.