ADR-289: An HTTP Server Is the Application’s Own — std Lends the Socket, the http Package Speaks the Protocol, and a Handler Is a Lambda Over the Request

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: what an HTTP handler is given and what it gives back (G6 in fortunes.nika); how a response reaches the socket when it is a file, and whether the program has to read it; whether http is in std at all (open-decisions.md’s what is std::http); what a program writes to be a microservice and who decides what is exposed (open-decisions.md); what std::net is (Part III 17.1’s one line, and #90’s blocker); whether nikaia serve is still coming; and whether a route may be refused, or listed, by what its handler touches (open-decisions.md, decided by the owner) Related: ADR-303 D1, D2, D3, D5, D6 (the server is Nikaia’s own, TLS is rustls, I/O split by kind, the runtime file, HTTP/1.1 first, and the order of the work), ADR-303 D8-D12 (a readiness wait is a registration — the layer every socket wait here sits on), ADR-121 D1 and D4 (the bell, and readiness as something awaitable), ADR-290 (how the HTTP/1.1 parser becomes Nikaia: std::http1), ADR-108 D1-D4 (a path names its root at the call; Anywhere is listed by --trust), ADR-010 D1, D2 and D8 (polarity, the provenance column, untrusted paths), ADR-017 D2 (html::Raw is the type of markup), ADR-283, D19 above all (views, and the one shared Bytes buffer), ADR-292 D3 (the touches column and what it orders), ADR-281 D30 (no alias analysis a compilation depends on), ADR-297 D6 (the drain), ADR-037 D2 (one thread for user code at no), ADR-140 D2 (a type is constructed by its anonymous constructor), ADR-203 D1 (an enum’s cases are in the ledger, so a match on Method is complete), ADR-286 D15 (pub is a package word; one path is one package)

1. Question

What does a Nikaia program write to serve HTTP, and which part of that is the language’s, which std’s and which a package’s?

2. How others do it

Language / tool Its answer What it costs there Source
Python, python -m http.server a standard file server, one command, binds every interface a footgun by default; it serves a directory and demonstrates nothing about an application D12
Rust, hyper on tokio a finished server over a readiness runtime a reader picks a runtime before binding a socket; async/await in every signature ADR-303 D1, D10
Apache httpd EnableSendfile off by default (it was on in 2.2); EnableMMAP warns of a bus error when NFS truncates a mapped file the mechanism moved toward off for correctness, not speed D24, D30; docs/history/zero-copy-send.md §5
nginx sendfile off by default; sendfile_max_chunk for fairness; SF_NODISKIO and a thread pool against a cold page cache a blocking sendfile occupies a worker D24, D30
HAProxy, lighttpd kernel splicing opt-in with a copy as fallback; server.network-backend names the mechanism as configuration — D24, D25

What every server above converged on, independently: the application declares what answers, the server chooses how it travels, the operator may overrule. What does not transfer is the runtime choice: a Nikaia program binds a socket without naming async, await, epoll or poll (D10), which no language in the table offers.

3. Options, weighed

Option Rejected because Was
http as a module of std moving a module out of std later breaks every use std::http; moving a package in is an addition. Nothing in it needs the compiler D2, D3
a standard file server, nikaia serve [dir] nothing waits on it, the people who want one have one, and it demonstrates nothing of the language (no handler, routing, ledger or provenance) D12
the operator names the routes for an application an application’s author decided already; a tool that overrode them removes the reason to write it D12
every pub fn is a route; an @route word pub is a package word; giving it the network may call it makes the dangerous meaning invisible (ADR-010 D1). No word is needed: the chain runs across a package boundary D14
a hidden current_request() thread-local state is a lie under work stealing and untestable D17
fn(request, response) a mutable response makes was it sent? every layer’s question D17
a Request argument always required breaks fn { "Hello World" }, which the implicit-argument rule already answers D17
a returned String served as HTML every string becomes a potential injection D18
a reusable read buffer for net::read a second shape with its own lifetime story; what read returns may be kept D7
a non-blocking connect a second readiness shape for a case nothing has D10
mmap per request 2–3× worse than reading below a megabyte, in every run D24
IORING_OP_SPLICE through a pipe 52–58 µs machine CPU at 4 KiB against 9–45 for every other vehicle D25
sendfile named by the program the mechanism is std’s and the operator’s, as in every server of §2 D24
refuse a route by what its handler touches (option 2) would mostly refuse correct programs, on a granularity the column does not have D31
list every route’s touches in --trust (option 3) almost every route reaches something; a report long by construction is read once, and its filter is undesigned D32, D33

The numbers that decided things, one line each:

4. Decision

Where it lives

D1 — http is a package, not a module of std, and it lives in examples/ until it is ripe.

It is reached by a path, http = { path = "../http" }, the arm the build already resolves. examples/ rather than a repository of its own, because there it is compiled by the test that compiles every example and grows against a moving compiler.

D2 — Subtract what needs the compiler, and nothing HTTP-shaped is left in std.

What needs the compiler is already in std under its own name: an untrusted path (ADR-010 D8, and ADR-108’s root at the call), escaping by type (std::html), a body the program never allocated (fs::map, Bytes). What is left — the protocol and the framework — needs no compiler, is large and ages.

D3 — The direction is chosen by which mistake stays cheap.

A package moved into std later is an addition; a module moved out is a break. So it starts outside, even for a reader who thinks a server belongs in a standard library.

D4 — It ripens at its own speed, and that is the reason it moved.

A version of std is a version of the language; a protocol wants to change its surface twice and settle. Outside, that is two versions of a package. Ripeness is its surface has stopped moving, not it is finished.

D5 — This is not blocked by how a package is named by a version.

A path dependency needs no registry. The day http leaves examples/ is D4’s ripeness, and a later decision.

D6 — The socket is std’s and the protocol is the package’s.

D2’s subtraction carried one item further: a socket is an operating-system resource of the kind fs and io own, and a Nikaia package cannot make a syscall. std lends the socket; the package speaks HTTP. (The text half of HTTP/1.1 — where a head ends, what its lines say — is std::http1, reaching no socket, by ADR-290’s route; the protocol’s meaning stays the package’s.)

The socket: std::net

D7 — Four verbs and two types, and nothing else.

net::listen(address) -> Listener          // bind and listen
net::connect(address) -> Connection       // the other end, for a client
Listener.accept() -> Connection           // the next one, waiting
Listener.address() -> String              // what port 0 turned into
Connection.read() -> Bytes                // what the peer sent
Connection.write(bytes)                   // all of it
Connection.peer() -> String               // who
Connection.close()                        // now rather than at the drop

read returns Bytes, ADR-283 D19’s one shared buffer, because what comes back may be kept. write takes all of it; the loop a partial write would need is inside. An empty read is the peer closing.

D8 — What comes off a socket is untrusted, and it is the first such source in std.

net::Connection::read carries provenance = "untrusted" in the ledger; ADR-010 D2’s analysis reads it unchanged.

D9 — touches gains socket, and it names no socket.

A connection is what a program touches, and telling two apart is the alias analysis ADR-281 D30 refuses. Two reads never conflict; a write orders against every socket touch.

D10 — Everything that waits gives the thread up, and the program cannot tell.

accept, read and write are non-blocking underneath and await ADR-121 D4’s readiness (served by ADR-303’s registrations), so one thread serves many connections at user_parallelism = no exactly as at yes. Nothing in a .nika file that binds a socket says async, await, epoll, io_uring or poll (ADR-303 D3). bind answers immediately and connect blocks — stated, not hidden.

D11 — Closing is the drop, and a Cleanup is not waited for.

close names the moment where a program wants one.

The server

D12 — There is one server, and it is the application’s; nikaia serve is cut.

An application that serves has an author who decided already, and everything about it — configuration, port, what it hard-codes — is the programmer’s; nothing is the compiler’s business and no tool overrides it. There are two products, each right about itself stands as the reason; the other product, a standard file server nikaia serve [dir] whose every setting would have been the operator’s, is cut — for schedule (nothing waits on it) and for value (it would show the part of the language it has least of). A cut is written down so that no list reads it as not yet.

D13 — The limits and the address are the program’s.

Changed by ADR-326 D2, D5-D7: there is no connection cap; admission is a strategy of net::serve, and connections is the number after which the server returns; head_wait gives way to head_timeout and body_bytes_per_second.

body_cap, head_cap, head_wait and connections are options on http::listen, with defaults that are a server and not a denial of service, and a program names only what it changes. A body cap and a connection cap exist from the first commit. http::listen(at) takes the address and there is no default to make safe: the localhost default was fail-closed for a product whose author was absent. Binding past localhost is the kind of thing ADR-108’s shape lists — recorded per site, listed by nikaia --trust — and that listing belongs there, not to either product.

D14 — An application routes with a call, and there is no @route and never every pub fn.

Made precise by ADR-332: what a handler declares is all it receives.

Routing is the package’s own form, .route(…) with a handler, and needs no language change. No word on the declaration, and pub is never read as the network may call it. A type is constructed as http::Server(), not http::Server::new() (ADR-140 D2).

D15 — The MVP’s protocol is the smallest one a microservice needs, and its parser is a Nikaia grammar.

Changed by ADR-290 D17: HTTP/1.1’s text half is written in Nikaia, std::http1, and reached from Rust as an ordinary module. Changed by ADR-326 D9: connections are kept alive.

GET and POST, bodies by Content-Length, Connection: close, no chunked transfer and no TLS — ADR-303’s order read as a scope. The parser is std::http1’s grammar (D6); the protocol’s meaning stays the package’s.

D16 — examples/hello-http/ is the demonstration, and it is a real server.

One address and one function: http::listen(at) fn(request) { … }, the handler deciding, driven by a test over a real socket — port 0, the bound address read back, both methods, a body by Content-Length, and each refusal.

The handler

D17 — The request is the handler’s first argument, under the rule lambdas already have.

A block lambda takes the arguments its body names (Part I 5.3), so fn { "Hello World" } takes none and fn(request) { … } takes the request. There is no second argument: a response is returned, not filled in.

D18 — A handler returns something that answers the request, and the library says which types do.

returns becomes
String 200, text/plain; charset=utf-8
html::Raw 200, text/html; charset=utf-8
Bytes 200, application/octet-stream (D22)
Response itself
T throws the value on success; on failure 500 with a generic body, the error logged

An error’s message is the operator’s and never reaches the client. html::Raw earning text/html is ADR-017 D2 read from the other end; a String may not be sent as markup.

D19 — A status, a header or a body of one’s own is a Response, a value built where it is returned.

Named fields, no builder chain; headers are a field like the others.

D20 — The request’s strings are views, and the request owns the buffer.

path(), header(name) and query(name) are views into the bytes the connection read (ADR-283); one kept past the response is owned first. The surface is small because each entry is a promise: method() an enum, path(), query(name) and header(name) nullable rather than empty, header case-insensitive, body() the bytes. No params map, cookies or multipart.

D21 — Neither build switch enters, and neither does async.

The same handler runs at every setting (ADR-296 D19). It does I/O, so it is not sync, and it carries no async and no await.

The response body

D22 — A response body may be a buffer the program did not allocate.

Bytes, and a Mapped that derefs to it, are bodies (D18’s third row). A page mapped once outside the handler costs a reference count per request. A handler answering with a captured buffer duplicates the handle, since it is called many times (ADR-312 D1).

D23 — A response body may be a file the program never read: http::File.

http::File is a response body, not a file handle; std::fs gains no handle type. Its Content-Type follows the extension, else application/octet-stream, and a Response header overrides it. The escaping contract is untouched.

D24 — Which mechanism carries the bytes is std’s choice, and what it may keep decides more than the mechanism.

std chooses at run time between sendfile(2), a mapping written out and a plain read; std may not map per request; an operator may overrule it with a send-file key in ADR-303 D5’s runtime file, default auto, pinnable to never or a size. No crossover size is named.

D25 — splice on the ring is not the mechanism.

A file cannot be spliced to a socket without a pipe between them, which is a second transfer. The column that answers is the machine’s busy time, not the sending thread’s.

D26 — TLS and HTTP/2 turn D23’s zero-copy path off, as a property and not a fallback.

Bytes that never enter the process cannot be encrypted, and HTTP/2 interleaves DATA frames; the file is mapped and written through the session. On wasm32-unknown it is read. kTLS is not decided (§6).

D27 — What the client sees is settled before the status line; a failure after it is not a status.

http::File stats the file when the response is built, which gives Content-Length and turns a missing file into D18’s 500. A failure mid-body closes the connection short and is logged.

D28 — http::File takes its root like every path-taking std function, and a name that leaves it is 404.

http::File(path, root), an fs::Root with no default (ADR-108 D1-D3); fs::Outside is answered with 404 before the status line; fs::Root::Anywhere is listed by --trust. It refuses, it does not sanitise, and no provenance follows the name.

D29 — What std keeps is bounded, invalidated, not a promise, and only under a named root.

A table from resolved path to mapping, sized by static-cache in the runtime file (0 disables it); entries dropped when the file’s identity or modification time moves, with no key to turn that off; no hit is promised or reported; nothing is kept for an Anywhere root. A page that must be kept is mapped by the program (D22).

D30 — Two hazards bound D24 and D29.

A mapping whose file another writer truncates faults the process, so std keeps no mapping of a file it does not own the directory of. A sendfile on a cold page cache blocks, and at user_parallelism = no that stops every connection, so whatever serves D23 pauses through the executor — a file to a socket reaches the I/O thread, not the handler’s (ADR-303 D3).

Routes and touches

D31 — A route is not refused by what its handler touches.

No reaching: at a route, no word, no NK code: touches stays a fact. A refusal would mostly refuse correct programs, on a column whose path is a parameter’s name and not a value.

D32 — And no report lists it: nikaia --trust gains no section.

In a real application almost every route reaches something, and a report whose rows nearly all fire tells a reviewer nothing. A useful listing would need a filter and a switch nobody has designed; refusing nothing costs nothing and stays extendable.

D33 — A report earns its keep by what it leaves out.

ADR-108 D4’s listing works because it lists ways around a check and is short by construction; a list of what every handler touches is long by construction. fs::Root’s listing is not option 3 arriving for the filesystem.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.