Skip to content
You are reading next, which describes the unreleased compiler. Khora is unstable before v1: syntax, standard-library APIs and behavior may change. The current release is documented under v0.4.

std::net::http

HTTP/1.1, over the sockets in std::net::socket. Both directions.

The specification does not define std::net::http; the shape here was reconstructed from how the reference application uses it, and every line below it is Khora over String::slice, String::index_of and Map.

Answering: a request line with its query string, headers, a body read to the length the headers promised, routing on method and path with :name segments, HEAD and OPTIONS answered whether or not they are mounted, and a response that can carry headers of its own. On a fiber per connection, which took a language feature to reach rather than a line here — see Router::serve_once.

Asking: HttpClient, a capability, from Call to Answer. A service that cannot call another service is not one, and this half arrived a long way after the first.

What neither does: content negotiation, multipart bodies, compression, or following a redirect. Each is absent deliberately rather than forgotten. Chunked transfer is read by the client and not written by the server, which is the split real traffic has: a response of unknown length is ordinary and a request of unknown length is not.

A connection is reached through a Transport, which is three closures, so that the serving path is written once, Router::listen and Router::listen_tls differ only in what they hand it, and the client reuses both without knowing which it has.

Types: Method · Params · Request · Header · Response · HttpError · RequestLine · Transport · Connection · Incoming · Route · Router · Url · Call · Answer · CallError

Effects: HttpClient

Methods: Method · Params · Request · Response · Connection · Router · Call · Answer · HttpClient

Functions: percent_decode · parse · default_limit · over_socket · over_tls · request_length · matches · default_answer_limit · default_answer_timeout · parse_url

pub type Method =
| Get
| Post
| Put
| Patch
| Delete
| Head
| Options;

The seven verbs this server routes on.

Not every verb HTTP defines: TRACE and CONNECT are neither routed nor answered, and a method nobody can mount a route for is not worth a case here. HEAD and OPTIONS earned theirs the moment the router began answering them unmounted – the answer has to be reachable from a Route for a caller to be able to replace it, and this comment said the opposite for as long as answering them meant a 400. of returns None for the rest, which becomes a 400 rather than a panic.

| Head

The same as Get, and the response carries no body. RFC 9110 requires a server to support it, and a router that answers 400 to it is refusing a method every cache and health checker sends.

| Options

What a path allows. This is the CORS preflight, so a router without it cannot be called from a browser on another origin at all.

pub type Params = {
pub bound: Map<String, String>,
};

The path parameters a route matched: /analyze/:account_id against /analyze/acc_9921 binds account_id.

pub type Request = {
pub method: Method,
pub path: String,
pub params: Params,
pub queries: Map<String, String>,
pub headers: Map<String, String>,
pub body: String,
};

One request, parsed: the verb, the path, what the route bound, the query, the headers and the body.

Everything is decoded on arrival, once. A handler reading three query parameters and two headers does no parsing at all — the maps below are already built, and the header names are already lowercased.

pub path: String

The path with the query string taken off: /analyze/acc_1?deep=true has a path of /analyze/acc_1. Routes match against this, so mounting /analyze/:id does not stop working the moment a client adds a parameter.

pub queries: Map<String, String>

Decoded once, when the request arrives, rather than on every lookup — a handler that reads three parameters should not decode the query three times.

pub headers: Map<String, String>

Keyed by the lowercased name, because HTTP header names are case-insensitive and a map is not. Folding the case here rather than in header means two headers differing only in case collide on arrival, which is the correct reading: they are the same header.

pub type Header = {
pub name: String,
pub value: String,
};

One header on the way out.

pub type Response = {
pub status: Int,
pub body: String,
pub content_type: String,
pub headers: List<Header>,
};

What a handler answers with: a status, a body, its type, and any extra headers.

json and text build the two common ones. with_header adds to either, and returns a new response rather than changing the one it was given.

pub headers: List<Header>

Anything beyond the three a response always carries. A list rather than a map, because Set-Cookie may legitimately appear more than once and a map would silently keep only the last.

derive(Show, Eq)
pub type HttpError =
| BindFailed(port: Int)
| MalformedRequest(details: String);

What can go wrong that is the server’s problem rather than a client’s.

A malformed request from a client is answered with a 400 and never reaches here — that is a response, not an error. These two are the cases where there is nobody to answer: the port would not bind, or a request could not be read at all.

pub type RequestLine = {
pub method: Method,
pub target: String,
};

The first line of a request, before the target has been split.

Public because a caller parsing a request for its own reasons wants the target as the client wrote it; Request carries the split version.

pub target: String

The request target as written, query string and all. parse splits it; keeping it whole here means a caller reading a request line for its own reasons gets what the client actually sent.

pub type Transport = {
pub receive: (Array<U8>) -> Int,
pub transmit: (String) -> Int,
pub shut: () -> (),
};

How to speak to one client, whatever is underneath.

Three closures rather than a socket handle, because a plain connection and a TLS one differ in exactly these three operations and in nothing else the router does. Before this the serving path took an Int, which is a socket, and there was no way to put a handshake in front of it without writing the whole path twice.

The cost is two indirect calls per request — one read, one write — against a parse that performs several hundred reference-count operations. It did not move the benchmark.

A record of closures is what an effect compiles to anyway (the effect-runtime design note §2), and this is not one only because a transport belongs to a connection rather than to a computation: two fibers serve two clients at once, and a capability installed by with would be one of them.

pub receive: (Array<U8>) -> Int

Bytes into the buffer: how many, 0 when the peer closed, or -1.

The buffer is lent for the call and is reused for the next read, so it holds only this read’s bytes until receive is called again. A transport that keeps what it reads (a recorder, a tee, a shim that decodes later) copies the bytes it needs before returning.

pub transmit: (String) -> Int

The text, all of it, or -1.

pub shut: () ->()

Ends the conversation and releases whatever is underneath.

pub type Connection = {
transport: Transport,
buffer: Array<U8>,
mut carried: Int,
scratch: Array<U8>,
clock: Clock,
mut second: Int,
mut date: String,
};

One connection, read a request at a time.

This is the layer between a socket and a router, and it is public because a router is a matter of taste and this is not. Framing a request correctly — reading until it is whole, honoring Content-Length, holding what a pipelined client sent early, refusing one that will not fit — is the same work whatever shape the framework above it has, and it is the work that fails in production rather than in testing.

Router is written against this and has no privileged access to anything below it. A framework with a different shape — middleware chain, effect handler, one function per method — starts here and owes nothing to Router.

Nothing here spawns. A connection is read by whoever calls next, on whatever fiber that is. Router gives each connection a fiber in its accept loop and that is a decision of the accept loop, not of this type: a server that answers one caller at a time, or hands connections to a pool, is written against exactly this.

Every answer it sends carries Date, the current second as an IMF-fixdate, formatted once a second for the connection: see Connection::reply. The time comes from Clock::real, or from the clock given to Connection::timed.

mut carried: Int

Bytes of the next request that arrived with the last one. A pipelining client sends two without waiting, and the second must not be dropped.

scratch: Array

Where each socket read lands before it is copied into buffer.

One per connection, so reading a request allocates nothing. Nothing holds it between reads: what arrived is copied out at once.

What it costs: 4 KiB per open connection, beside buffer.

clock: Clock

Where the Date header’s time comes from. Clock::real unless the connection was made with Connection::timed.

mut second: Int

The second date was written for, in seconds since 1970, or a value no clock gives before the first answer.

mut date: String

The Date value for second, as an IMF-fixdate.

Kept per connection, not per process or per worker, because a connection is on one fiber for its whole life. A cache any two fibers could read would be a String published to both, which is what the shared-count rules exist to keep out; this one never leaves its fiber. What it costs: the text once a second per open connection that answers, and one clock read and one compare per answer. A client that opens a connection per request formats it every time, which is what a server writing its own date per request costs anyway.

pub type Incoming =
| Arrived(request: Request, keep: Bool)
| Ended
| Rejected(response: Response);

What reading a connection produced.

| Arrived(request: Request, keep: Bool)

A whole request, and whether the client asked to keep the connection open.

The flag is read from the Connection header here rather than by the caller, because the caller would have to fold the header’s case and this has already done it.

| Ended

The peer closed, or the read deadline passed. There is nothing to answer and nothing to send.

| Rejected(response: Response)

Nothing usable arrived: send this and close.

A client that could not be understood cannot be trusted about where its next request begins, so the connection cannot be reused whatever the Connection header said.

pub type Route<'er> = {
pub method: Method,
pub pattern: String,
pub handler: SharedFn<Request, Response, 'er>,
};

One mounted route.

The handler is a SharedFn rather than a plain closure, and that is what lets a router be answered on a fiber of its own. A closure’s captures are not in its type, so a record holding one can never be handed to another fiber; SharedFn::of checks the captures where the closure is written and hands back something that has forgotten it was ever a closure.

It also takes the capabilities out of the router’s type. A handler closes over what it needs at the mount site, so there is no 'r left for a Route to carry — Router<'er> says only how its handlers can fail.

pub type Router<'er> = {
pub routes: List<Route<'er>>,
pub limit: Int,
};

A router carries its handlers’ failures, so mounting a route that can fail against the database widens the router’s own failure row.

The three answers the router writes itself are text/plain: 404 for a path nothing mounts, 405 for a path mounted under another method, and 413 for a request over limit. None of them reaches a handler, so a service whose handlers all return a JSON error body still emits three plain-text ones, and a client that parses every response as JSON fails on exactly the responses it was not expecting. Nothing here configures them: a path that has to answer JSON for a method it does not want must mount that method and refuse inside the handler.

pub limit: Int

The most bytes one request may occupy, headers and body together.

On the router because that is the only thing a caller holds: the limit belongs to Connection, and every path from listen to it goes through five functions that had no reason to carry a number. Router::holding is how it is set.

pub type Url = {
pub secure: Bool,
pub host: String,
pub port: Int,
pub target: String,
};

Where a call is going.

pub secure: Bool

Whether to put TLS in front of the socket.

pub target: String

Path and query, together, beginning with /. What goes on the request line unchanged — a client that re-encodes what it was given is a client that breaks somebody’s signed URL.

pub type Call = {
pub method: Method,
pub url: String,
pub headers: List<Header>,
pub body: String,
};

A request this program is making.

pub headers: List<Header>

A list rather than a map, matching Response: two headers of the same name are legal on the way out and a map would keep one.

pub type Answer = {
pub status: Int,
pub headers: Map<String, String>,
pub body: String,
};

What came back.

Headers keyed by the lowercased name, as Request’s are and for the same reason: HTTP header names are case-insensitive and a map is not.

derive(Show, Eq)
pub type CallError =
| BadUrl(String)
| Unreachable(String)
| Insecure(String)
| Closed(String)
| Malformed(String)
| TooLarge(Int)
| Denied(String);

What can go wrong reaching somebody else.

Separate from HttpError, which is what goes wrong being a server — a port that would not bind, a request that could not be read. Nothing that matches one has any use for the cases of the other.

| BadUrl(String)

The URL could not be read.

| Unreachable(String)

No connection: the name did not resolve, or nothing answered.

| Insecure(String)

The TLS handshake did not complete.

| Closed(String)

The peer went away before the answer was whole.

| Malformed(String)

Something arrived and it was not an HTTP answer this can read.

| TooLarge(Int)

The answer was longer than the client was willing to hold.

| Denied(String)

The manifest does not grant this host.

Its own case, not Unreachable. The two send a reader to different places: Unreachable is DNS, a firewall, a service that is down, and Denied is [permissions] network in khora.toml. Reported as host:port so the line that would allow it can be copied out of the message.

pub effect HttpClient {
send: (Call) -> Result<Answer, CallError>,
}

Making requests, as a capability.

An effect rather than a function, for the reason std::db’s Db is one. A program that says with { http: HttpClient } can be handed a recording double in a test, a client that only reaches one host, or one wrapped for tracing — and every caller above is unchanged, because a capability is an interception point. The observability design note shows the wrapper.

One operation. Call carries everything a request is, so a second would only be a shorter way to build one, and Call::get is already that.

send: (Call) -> Result<Answer, CallError>

Sends one Call and waits for the answer.

A Result rather than a raise, because reaching somebody else fails often enough that the caller usually has a second thing to try — another host, a cached copy, a retry — and a failure that is a value is one a match reaches without unwinding first.

A status is not a failure. A 404 or a 503 arrives as Result::Ok carrying that status on the Answer; only a call that never became an answer is a CallError. Answer::ok is the question about the status, and it is a separate question on purpose.

impl Method
pub fn of(text: String) -> Option<Method>

The method this text names, or None.

Case-sensitive, because HTTP methods are: get is not GET, and a server that accepted it would be lenient about the one part of the request line that has never been ambiguous.

pub fn same(self, other: Method) -> Bool

Whether these are the same method.

Method predates derive(Eq) reaching this file; rank is what it compares on.

impl Params
pub fn empty() -> Params

No parameters bound. What a route with no :name in it matches with.

pub fn get(self, name: String) -> Option<String>

Option::None when the route did not declare that parameter, which is a programming error the type makes you notice rather than an empty string.

impl Request
pub fn get(path: String) -> Request

A GET request for path, with no parameters matched yet. Chiefly for tests: a real one arrives from a socket.

pub fn post(path: String, body: String) -> Request

A POST to path carrying body. For tests, like get.

pub fn of(method: Method, target: String, body: String) -> Request

The query string is honored here too, so a hand-built request behaves like one off the wire. A test that writes Request::get("/x?a=1") and finds no a would be testing the constructor rather than the code.

pub fn query(self, name: String) -> Option<String>

A query parameter, already percent-decoded.

A parameter written with no = — /x?verbose — is present with an empty value, which is how every server reads it and what lets a handler ask “was this requested” rather than “what does it say”.

pub fn header(self, name: String) -> Option<String>

A header, by a name in any case.

Host, host and HOST are one header. The RFC says so, and this is the half of it that everybody gets wrong.

impl Response
pub fn json<A: Encode>(status: Int, body: A) -> Response

Encodes body as JSON and sends it with the given status.

A: Encode, so a derived record, a Json built by hand and a list of Rejections all serve: Response::json(422, problems) is the body a client reads a refused request from. A record holding a secret has no Encode, so it cannot be sent by accident.

A derived record’s keys go out in the order its type declares them; a Map and a Json object go out sorted by key.

A String argument is encoded, not passed through. String has an Encode impl — it encodes to a JSON string — so passing JSON you assembled yourself sends it quoted and escaped inside another string. Response::of(status, "application/json", body) is the one that sends bytes as they are.

pub fn text(status: Int, body: String) -> Response

body as text/plain, with the given status.

pub fn of(status: Int, content_type: String, body: String) -> Response

A body that is already serialized, with the content type you name.

For JSON you built yourself, this and not json. Response::json encodes its argument, and a String encodes to a JSON string — so json(200, "{\"a\":1}") sends "{\"a\":1}", quoted and escaped, which is a correct encoding of the wrong thing. It type-checks, no diagnostic fires, and a client reading the body gets text where it expected an object.

Prefer derive(Encode) on a record and json when you can: it removes the hand-written escaping that is the usual reason a body is a String in the first place. Use this when the bytes come from somewhere that already serialized them.

pub fn with_header(self, name: String, value: String) -> Response

The same response with one more header on it.

A new response rather than a mutated one: a half-built response cannot be shared and then changed underneath its other holder.

pub fn rendered(self) -> String

The response, as bytes on the wire.

pub fn rendered_keeping(self, keep: Bool) -> String

The same, saying whether the connection is staying open.

Reuse is worth two orders of magnitude and nothing else here is. Measured on this machine, one server answering the same request: 6,116 a second on a fresh connection each time, 1,129,782 on one held open. The handshake, the teardown and the port churn were the whole cost; the server was never the bottleneck. That is also why comparing this to a server benchmarked with keep-alive was comparing two different questions.

Content-Length is on every response already, which is what makes this safe: a client knows where the body ends and where the next answer begins without the connection closing to tell it.

pub fn head_keeping(self, keep: Bool) -> String

Everything up to the blank line, and nothing after it.

This is what a HEAD answer is, and why it is a separate function rather than rendered_keeping with the body cleared: Content-Length here is the length of the body a GET would have sent, and clearing the body first would compute it as zero. RFC 9110 is explicit that the two answers carry the same headers and differ only in the body.

pub fn reason(status: Int) -> String

The reason phrase for a status.

The ones a service actually sends, which has twice turned out to be more than the list had. The link shortener answered a redirect and got HTTP/1.1 302 Unknown on the wire; later a service under load answered 503 and got the same. A phrase is advisory — every client reads the number — but one that says Unknown about a status the server deliberately chose reads as a bug to whoever is holding the packet capture, and it was.

500 through 504 are all here now, because those are the ones a server sends about itself: 503 under backpressure, 502 and 504 from anything with a proxy in front of it, 501 for a method it does not implement. Those are the lines somebody reads at three in the morning. The rest of 5xx still answers Unknown, as anything not in the table does.

A match rather than a chain of else ifs. Twenty-seven of them in a row is a table pretending to be control flow, and a table should look like one – the numbers are in order here, so a missing status is something a reader can see rather than something they have to search for.

413 stays “Payload Too Large” although RFC 9110 renames it “Content Too Large”. That is a wire string somebody may be matching on, and changing it is a decision of its own rather than something to slip into a list of additions.

impl Connection
pub fn over(transport: Transport) -> Connection

Over a transport, holding at most default_limit bytes per request.

pub fn holding(transport: Transport, most: Int) -> Connection

The same, with a cap of your own.

The buffer is allocated once for the connection rather than once per request, so this is what one caller costs in memory for as long as they are connected.

pub fn timed(transport: Transport, most: Int, clock: Clock) -> Connection

The same, taking the time for its Date headers from clock.

For a test that has to know what Date will say, and for a server that keeps its own time: a fixed or a stepped clock is an ordinary Clock handler.

pub fn next(self) -> Incoming

Reads until one request is whole, and says what arrived.

Blocks. Whether that blocks a thread, a fiber or the only thread there is belongs to the caller — see the note on Connection.

pub fn understood(text: String) -> Incoming

Parses one request, or the 400 to answer it with.

Everything a connection needs to decide before a handler runs: whether the bytes were a request at all, and — from the headers the parser has already folded — whether the client asked to keep the connection.

pub fn reply(self, response: Response, keep: Bool) -> Int

Renders response and sends it.

keep decides the Connection header, and is what Incoming::Arrived reported unless the caller has its own reason to close.

The two travel together because they are decided together: the answer’s status and the request’s Connection header are both known once, at the point the request has been parsed, and working either out again afterwards means reading the request a second time.

Every answer carries Date, which RFC 9110 asks of a server with a clock: the current second as an IMF-fixdate, Sun, 06 Nov 1994 08:49:37 GMT, formatted once a second per connection. A response with a Date header of its own, in any case, is sent with that one alone. Response::rendered has no connection and so no clock, and writes none.

pub fn reply_headless(self, response: Response, keep: Bool) -> Int

The same, for a request whose answer carries no body.

HEAD only. The headers are the ones GET would have sent, including the Content-Length of the body that is not being sent – which is the whole point of the method, and the reason this is decided here rather than by clearing a field somewhere upstream.

pub fn shut(self) ->()

Ends the conversation and releases the transport.

impl Router

Mounting is written router |> Router::post(..), so the router is the first parameter of each. They are the type’s own functions rather than free ones so that the pipeline reads as a chain against Router and cannot collide with another module’s get.

One per method, over a general on that takes the method as a value. Both are worth having: the named seven are what a pipeline reads best with, and on is what stops the set being closed — a router with only the verbs somebody thought to write helpers for is the shape this had before, and it made a REST service unwritable.

pub fn new<'er>() -> Router<'er>

A router with no routes. The start of the pipeline.

pub fn holding<'er>(router: Router<'er>, most: Int) -> Router<'er>

The same router, holding at most most bytes per request.

8 KB is the default and was, until now, the only value. It is the right answer to “how much may an unauthenticated client make a server hold” and the wrong one for a service that accepts a document – and Connection::holding has taken a size since it was written, with no way to reach it from a router. A form post or a JSON body above 8 KB meant abandoning Router for a hand-written accept loop, and nothing said so.

Written in the pipeline like everything else:

Router::new()
|> Router::holding(1048576)
|> Router::post("/documents", SharedFn::of(store))
pub fn on<'er>(router: Router<'er>, method: Method, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for method.

The general one, and the seven below are it with a name. A router that could only mount the methods somebody had written a helper for is a router that stops at whatever its first caller needed — which is what this one did, for months, with get and post and no way to say PUT at all. The named seven exist because Router::get(..) reads better in a pipeline than Router::on(Method::Get, ..), not because they are the only ones allowed.

The last two are unlike the other five: the router answers HEAD and OPTIONS whether or not anything is mounted for them, so mounting one replaces an answer rather than supplying the only one there is.

Later mounts are tried first, because each prepends. Mounting the same route twice is allowed and the second one wins, which is worth knowing rather than an invariant anything enforces.

pub fn get<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for GET.

pub fn post<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for POST.

pub fn put<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for PUT.

pub fn patch<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for PATCH.

pub fn delete<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for DELETE.

delete rather than remove, because the method is spelled DELETE and a router’s job is to say what the wire says.

pub fn head<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for HEAD.

Rarely what you want. A router without this answers HEAD by running the GET handler and sending the headers alone, which is what RFC 9110 requires the two to agree on and the only arrangement that cannot drift. Mounting HEAD separately takes that guarantee away, and is here for the one case that needs it: a resource whose length or validators are cheap to compute and whose body is not, where answering HEAD by building the body and discarding it is the whole cost of the request.

Whatever this returns is sent without its body, exactly as the GET fallback would be.

pub fn options<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>

Mounts handler at route for OPTIONS.

Also rarely what you want, and for the opposite reason: a router without this answers OPTIONS with 204 and an Allow naming what the path mounts, which is the correct answer to the question the method asks. What it does not carry is Access-Control-Allow-Origin and the rest of the CORS reply, because the library has no way to know which origins a service trusts – and a default of “none” and a default of “any” are both wrong. A service that answers preflights mounts this and sets them.

A handler mounted here replaces the default outright, so it is also responsible for the Allow header the default would have sent.

pub fn listen<'er>(router: Router<'er>, port: Int) ->() raises 'er + HttpError + ChildFailed

Serves until the process stops, on a fiber per connection.

This writes one line to standard output — listening on <port> — once the port is open. It is a readiness signal: something started this process and wants to know when it can connect, and a line on stdout is what every supervisor and test harness can already read without being taught a protocol.

A server whose output is structured logs does not want it, because it arrives on the same stream in a different format and everything parsing that stream has to be taught about it. listen_quietly is this without the line.

pub fn listen_quietly<'er>(router: Router<'er>, port: Int) ->() raises 'er + HttpError + ChildFailed

listen, without the line on standard output.

For a server whose output is structured. One unsolicited line is not much, but it is on the same stream as the logs and is not in their format. A caller here announces readiness however the rest of its output does — or not at all, for a service behind a health check that answers the question better anyway.

pub fn listen_tls<'er>(router: Router<'er>, port: Int, certificate: String, key: String) ->() with { scope: Scope } raises 'er + HttpError + TlsError + ChildFailed

The same, over TLS.

Takes the certificate chain and the key as PEM rather than as paths: a server that reads its own files has decided where they live, and the two deployments that matter — a file on disk and a secret handed in by the platform — disagree about that.

The configuration is released when the caller’s scope ends, which for a server is usually the program’s.

import std::core::{SharedFn};
import std::fs::{FsRead, read_text};
import std::net::http::{Response, Router};
pub fn main() -> Int {
with { reads: FsRead::real() } {
Router::new()
|> Router::get("/health", SharedFn::of(fn _ => Response::text(200, "ok")))
|> Router::listen_tls(8443, read_text("cert.pem")!, read_text("key.pem")!)
};
0
}

PEM text rather than paths, because a certificate arrives from a file, a secret store or an ACME client, and only one of those is a path.

pub fn bound<'ef>(port: Int) -> Int raises HttpError

A listening socket on port, announced by the caller.

pub fn serve_secured<'er>(router: Router<'er>, server: Int, settings: TlsServer) ->() with { nursery: Nursery } raises 'er + HttpError

serve_forever over TLS: accepts, handshakes, and serves on a fiber each.

A failed handshake ends that fiber quietly rather than the loop — on a public port it is the ordinary case, not an incident.

import std::net::socket::{listen_on};
import std::net::tls::{server};
fn serve(router: Router<'er>) -> () with { scope: Scope } raises TlsError {
let settings = server(certificate, key)!;
Router::serve_secured(router, listen_on(8443), settings)
}

The socket is already bound, which is what this has that listen_tls does not: a caller that wants the port opened on its own terms – an inherited descriptor, a chosen backlog – hands one over here.

Like serve_forever, it accepts any number of connections over its life.

pub fn serve_forever<'er>(router: Router<'er>, server: Int) ->() with { nursery: Nursery } raises 'er + HttpError

Accepts connections on server until the process stops.

listen is the one to call: it binds the port and then this. Taking an already-bound socket is for a caller that wants to bind it themselves — a test on port zero, a socket inherited from a supervisor.

import std::core::{SharedFn};
import std::net::http::{Response, Router};
import std::net::socket::{listen_on};
pub fn main() -> Int {
Router::new()
|> Router::get("/health", SharedFn::of(fn _ => Response::text(200, "ok")))
|> Router::serve_forever(listen_on(8080));
0
}

It does not return. A connection is served on a fiber of its own.

It accepts any number of connections over its life. It is a loop rather than a call to itself, because Khora does not promise tail calls: a call per connection would cost a stack frame per connection on the thread running main, and an 8 MB stack holds about 130,000 of them.

A trap inside a handler ends the server, not the request. This said the opposite, and reference/traps.md has always been right: there is no per-fiber trap containment, the page says so in bold, and it tells you not to design recovery around it. A doc comment promising the mechanism that page forbids is the worst place for the two to disagree, because this is the one a reader meets while writing the handler.

A trap is for a bug – an index out of bounds, an overflow, a division by zero. Failures a handler is meant to survive belong in its failure row, where catch can reach them.

pub fn answer_on<'er>(router: Router<'er>, transport: Transport) ->() raises 'er

Answers every request on one connection, until the client stops.

Written against the public Connection, with nothing reserved. A framework of a different shape writes this loop itself and is not missing anything: the reading, the framing and the refusals are all above.

The limit is the router’s, which is what Router::holding set. This read default_limit() when holding was written, so a caller who raised the limit and then drove its own transport through here silently got 8 KB back – the one path on which holding did nothing, and the path a framework of a different shape is on by definition.

pub fn answer_on_holding<'er>(router: Router<'er>, transport: Transport, most: Int) ->() raises 'er

The same, holding at most most bytes of request.

The limit was unreachable through the router, which is the half of it that was a bug: Connection::holding has taken a size since it was written, and every path from Router::listen went through Connection::over and its 8 KB. A service accepting a JSON document or a form had to abandon the router and write its own accept loop, and nothing said so. The default is unchanged – 8 KB is the right answer to “how much may an unauthenticated client make a server hold” – and it is now an answer a caller can disagree with.

impl Call
pub fn get(url: String) -> Call

A GET of url, with no headers of its own.

pub fn post(url: String, body: String) -> Call

A POST of body, with no content type.

Deliberately none: a POST of form data and a POST of JSON differ in exactly that header, and guessing it for the caller is how a client ends up sending application/json over a form. json below is the one that guesses, because there the name says what it guessed.

pub fn json(url: String, body: String) -> Call

A POST of body as application/json.

pub fn of(method: Method, url: String, body: String) -> Call

Any method, which is what the three above are shorthand for.

pub fn with_header(self, name: String, value: String) -> Call

The same call with one more header on it.

A new call rather than a changed one, like Response::with_header and for the same reason: a half-built request cannot be shared and then altered underneath whoever else is holding it.

impl Answer
pub fn header(self, name: String) -> Option<String>

The header under name, matched without regard to case.

pub fn ok(self) -> Bool

Whether the status is one of the 2xx.

Not “did the call work” — a 404 arrived successfully and is not ok, and a CallError is the other thing entirely.

impl HttpClient
pub fn real() -> HttpClient

A client that really connects, holding up to default_answer_limit.

pub fn bounded(limit: Int) -> HttpClient

The same, holding up to limit bytes of answer.

pub fn percent_decode(text: String) -> String

%20 becomes a space, and so does +.

A % not followed by two hex digits stays a literal %. Refusing the whole parameter would be defensible and is not what any browser or server does, and a query string is not the place to be the only strict one.

The decoded form is never longer than the encoded one, so a buffer of the input’s length is always enough and there is nothing to grow.

pub fn parse(text: String) -> Option<Request>

A request read off the wire.

pub fn default_limit() -> Int

The most a request may be, headers and body together, unless a caller says otherwise.

Eight kilobytes is what a buffer costs per connection, and a connection is what a fiber costs — so this is the number that decides how many callers a server can hold, not a guess about how big a request is. Connection::holding takes another, and Router::holding is how a router says so.

The buffer is allocated once at this size and never grows, which is what makes a lying Content-Length harmless: the header is a promise about what is coming, not an instruction to allocate.

Eight kilobytes is a policy, not a limit of the parser. Nothing here recurses per byte or per line, so a larger request is refused because the limit says so rather than because the parser cannot manage it: a 39,808-byte request carrying 2,001 headers parses and answers when the limit is raised to admit it.

The number stays at eight kilobytes anyway, which is what nginx and most others allow, because the question it answers changed rather than disappeared. It is no longer “how much can we survive” but “how much may an unauthenticated client make a server hold”, and that is a smaller number than the one the stack would now permit.

pub fn over_socket(connection: Int) -> Transport

A transport over a plain socket.

pub fn over_tls(connection: TlsConnection) -> Transport

A transport over a secured connection.

The socket is the TLS connection’s now — secure took it and shut closes it — so there is nothing here that closes it a second time.

pub fn request_length(bytes: Array<U8>, filled: Int) -> Int

How long the whole request should be, or negative while the headers are still arriving.

The framing primitive. A reader needs to know when to stop, and HTTP answers that in two parts: the headers end at a blank line, and Content-Length says how much follows. Getting it wrong is not a bug that shows up in testing — it is a request truncated at a packet boundary, or two requests read as one — which is why this is exported rather than left for each framework to derive again.

Connection is this with a buffer around it, and is what most callers want.

pub fn matches(pattern: String, path: String) -> Option<Params>

Whether path matches pattern, and what its :name segments bound.

/analyze/:id against /analyze/acc_9921 binds id. None when the shapes disagree, which is how the router moves on to the next route.

Exported so a program can route somewhere other than through Router — a WebSocket upgrade, a second listener — without reimplementing the one piece of matching that has to agree with the server’s.

pub fn default_answer_limit() -> Int

How much of an answer this client will hold, when nobody says otherwise.

64 KiB, and it is a buffer allocated per call rather than per connection — a hundred fibers each making a request is a hundred of these at once, which is why it is not measured in megabytes. HttpClient::bounded raises it for a caller who knows they are fetching something bigger.

pub fn default_answer_timeout() -> Int

How long to wait for bytes that are not arriving, in milliseconds.

On both paths now. It was the plain one only, because set_receive_timeout takes a socket and a TLS session owns its socket rather than handing it back – so a TLS server that accepted the connection and then said nothing held the fiber until the process ended. std::net::tls sets the deadline on the socket underneath, and dial uses it for https exactly as it does for http.

pub fn parse_url(text: String) -> Option<Url>

Reads https://host:port/path?query.

Only what a client needs to dial and ask. No fragment, because a fragment never leaves the browser; no userinfo, because credentials in a URL are deprecated and accepting them would mean deciding how to encode them; and no normalization of the path, because a server may have signed exactly the bytes it was given.

Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource