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…

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 an if on the path inside handle, or std::http::Router once there are more than a few routes.

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”

Serving many requests at once

Section titled “Serving many requests at once”

The server above answers one request at a time: fn main()’s thread runs the accept loop, and while one handler works — a sleep, a call to another service, a minute-long job — every other request waits, health checks included. To serve requests concurrently, make the server a field of a main locus and place it on an async_io pool:

locus Api {
params { hits: Int = 0; }
fn handle(req: std::http::Request) -> std::http::Response {
if req.path == "/slow" {
std::time::sleep(1s); // parks this request only
}
self.hits = self.hits + 1;
return std::http::Response { status: 200, body: f"hit #{self.hits}\n" };
}
}
main locus App {
params {
web: std::http::Server = std::http::Server { port: 8080, handler: Api { } };
}
placement { web: cooperative(pool = web) where async_io; }
run() {
// the server runs on its own pool; main keeps the program up
while !self.draining { std::time::sleep(100ms); }
}
}
fn main() {
App { };
}

That one placement line is the switch. On an async_io pool every connection runs on a coroutine of its own: when a handler parks — a sleep, a socket read, an outbound plain-HTTP call, a slow client on the write — the pool runs the other requests and keeps accepting. Five concurrent requests to /slow take one second instead of five. At most max_connections (256 by default) are in flight at once; the next ones wait for a slot (as many again), and past that a connection is closed unserved. Anywhere else — fn main(), the main pool, pinned, a plain cooperative(pool = io) — the server stays one-at-a-time, because a blocking call there holds the whole thread. (macOS has no async_io pools; a server there is one-at-a-time.)

What it means for your handler’s params: every request calls the same Api locus, and on one thread, so two handle calls never run at the same time. But they can interleave at a park: while one request sleeps, another runs and bumps self.hits. Code between two parks runs uninterrupted; a value you read before a park may have changed after it. Read state again after a sleep or a call out rather than trusting a copy from before it.

Blocking calls are the thing to avoid in a concurrent handler — https requests, std::process::run, file reads hold the pool’s one thread, and with it every request. hale check warns about a blocking call reachable from an async_io locus.

To stop the server from another locus, give it a name (unique in the process: a name another server holds fails the second one at birth; without one, the name is 127.0.0.1:<the port it bound>) and call std::io::tcp::stop(name) — a plain function, so it works from any pool (calling self.web.shutdown() from App’s pool is refused: a method call may not cross pools). Stopping ends the accept and lets the requests already accepted finish: the server’s run() returns once each has been answered.

// in App: `web: std::http::Server = std::http::Server { port: 8080, name: "web", handler: Api { } };`
std::io::tcp::stop("web");

The listener and the serving, placed apart

Section titled “The listener and the serving, placed apart”

std::http::Server is two pieces in one locus: a listener that accepts connections and hands each one over the bus, and a consumer that serves what it is handed. You can place the two yourself — the listener pinned on a thread of its own, the serving on async_io pools — and put more than one consumer behind one listener:

locus Api {
params { tag: String = ""; }
fn handle(req: std::http::Request) -> std::http::Response {
return std::http::Response { status: 200, body: self.tag };
}
}
main locus App {
params {
// consumers first: they are listening before the listener accepts
zero: std::io::tcp::Connections = std::io::tcp::Connections {
listener: "web", shard: 0,
handler: std::http::Responder { handler: Api { tag: "zero" } }
};
one: std::io::tcp::Connections = std::io::tcp::Connections {
listener: "web", shard: 1,
handler: std::http::Responder { handler: Api { tag: "one" } }
};
acceptor: std::io::tcp::Acceptor = std::io::tcp::Acceptor {
port: 8080, name: "web", shards: 2
};
}
placement {
zero: cooperative(pool = zero) where async_io;
one: cooperative(pool = one) where async_io;
acceptor: pinned;
}
run() {
while !self.draining { std::time::sleep(100ms); }
std::io::tcp::stop("web");
}
}
fn main() {
App { };
}

The listener deals its connections round-robin over its shards; each Connections takes the shard it names and serves it with its handler — std::http::Responder turns an HTTP handle into one, and any locus with fn serve(c: std::io::tcp::Accepted) is one too (it owns c.fd). A connection no consumer takes is closed by its listener, never leaked, so require no_silent_loss(topic __TcpAcceptedT) holds in a program’s claims. Neither piece grows with the connections it serves: each connection is served in a child that goes with it, and the handle the listener sends is gone once the consumer has taken it.

std::io::tcp::Acceptor { path: "/run/app.sock" } listens on a Unix socket instead of a port (its name is unix:/run/app.sock, and it removes the file when it goes); the consumers are the same, since a connection’s descriptor reads and writes alike for both. The api runtime’s http::Rpc, mcp::Rpc, unix::Rpc and grpc::Rpc listeners and a ws::Hub’s are built this way too (see The API surface). A handler whose per-connection work is a locus that subscribes to the bus starts it as a flow child of its own — declare accept and release for it on the handler — rather than building it inside serve: a locus born and gone within a method has its deliveries queued for the main thread.

Below HTTP, std::io::tcp::Listener is the same two pieces with a function for a handler: each connection is handed to on_connection as a std::io::tcp::Stream, closed when the function returns. max_accepts defaults to 1 (pass -1 to serve until stopped), and the placement decides the concurrency exactly as for Server:

fn echo(s: std::io::tcp::Stream) {
let line = s.recv(1024) or "";
s.send(line) or discard;
}
main locus App {
params {
echo: std::io::tcp::Listener = std::io::tcp::Listener {
port: 9000, max_accepts: -1, on_connection: echo
};
}
placement { echo: cooperative(pool = echo) where async_io; }
run() {
while !self.draining { std::time::sleep(100ms); }
}
}
fn main() {
App { };
}

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. The router keeps what add (and use) hand it: it holds the handler as a borrow and never owns it, so the handler has to live as long as the router does. Make each handler a field of the locus that owns the router, and fill the router in birth() — register patterns, get path captures and query params extracted, and mount the owner 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 };
}
}
// The routes' owner: each handler is a field, so it lives exactly as
// long as the router that dispatches to it.
locus Routes {
params {
hello: Hello = Hello { };
router: std::http::Router = std::http::Router { };
}
birth() {
self.router.add("GET", "/hello/:name", self.hello);
}
fn handle(req: std::http::Request) -> std::http::Response {
return self.router.dispatch(req);
}
}
fn main() {
std::http::Server { port: 8080, handler: Routes { } };
}

A handler literal built in a function that returns the router, or in a function handed someone else’s router, would be reclaimed with that function’s frame while the router still dispatches to it, so hale check (and hale build) refuses it and names the rule (GH #1048). Building and using a router in one function — r.add(…, Hello { }); r.dispatch(…) — is sound: the handler and the router end together. Not inside a loop, though: a handler built in a loop body is reclaimed when the next iteration reuses its storage, so register handlers that are fields, or build each one outside the loop.

: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
};
}
}
// on the owner: a field `cors: Cors = Cors { };`, and in `birth()`
// `self.router.use(self.cors);` — `use` keeps its middleware as `add` does

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 about 90 ns per route it tries (measured worst case: the request matches the last route), a microsecond or so at ten routes, next to what 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:

type Conn { fd: Int; }
topic WsConn { payload: Conn; subject: "ws.conn"; }
locus WsHandler {
bus { publish WsConn; }
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).
WsConn <- 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 (the client’s TLS handshake and reads don’t park on async_io) — keep loci that make them on pinned or an ordinary cooperative pool, not an async_io one.

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.