Documentation channel: current development main at compiler checkpoint 1d7e15e. The latest tagged release is v0.0.3; pages identify APIs that are not yet released.

HTTP server

Release status: routing, buffered responses, lifecycle controls, TLS, async handlers, and embedded assets are available in v0.0.3. Strict framing, persistence, streaming, request cancellation, application helpers, file ranges, NDJSON, WebSockets, and certified WebSocket-to-PTY composition document current development at compiler checkpoint 1d7e15e and are not yet released.

The built-in server provides bounded concurrent HTTP/1.0 and HTTP/1.1 handling over TCP or TLS. It includes the earlier routing, async, embedded-static, SQLite-composition, lifecycle, and HTTPS features plus current-main strict framing, streaming, persistence, cancellation, application helpers, file ranges, and NDJSON work. The implementation is extensively tested, but this documentation makes no production-grade or internet-scale deployment claim.

Routes and responses

function main() -> int : (NetworkError, HttpError) {
    app := http_server();
    app.get("/hello/:name", (http_request request) => {
        return http_text("hello " + request.params["name"]);
    });
    app.post("/items", (http_request request) => {
        return http_json_response(request.json());
    });
    app.listen("127.0.0.1", 8080);
    return 0;
}

get and post register handlers that return buffered http_server_response values. http_text, http_html, and http_json_response create common responses. A response exposes status, body, content_type, validated custom headers, and ordered structured cookies. get_async and post_async await handlers returning future<http_server_response>.

http_request exposes method, path, lowercase single-valued headers, raw last-value query, decoded repeated-value query_values, validated repeated-name cookies, route params, buffered body, and a read-only request-lifetime cancellation token. http_values.get(name), values(name), and has(name) provide first, all-in-wire-order, and presence lookup.

Handlers are ordinary closures, so they can capture an opened sqlite_db and return http_json_response(db.query(...)); SQLite failures remain checked SqliteError values. Embedded static maps and direct filesystem responses are described below.

function main() -> int : (NetworkError, SqliteError) {
    db := sqlite_open(":memory:");
    db.exec("CREATE TABLE notes(id INTEGER PRIMARY KEY, text TEXT)");

    app := http_server();
    app.get("/notes", (http_request req) => {
        return http_json_response(db.query("SELECT * FROM notes"));
    });
    app.listen("127.0.0.1", 8080);
    return 0;
}

Lifecycle and limits

app := http_server();
app.timeouts(30000, 30000, 5000, 5000);
app.limits(1048576, 65536, 100, 1024);
app.get("/health", (http_request request) => { return http_text("ok"); });
app.listen("127.0.0.1", 8080);

listen(host, port, max_requests?) blocks until stop() or the optional number of admitted connections is reached. Connections run through a lazily grown reusable worker pool; queued plus running work is bounded by the connection limit. Saturated plaintext admission receives 503. max_requests is primarily useful for tests: admitted malformed requests count, while saturation rejections do not.

timeouts configures read, write, persistent-idle, and graceful-shutdown milliseconds. limits configures decoded request-body bytes, request-head/framing bytes, field or query-pair count, and admitted connections. The values shown are the defaults.

stop() is safe before start and idempotent. It closes admission and drains accepted work against one shared shutdown deadline, then interrupts remaining sockets. running() remains true while accepting or draining. A handler may initiate stop without waiting on itself. A handler that outlives the deadline keeps its retired generation alive and prevents restart or reconfiguration until it returns.

listener := thread(() => { app.listen("127.0.0.1", 8080); });
wait_for_shutdown_signal();
app.stop();
listener.join();

Request framing and persistence

The parser accepts strict HTTP/1.0 and HTTP/1.1 origin-form requests. HTTP/1.1 requires exactly one valid Host. Controls, folded fields, ambiguous lines, repeated field names, conflicting or malformed lengths, and every Transfer-Encoding/Content-Length combination fail before dispatch. A sole HTTP/1.1 chunked coding is decoded; chunk syntax and cumulative framing are bounded, non-empty trailers are rejected, and Expect receives 417. Unsupported coding chains receive 501 where distinguishable.

HTTP/1.1 persists by default; HTTP/1.0 requires Connection: keep-alive and a self-delimited response. Pipelined requests execute sequentially in wire order on one connection. Reuse requires validated body EOF and a finished self-delimited response. Unread bodies, malformed framing, close-delimited output, failure, timeout, explicit close, or shutdown end reuse. HEAD uses the GET route and suppresses body bytes.

Every dispatched request receives a fresh cancellation state, including consecutive requests on one connection. It is cancelled at normal completion and on framing, handler, response, transport, timeout, abort, or shutdown termination. Peer-disconnect observation is cooperative; CPU-only work that performs no transport operation is not promised immediate notification. Passing the token to process cancels process-pipe I/O, not the child process.

Buffered body helpers

request.text(limit?), json(limit?), and form(limit?) operate repeatedly on the already-buffered body. Limits are non-negative byte limits. Form parsing requires exactly application/x-www-form-urlencoded, supports at most 1024 pairs, and does not support media-type parameters or multipart. In request-stream handlers these helpers raise HttpError, because http_request_body is the sole body consumer.

Streaming responses

function configure() -> void : NetworkError {
    app := http_server();
    app.get_stream("/events", (http_request request, http_response_writer response) => {
        response.status(200);
        response.content_type("application/octet-stream");
        response.header("X-Source", "live");
        response.write_bytes(bytes.from_string("first"));
        response.flush();
        response.write_bytes(bytes.from_string("second"));
        response.finish();
    });
}
function main() -> int {
    return 0;
}

get_stream and post_stream retain a buffered request and supply an incremental writer. Before commitment it accepts status, header, cookie, content_type, and transport-owned content_length. The first write or flush commits. finish is idempotent; flush after finish is a no-op; late metadata, writes after finish, and a declared-length mismatch raise NetworkError.

Without a declared length, HTTP/1.1 uses transport-generated chunking and HTTP/1.0 closes to delimit output. Writes are synchronous and apply socket backpressure without an unbounded response queue. Failure before commitment can become a fixed safe 500; failure after commitment closes the connection without a second response.

Streaming requests

app.post_request_stream("/upload", (http_request request,
        http_request_body body, http_response_writer response) => {
    bytes data := body.read_all_bytes(1048576);
    response.content_type("application/octet-stream");
    response.content_length(data.length());
    response.write_bytes(data);
    response.finish();
});

get_request_stream and post_request_stream supply the validated, decoded body reader and a response writer. The reader supports read_bytes(max_bytes), read_all_bytes(limit?), eof(), and idempotent close(). It does not expose chunk boundaries. Copies share one state, concurrent reads fail, and handles become inactive when the handler returns.

The body must reach EOF or be closed before the response commits. Commitment during an active read fails, and no later reads are allowed. Returning with an unread body may complete the response but closes the connection rather than draining hidden input. Framing, transport, interruption, and normalized TLS read failures use NetworkError.

Response validation and helpers

Buffered and streamed responses share one validator. Status must be 200 through 599. Header names must be tokens; values reject response-splitting controls; content types require media-type syntax. Case-insensitive duplicates are rejected. Generic Content-Type, Content-Length, Transfer-Encoding, Connection, and Set-Cookie are reserved for dedicated APIs. Invalid precommit metadata produces a safe 500. Responses 204, 205, and 304 reject non-empty bodies.

http_cookie(name, value) creates a structured cookie with optional path, domain, max_age, expires, secure, http_only, and same_site. SameSite None requires Secure. Up to 64 cookies of at most 4096 serialized bytes each are emitted as distinct fields. http_redirect(location, status?) validates safe Location metadata and accepts only 301, 302, 303, 307, or 308; it does not authorize a URL.

Files, NDJSON, and embedded assets

http_serve_file(request, writer, path, content_type?) streams one explicit regular-file path in bounded 64 KiB reads. GET supports one closed, open-ended, or suffix byte range, returning 206 or 416 as appropriate; malformed, unknown-unit, and multipart ranges are ignored. HEAD returns full-representation metadata without reading the body. The helper does no URL decoding, traversal cleanup, root containment, symlink policy, authorization, cache validation, compression, or path normalization. Use immutable application-owned paths that the application has already authorized.

http_write_ndjson(request, writer, value) validates and serializes one JSONIC value, appends exactly one LF, writes, and flushes it. The first record selects application/x-ndjson; an empty stream must set that content type itself. Memory is bounded by the largest record, not the complete stream. HTTP chunking is independent of NDJSON record boundaries, and consumers must treat a truncated final record as incomplete.

app.static(prefix, files, fallback?) serves an application-provided map of embedded paths to contents. It remains distinct from http_serve_file, which reads an explicit filesystem path at request time.

WebSocket and PTY composition

P10 certifies that an application WebSocket route can own a PTY session using only the ordinary public APIs. The handler passes request.cancellation to pty_spawn, validates binary input and explicit text controls, copies bounded PTY output chunks through synchronous binary WebSocket writes, closes the PTY on every terminal path, and joins its output task. There is no built-in WebSocket-to-PTY bridge, terminal protocol, or runtime dependency between the WebSocket and PTY components.

HTTPS and deployment boundaries

function main(string command, string[] args) -> int : (NetworkError, TlsError) {
    app := http_server();
    app.get("/health", (http_request request) => { return http_text("secure"); });
    app.listen_tls("127.0.0.1", 8443, args[0], args[1]);
    return 0;
}

listen_tls(host, port, certificate, private_key, max_requests?) uses OpenSSL, requires TLS 1.2 or newer, and validates a PEM certificate chain and matching PEM private key before opening the listener. Missing, unreadable, invalid, or mismatched material raises TlsError; there is no plaintext fallback. The handshake has one absolute deadline equal to the shortest configured read, write, or idle timeout. Saturated TLS connections close before handshake.

Certificate issuance, rotation, ACME, HTTP/2, reverse-proxy interpretation, Unix-domain listeners, and server-side client-certificate/mTLS validation are not provided. Forwarded headers are ordinary untrusted request headers unless the application validates them. Place the server behind a proxy only with an explicit application and network trust policy.

Related