HTTP
std::http provides an HTTP/1.1 server, request router, streaming SSE and chunked-body writers/readers, and an outbound HTTP/HTTPS client. An HTTP server is configured by passing a handler locus—any locus implementing fn handle(req: std::http::Request) -> std::http::Response—to std::http::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() { std::http::Server { port: 8080, handler: Api { } };}Compile and run the server with hale build, then query curl localhost:8080/health. Because Api stays alive for the lifetime of Server, its params (self.hits) persist across requests.
1. Quickstart HTTP Server
Section titled “1. Quickstart HTTP Server”Core Server Types
Section titled “Core Server Types”| Type / Interface | Key Fields & Methods | Description |
|---|---|---|
std::http::Request |
method, path, version, body, conn_fd |
Parsed inbound HTTP request. Headers are queried case-insensitively. (std::http::parse_request parses raw requests into this type.) |
std::http::Response |
status, body, content_type, headers, takeover, takeover_raw |
Outbound HTTP response. Requires status and body; content_type defaults to "text/plain". |
std::http::Server |
port, handler, name, max_accepts, max_connections, max_request_bytes, too_large |
Owns the listen-accept-parse-dispatch loop. Reassembles POST bodies by Content-Length across TCP segments. |
std::http::Handler |
fn handle(req: Request) -> Response; |
Structural interface satisfied automatically by any locus declaring a matching handle method. |
std::http::Server Configuration
Section titled “std::http::Server Configuration”max_accepts: Bounds the server toNrequests before returning (useful in tests). By default, the server runs until stopped.max_request_bytes: Caps the total size of a request including request line, headers, and body (1 MiBdefault, up to1 GiBmaximum).too_large: When a request’s declaredContent-Lengthexceedsmax_request_bytes, the server replies immediately withtoo_largewithout reading the request body. Defaults to a plain-text413 payload too largeresponse, and can be overridden (for example,too_large: std::http::Response { status: 413, content_type: "application/json", body: "{\"error\":\"too large\"}" }).
Structural Handler Interface
Section titled “Structural Handler Interface”Server’s handler parameter has type std::http::Handler:
// (declared in the standard library)interface Handler { fn handle(req: Request) -> Response;}Hale interfaces use structural satisfaction: any locus that defines fn handle(req: std::http::Request) -> std::http::Response satisfies std::http::Handler without an explicit impl declaration. Conversely, a parameter typed as Handler exposes only h.handle(req)—accessing concrete locus fields (such as h.routes) is a compile-time error.
2. Concurrent Serving
Section titled “2. Concurrent Serving”When started directly in fn main(), on the main pool, pinned, or on a plain cooperative(pool = io) pool, std::http::Server processes one request at a time (macOS also runs one request at a time, as async_io pools are Linux-only).
To serve requests concurrently, declare std::http::Server as a param on your main locus and place it on a cooperative pool with where async_io:
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 coroutine 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() { while !self.draining { std::time::sleep(100ms); } }}
fn main() { App { };}Concurrency & State Semantics on async_io
Section titled “Concurrency & State Semantics on async_io”- Per-connection coroutines: Each accepted connection runs on its own coroutine. When a handler parks (
std::time::sleep, socket read/write, or outbound HTTP request), the pool executes other ready requests and continues accepting new connections. - Connection limits (
max_connections): Up tomax_connections(256by default) requests execute concurrently. Up tomax_connectionsadditional connections wait in the queue; connections beyond that limit are closed unserved. - Single-threaded interleaving: Every request dispatches to the same
Apilocus on a single OS thread. Twohandlecalls never execute simultaneously, and code between park points runs uninterrupted. However, state may change across a park point (sleepor I/O call)—always re-read mutableselffields after parking rather than relying on values cached before the park. - No blocking calls: Synchronous file I/O or
std::process::runblocks the pool’s underlying OS thread and stalls all concurrent requests on that pool.hale checkemits a warning if a blocking call is reachable from awhere async_iolocus.
Stopping a Server (std::io::tcp::stop)
Section titled “Stopping a Server (std::io::tcp::stop)”Because cross-pool method calls (such as self.web.shutdown()) are disallowed, stop a server from another pool by assigning it a unique name (defaults to "127.0.0.1:<port>"; duplicate active names fail at birth) and calling the free function std::io::tcp::stop(name):
// In App params:// web: std::http::Server = std::http::Server { port: 8080, name: "web", handler: Api { } };std::io::tcp::stop("web");std::io::tcp::stop closes the listening socket and waits for in-flight requests to finish before the server’s run() returns. SIGTERM and Ctrl-C trigger the same graceful drain automatically. Note that main’s run() returning does not terminate an active server; a main locus that only constructs server loci does not need a run() block unless it performs additional coordination.
Sharded Listener & Worker Pools (Acceptor + Connections)
Section titled “Sharded Listener & Worker Pools (Acceptor + Connections)”std::http::Server combines a connection listener and a connection consumer in a single locus. For multi-core scaling, split them explicitly: place a single std::io::tcp::Acceptor on a pinned thread and distribute connections round-robin across multiple std::io::tcp::Connections shards on separate where async_io pools:
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 { // Declare Connections consumers before Acceptor so subscribers exist before accept begins 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 { };}- Handler adaptation:
std::http::Responderwraps an HTTPHandlerinto a connection consumer. Any locus declaringfn serve(c: std::io::tcp::Accepted)(taking ownership ofc.fd) can also be passed as thehandlerofConnections. - Zero connection leaks: Any connection not claimed by a consumer is closed by
Acceptor, satisfyingrequire no_silent_loss(topic __TcpAcceptedT)in programclaims. Each connection runs in a transient child arena that reclaims on close. - Unix domain sockets:
std::io::tcp::Acceptor { path: "/run/app.sock" }listens on a Unix socket (named"unix:/run/app.sock", removing the socket file on dissolve) and feeds the exact sameConnectionsconsumers. The API runtime’shttp::Rpc,mcp::Rpc,unix::Rpc,grpc::Rpc, andws::Hublisteners use this same architecture (see The API surface). - Bus-subscribing connection children: If per-connection work requires a locus that subscribes to the bus, declare
acceptandreleaseon the handler to spawn it as a flow child rather than constructing it inline insideserve(loci constructed and dissolved inside a method have their deliveries queued for the main thread).
For raw TCP protocols below HTTP, std::io::tcp::Listener dispatches each accepted socket as a std::io::tcp::Stream to an on_connection function and closes the stream when the function returns (max_accepts defaults to 1; pass -1 to serve until stopped):
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 { };}3. Routing
Section titled “3. Routing”Hale supports two routing patterns:
std::http::Router: Dispatches routes to free functions or separate per-route handler loci, with support for middleware chains.std::http::build_context+std::http::is_route: Dispatches routes directly to methods on a single stateful locus.
Multi-Handler Routing (std::http::Router)
Section titled “Multi-Handler Routing (std::http::Router)”For stateless endpoints, register a free function with router.add_fn:
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);For stateful endpoints, register a handler locus with router.add. Because Router borrows handler and middleware loci without owning them, every registered handler and middleware locus must outlive the router. Declare them as params fields on the locus that owns the Router and register them in birth():
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 }; }}
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 { } };}Path Captures, Query Strings, and Middleware
Section titled “Path Captures, Query Strings, and Middleware”- Path parameters (
:name): Extracted viastd::http::path_param(ctx.params, "name"), automatically percent-decoded (/hello%20worldyields"hello world"). - Query parameters (
?k=v): Extracted viastd::http::query_param(ctx.params, "k"), percent-decoded with+converted to space. - Route precedence:
addandadd_fnshare a single route list evaluated in first-match-wins order. Register specific routes before wildcard/parameterized routes. Unmatched requests return a customizable404response. - Middleware (
router.use): Wraps route handlers in onion order. A middleware locus implementsbeforeandafter:
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 }; }}// In Routes params: `cors: Cors = Cors { };`// In Routes birth(): `self.router.use(self.cors);`Single-Locus Routing (build_context + is_route)
Section titled “Single-Locus Routing (build_context + is_route)”When multiple endpoints share a single piece of locus state (such as a database connection or session table), dispatch directly inside handle using std::http::build_context and 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)splits the query string intoctx.params.qs.is_route(ctx, method, pattern)runs the router matcher, populating:namecaptures inctx.paramson a match and clearing captures on a miss.- Always read
std::http::path_param(ctx.params, "id")afteris_routematches. Because records are value types, bindinglet p = ctx.params;before theifladder copies an empty capture table (hale checkwarns on this bug). - Linear route matching costs ~90 ns per tested route (~1 µs for 10 routes) and pairs naturally with replicated placement (
pinned(..., replicas = K)), giving each replica its own independentApiinstance and state.
4. Streaming Responses & Connection Takeover
Section titled “4. Streaming Responses & Connection Takeover”Server-Sent Events (std::http::SseWriter)
Section titled “Server-Sent Events (std::http::SseWriter)”To stream Server-Sent Events (text/event-stream), construct an std::http::SseWriter with req.conn_fd, emit events with w.event(...), and return w.end():
locus Countdown { fn handle(req: std::http::Request) -> std::http::Response { let w = std::http::SseWriter { conn_fd: req.conn_fd }; let mut n = 3; while n > 0 { w.event(std::http::SseEvent { event: "tick", data: to_string(n) }) or discard; std::time::sleep(1s); n = n - 1; } w.event(std::http::SseEvent { event: "done", data: "liftoff" }) or discard; return w.end(); }}- Constructing
SseWriterimmediately flushes the HTTP response headers. - Each
w.event(SseEvent { ... })writes a single chunked frame to the client.SseEventacceptsdata(multi-line strings are split into onedata:line per line), plus optionalevent,id(returned by reconnecting clients asLast-Event-ID), andretry(retry: 5s).
Chunked Byte Streams (std::http::BodyWriter)
Section titled “Chunked Byte Streams (std::http::BodyWriter)”For raw streaming bodies such as newline-delimited JSON (application/x-ndjson) or log tails, use std::http::BodyWriter:
let w = std::http::BodyWriter { conn_fd: req.conn_fd, content_type: "application/x-ndjson" };w.chunk("{\"step\": 1}\n") or discard;w.chunk("{\"step\": 2}\n") or discard;return w.end();Both SseWriter and BodyWriter use HTTP/1.1 chunked transfer encoding. w.event and w.chunk fail only when the client disconnects—use that failure to break out of your loop and return w.end();, which writes the terminating chunk and closes the connection.
Connection Takeover (takeover and takeover_raw)
Section titled “Connection Takeover (takeover and takeover_raw)”For protocol upgrades (such as WebSockets) or deferred responses written by another locus, a handler can take ownership of req.conn_fd:
| Response Flag | What Server Writes |
Caller Responsibility |
|---|---|---|
takeover: true |
Status line + custom headers (no Content-Length or Connection: close) |
Read/write the upgraded protocol on req.conn_fd and call std::io::tcp::close_fd(fd) when done |
takeover_raw: true |
Nothing (status, headers, and body are ignored) |
Write the complete raw HTTP response (including HTTP/1.1 200 OK\r\n...) and close req.conn_fd |
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 { WsConn <- Conn { fd: req.conn_fd }; return std::http::Response { status: 101, headers: "Upgrade: websocket\r\nConnection: Upgrade", body: "", takeover: true }; }}For takeover_raw: true, return std::http::Response { status: 0, body: "", takeover_raw: true } and later write the full HTTP frame from the owning locus:
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);When taking over a connection (via raw-fd helpers send_fd, recv_into, close_fd or a borrowed Stream { conn_fd: fd, owns_fd: false }), observe four rules:
- Clear the receive timeout: The server’s 5s receive timeout remains on
fd; callstd::io::tcp::set_recv_timeout(fd, 0)for long-lived sessions. - Bound send timeouts: Call
std::io::tcp::set_send_timeout(fd, 200ms)before entering a push loop so one stalled client cannot block fan-out. - Never drop
req.conn_fd: Returning a takeover response without saving and eventually closingreq.conn_fdleaks the socket descriptor. - Bound deferred responses: Under
takeover_raw, the client waits on a silent socket until you write, so enforce a timeout on deferred work.
5. HTTP Client & Streaming Reads
Section titled “5. HTTP Client & Streaming Reads”One-Shot Requests & Pooled Client
Section titled “One-Shot Requests & Pooled Client”Use std::http::get and std::http::post for one-shot requests, or std::http::Client for connection pooling and automatic retries:
// One-shot GET and POST (30s default deadline)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;
// Pooled client with retries and custom timeoutlet c = std::http::Client { keep_alive: true, max_retries: 2, timeout_ms: 5000 };let r = c.get("http://api.local/health") or raise;- Error handling (
HttpError): Client calls arefallible(std::http::HttpError)witherr.kind("connect_failed","recv_failed","bad_url","retries_exhausted", etc.) anderr.detail. - Binary-safe bodies:
resp.bodyisBytes; convert to text withstd::str::from_bytes(resp.body). - URL encoding & IPv6: Escape URL parameters with
std::url::encodeand decode withstd::url::decodeorstd::url::decode_query(where+decodes to a space). Bracket IPv6 hosts in URLs ("http://[::1]:8080/health"):
let q = std::url::encode("tea & cake"); // "tea%20%26%20cake"let r = std::http::get("http://api.local/search?q=" + q) or raise;let back = std::url::decode_query("tea+%26+cake"); // "tea & cake"- Timeouts (
timeout_ms): Defaults to30000(30s) on bothClientand one-shotget/post/request. Bounds the entire operation (TCP dial, TLS handshake, request write, response headers and body read, and all retries). A truncated body at deadline is always an error. Settimeout_ms: 0to disable the timeout for large downloads. - 5xx Retry behavior: One-shot
getreturns5xxresponses directly.Clientretries5xxresponses up tomax_retries; if all retries fail, it raisesHttpErrorwitherr.kind == "retries_exhausted"and populateserr.status,err.headers, anderr.bodyfrom the final5xxresponse (err.status == 0if no response was received). async_iointegration: On awhere async_iopool, TCP connect, TLS handshake, and socket reads/writes park only the calling coroutine. (DNS hostname resolution still runs synchronously on the worker thread; dial IP addresses or isolate slow resolvers on a dedicated pool.)
Streaming Client Reads (SseReader and BodyReader)
Section titled “Streaming Client Reads (SseReader and BodyReader)”To consume SSE streams or chunked responses incrementally as they arrive, use std::http::SseReader or std::http::BodyReader:
let url = std::http::parse_url("https://feeds.local/events") or raise;let feed = std::http::SseReader { req: std::http::ClientRequest { url: url, body: b"" } };while true { let e = feed.event() or { break; }; println(e.event, ": ", e.data);}- Constructing
SseReaderorBodyReadersends the request and reads the response status and headers immediately intofeed.body.statusandfeed.body.headers. feed.event()yields the nextSseEvent(data,event,id,retry);BodyReader’schunk()yields the nextByteschunk.- A long download in
run():let c = body.chunk() or { break; }; h.update(c); out.write_bytes(c) or { break; };inrun()’s own loop holds one chunk at a time, however long the body: each iteration’s chunk is freed when the next iteration starts (Memory & Lifetime). Keeping each chunk in a binding declared before the loop would keep them all. - Bounded-wait reads (
event_within(ms)andchunk_within(ms)): When a streaming relay also needs to service a control channel or cancellation signal between events,feed.event_within(ms)(orbody.chunk_within(ms)) waits at mostmsmilliseconds and fails witherr.kind == "idle"if nothing arrived, leaving the stream open and any partially received event buffered for the next read. The reader’s overalltimeout_msstill bounds the entire stream. Build the reader withdeferred: trueand the wait for the upstream’s response head is bounded the same way: birth sends nothing, the firstevent_within(ms)opens the exchange and failsidlewhile the head has not come (or callopen_within(ms), which answerstrueonce the head is read). The dial itself is still bounded bytimeout_ms. - A gated stream (
bearer:): a download or a feed behind a token takes the vault credential itself,std::http::BodyReader { req: ..., bearer: self.key }(or onSseReader), and sends it asAuthorization: Bearer <token>. The credential writes that header line onto the wire on its sealed side, so the token is never aStringthe program can read, deferred reader or not. A credential that resolves to nothing fails the opening withsend_failedand nothing is sent.hale checkrefuses areveal_text()into thereqof a reader built withdeferred, which would keep the header readable inrequntil the opening. - Redirects (
follow_redirects): a reader hands a 3xx back as it is unless its request setsfollow_redirects: n, the most redirects it follows (a download URL that answers 302 to a CDN, say). The reader’sstatusandheadersare then the final target’s. It follows the same scheme or an upgrade to https, never https to http (err.kind == "redirect_refused"), and failstoo_many_redirectspastn. When the redirect moves to another origin, theAuthorizationheader and thebearerare not sent there. The whole-bodyget/post/requestcalls still return a 3xx as it is. - When the stream finishes,
event()/chunk()fails witherr.kind == "end"on clean completion or"recv_failed"on an interrupted connection or deadline expiry.timeout_msdefaults to30000(settimeout_ms: 0for long-lived streams).
End-to-End Example: SSE Relay
Section titled “End-to-End Example: SSE Relay”Combining SseReader and SseWriter creates a streaming proxy that inspects the upstream status before forwarding events as they arrive:
locus Relay { params { upstream: String = "https://models.local/v1/chat/completions"; } fn handle(req: std::http::Request) -> std::http::Response { let url = std::http::parse_url(self.upstream) or std::http::Url { }; let up = std::http::SseReader { req: std::http::ClientRequest { method: "POST", url: url, headers: "Content-Type: application/json", body: std::bytes::from_string(req.body), }, timeout_ms: 0, }; if up.body.status != 200 { return std::http::Response { status: 502, body: "upstream answered " + to_string(up.body.status) }; } let w = std::http::SseWriter { conn_fd: req.conn_fd }; while true { let e = up.event() or { break; }; w.event(e) or { break; }; } return w.end(); }}Next: UDP & TLS.