SFEP-0019
sfn/http — Typed HTTP Surface
- Status
- Accepted
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect
- Tracking
- #1321
Proposal: sfn/http — typed HTTP server/client surface
Status: approved (design gate cleared 2026-06-13) — groomed into a tracking epic + Wave 1–4 issues
Decisions locked at the gate: handler API = return-based fn(Request) -> Response; v0 handlers
restricted to top-level named functions (closures documented + best-effort diagnostic, per
open question 2); ServerConfig.host dropped from the v0 signature.
Depends on: M4 epic #965 (closed 2026-06-12) — Sailfin-native scheduler + sfn_serve
Related: runtime/sfn/concurrency/serve.sfn (raw server), runtime/sfn/adapters/http.sfn
(raw client), capsules/sfn/http/src/mod.sfn (typed stubs), #1308 / #1318 (string-ABI
reshaping — deliberately not touched), #1220 (move semantics — designed-around).
1. Goal
Turn the sfn/http capsule into a real, typed HTTP/1.1 server (and incrementally
client) surface so users can write web servers in native Sailfin:
// In a consumer capsule whose capsule.toml declares [dependencies] "sfn/http" = "*"import { serve, Request, Response, response, not_found } from "sfn/http";
fn handler(req: Request) -> Response { if strings_equal(req.path, "/") { return response("Welcome to Sailfin!"); } return not_found();}
fn main() ![net, io] { // Handlers are top-level fns passed address-taken (closures unsupported in v0). serve(handler as * fn (Request) -> Response, 8080);}This is the “follow-up wave” the serve.sfn header (lines 24–36) explicitly names:
the fn(Request) -> Response struct-marshalling layer on top of the proven raw
fn(* u8) -> * u8 runtime ABI.
2. Handler API decision
Chosen: return-based fn(req: Request) -> Response (Deno.serve / Cloudflare
Workers fetch shape).
Rationale:
- Single marshalling boundary. The runtime ABI is one value in, one value out
(
* u8request bytes →* u8response bytes). A return-based handler maps onto it 1:1; an Express-stylefn(req, res)mutation API would need a mutableresobject threaded across the boundary and a “did the handler respond?” protocol. - No mutable aliasing. A shared mutable
resis exactly the #1205-class aliasing shape the ownership floor (#1209) exists to forbid, and it fights #1220 (channel-send/spawn as moves). Value-return composes with moves; mutation does not. - Boring syntax / LLM-friendly.
Request -> Responseis the dominant modern convention (Deno, Workers, Bun, Go’shttp.Handleris morally request→write-once). LLMs generate it reliably. - Methods-on-structs not required.
res.send(...)needs method dispatch on a capsule struct; free functions (response(...),json_response(...)) need nothing new.
Reconciling the three in-tree shapes:
| Shape | Disposition |
|---|---|
runtime fn(* u8) -> * u8 |
Stays as the substrate. Untyped serve(handle, 8080) with a fn(string) -> string handler keeps working (pinned by test_runtime_serve.sh). |
capsule fn(Request) -> Response |
Becomes the real, typed public API. serve(handler: fn(Request) -> Response, port: int) replaces the any-typed stub. |
examples/web/http-server.sfn Express-style fn(req, res) + { port: 8080 } |
Rewritten to the return-based form in the same PR that ships typed serve (Wave 2). The Express shape never worked (serve was a print stub) so there is no compat concern. |
ServerConfig is dropped from the v0 signature (kept as a type only if trivially
useful later): the runtime binds INADDR_ANY unconditionally, so a host field would
be parsed-but-not-enforced — exactly what we don’t ship. v0 is serve(handler, port: int = 8080); ServerConfig { port, host } returns when host binding is real.
3. Architecture
3.1 Where things live
- All marshalling lives in the capsule (
capsules/sfn/http/src/), in plain high-level Sailfin (string,string[], structs). No retyping of runtime descriptors, no arena threading, no interaction with #1318’sOwnedBufreshaping. This works because Sailfin strings are NUL-terminatedi8*at the ABI (sfn_str_to_cstr/from_cstrare identity bridges,runtime/sfn/string.sfn:202), so a trampoline typedfn(raw: string) -> stringis the runtime’sfn(* u8) -> * u8— proven by the pinned e2e probefn handle(req: string) -> stringincompiler/tests/e2e/test_runtime_serve.sh. - One additive runtime entrypoint (
runtime/sfn/concurrency/serve.sfn):sfn_serve_framed(handler: * u8, port: i32)— identical accept loop, but the handler’s return is the full pre-framed HTTP response (status line + headers + body) sent verbatim;null→500. This is what makesResponse.statusandResponse.headersreal instead of parsed-but-ignored (today_serve_send_responsealways frames200 OK). It is a new Sailfin-only symbol — purely additive, with no C definition, header, or shared-struct layout to consider (the runtime is pure Sailfin). - One small compiler change (
compiler/src/llvm/expression_lowering/native/core.sfn:1415): the bespokeserve(lowering text-matches any call spelledserve(...)and lowers it straight to@sfn_serve(handler, port). An imported capsuleservewould be silently hijacked (its typed handler bitcast to a raw handler, its config arg truncated to i32). Fix: gate the bespoke path onservenot resolving to a user-defined/imported function (consult thefunctionsmap already in scope; fall through to generic call lowering when found). Bareserve(handle, 8080)with no import is unaffected, so the pinned e2e stays green.
3.2 Data flow
user: serve(handler, 8080) [capsule serve, ![net, io]] └─ stores handler code-pointer in module global let mut _handler_addr: i64 (the future.sfn `_sfn_g_queue_addr` pattern) └─ extern call: sfn_serve_framed(_sfn_http_trampoline as ... as * u8, port as i32)
runtime: accept → pool worker → dispatch (fn(* u8) -> * u8) └─ _sfn_http_trampoline(raw: string) -> string [capsule, top-level fn] ├─ parse_request(raw) -> Request [capsule wire.sfn] ├─ user handler(Request) -> Response (called via * fn cast from _handler_addr) └─ serialize_response(rsp) -> string [full HTTP/1.1 bytes]
runtime: send verbatim, Connection: close, close(fd)3.3 Request population (v0 honesty)
_serve_recv_request reads only to the \r\n\r\n terminator (GET-shaped v0), so:
- v0 (Wave 2):
method,path(query split off),query(raw string),headers: string[](“Name: value” lines),body= whatever trailed the terminator in the final chunk — documented as unreliable; GET-only support. - Wave 3 (runtime, additive):
_serve_recv_requestdrainsContent-Lengthbytes after the terminator (capped, e.g. 1 MiB) →bodybecomes real, POST ships. No ABI change.
3.4 Response serialization (capsule wire.sfn)
- Status line from
Response.statuswith a small reason-phrase table (200, 201, 204, 301, 302, 400, 401, 403, 404, 405, 500, 502, 503; default"Status"). - User headers emitted as-is after CR/LF rejection (any header string containing
\ror\nis dropped — header-injection guard). Content-Lengthcomputed frombody.length;Connection: closeappended.- Text bodies only in v0 (strings are NUL-terminated; embedded-NUL binary bodies are out of scope and documented).
3.5 Handler-value restriction (v0)
The handler slot stores a bare code pointer (i64) and the trampoline calls it via a
* fn cast — the same env-less indirect-call shape sfn_serve itself uses. Closures
({fn_ptr, env}, #689) have a different representation, so v0 requires a top-level
named function as the handler (documented; same restriction the raw serve has
today). Closure-handler support is a follow-up (store the full fn value / add an
env-aware dispatch) and is listed as an open question below.
4. Scope cut
| Surface | v0 (this proposal) | Follow-up | Out (post-1.0) |
|---|---|---|---|
Typed serve(fn(Request) -> Response, port) |
yes (Wave 2) | — | — |
| Status codes + headers honored on the wire | yes (sfn_serve_framed) |
— | — |
| Request: method, path, query, headers | yes | — | — |
| Request body (POST) | no — documented GET-only | Wave 3 (Content-Length drain) | — |
Header/query accessors (header(req, n), query_param(req, n)) |
yes (Wave 1, pure capsule) | — | — |
Typed client (fetch-style → Response with status/headers) |
no — get/post body-string wrappers stay |
Wave 4 (needs additive sfn_http_request_raw returning the full raw response) |
— |
Routing (Router) |
no — handler-side if req.path == ... idiom |
shipped (SFN-37 — Router/route/dispatch with {param} path capture in router.sfn; handlers are top-level fn(Request, Params) -> Response registered by address, sidestepping the closure-handler story; 405 on method mismatch, 404 on no match) |
— |
| Keep-alive | no (Connection: close default) |
shipped (#1711 — the serve connection worker loops recv→dispatch→send; serialize_response is negotiation-aware; a native single-connection-reuse client sfn_http_conn_* lands in the adapter) |
— |
| Closure handlers | no (top-level fn only, documented) | follow-up | — |
ServerConfig.host binding |
dropped from v0 API | with host-bind runtime support | — |
DNS resolution (getaddrinfo) |
no — localhost / dotted-quad only | shipped (#1707 — real hostnames resolve via getaddrinfo, first IPv4/A record; sfn/net::resolve wired) |
— |
| Chunked response decode (client) | no — Content-Length only |
shipped (#1708 — _extract_body decodes Transfer-Encoding: chunked on the get/post/download body-extraction path, length-tracked; chunked request decode on serve stays out) |
— |
TLS (https:// client + serve termination) |
— | shipped (SFEP-0036, epic #1540 B1 — outbound https:// on http.*/capsule get/post with peer-chain + hostname verification against the system CA store; inbound termination via the sfn_serve_tls runtime entry; runtime/sfn/platform/tls.sfn. Typed fetch/keep-alive client stays HTTP-only for now) |
— |
| Redirects | — | — | out (matches client v0 limits) |
| Per-response buffer free / Task reaping | no — documented leak (same class as the existing fire-and-forget Task leak) | request-scoped arena follow-up (ties to ownership/arena work) | — |
Stage1 readiness: everything in the v0 column is enforced end-to-end (status/headers actually hit the wire; the GET-only body limit is documented, not implied-working).
5. Sequencing (pickable issues)
Wave 1 — sfn/http wire layer (S). Pure capsule, no deps, first mergeable PR.
capsules/sfn/http/src/wire.sfn:parse_request(raw: string) -> Request,serialize_response(rsp: Response) -> string,header(req, name) -> string?,query_param(req, name) -> string?; path/query split; CR/LF header guard; reason-phrase table.capsules/sfn/http/tests/wire_test.sfnunit tests (parse a canned GET/POST head, round-trip a 404-with-headers response, injection guard).- No runtime, no compiler, no example changes. Independently shippable.
Wave 2 — typed serve vertical slice (M). The first user-visible slice.
Bundles the compiler gate with its single consumer (per decomposition discipline —
splitting would add a PR with no standalone value; no seed cut needed because the
compiler change and the capsule consumer land in one make compile pass and the
compiler itself never calls serve).
- Runtime:
sfn_serve_framedinruntime/sfn/concurrency/serve.sfn(additive; reuse_serve_*helpers; conn ctx grows a mode word or a second worker fn). - Compiler: gate the bespoke
serve(lowering (compiler/src/llvm/expression_lowering/native/core.sfn:1415) onservenot resolving infunctions. - Capsule: replace the
servestub inmod.sfnwith the typed signature, handler slot global,_sfn_http_trampoline,extern fn sfn_serve_framed(...). - Example: rewrite
examples/web/http-server.sfnto the return-based form. - e2e: new
compiler/tests/e2e/test_http_capsule_serve.sh— loopback round-trip asserting a non-200 status and a custom header arrive on the wire; plus an IR probe that an importedservedoes not lower to@sfn_servedirectly. - Pre-flight verifications (cheap, before coding): (a) struct params/returns lower as
single pointers at the
* fncast boundary; (b) thefunctionsmap at core.sfn:1415 contains imported capsule functions; (c) capsule sources acceptextern fn+as * u8casts likeruntime/sfnmodules do.
Wave 3 — POST body draining (S).
- Runtime:
_serve_recv_requestparsesContent-Lengthand drains the body (cap ~1 MiB, over-cap → 500 path). No ABI change. - Capsule: none (wire.sfn already parses the body when present).
- e2e: POST round-trip through the typed handler echoing
req.body. - Docs: lift the GET-only caveat.
Wave 4 — typed client (S/M, optional for the server goal).
- Runtime (additive):
sfn_http_request_raw(method, url, headers, body) -> * u8returning the full raw response inruntime/sfn/adapters/http.sfn(shares the existing socket helpers; same v0 limits: no TLS/DNS/chunked). - Capsule:
fetch(method, url, headers, body) -> Response ![net]parsing via a response-side twin ofwire.sfn’s request parser.get/postwrappers unchanged. - e2e: loopback client↔server round-trip — the capsule talks to itself.
Dependency order: 1 → 2 → 3; 4 after 1 (parallel to 2/3 in principle, but sequencing after 3 lets its e2e use the capsule server as the test peer).
6. Risks / open questions
- Bespoke-lowering gate regressions. Text-matched lowerings are fragile; the gate
must not break bare
serve(h, port)(pinned e2e) nor accidentally suppress the bespoke path inside the capsule itself. Mitigation: the capsule never spells a callserve(...)internally — it calls thesfn_serve_framedextern directly. - Closure handlers typecheck but crash (a closure value bitcast to a bare code pointer). Resolved at the design gate (2026-06-13): ship v0 with the documented top-level-function restriction + a best-effort diagnostic (“serve handler must be a top-level function”). Closure-handler support is a post-v0 follow-up (store the full fn value / env-aware dispatch). Wave 2 is not blocked on closure-value plumbing.
- Per-request allocations are never freed (handler-built strings come from the
runtime allocator; the worker can’t
free()them). Same class as the documented fire-and-forget Task leak. Acceptable for v0? The principled fix is a request-scoped arena — flag for the ownership/arena track, not this epic. - Effect enforcement depth. Extern fns must not declare effects
(
typecheck_types.sfn:1388), so thesfn_serve_framedcall inside the capsule is effect-invisible; enforcement rides on the capsuleserve’s own![net, io]annotation (pinned by test). The general “externs are effect holes” gap is pre-existing and out of scope here. - Single server per process (one global handler slot;
sfn_serve*never returns and owns its pool). Matches the runtime’s existing shape; document it. - Struct ABI assumption (single-pointer structs across the
* fncast) is load-bearing for Wave 2 — verify in pre-flight, escalate to the architect if structs lower by-value anywhere on this path.
7. Pipeline touchpoints
| Stage | Files |
|---|---|
| Capsule | capsules/sfn/http/src/mod.sfn, capsules/sfn/http/src/wire.sfn (new), capsules/sfn/http/capsule.toml (version bump), capsules/sfn/http/tests/wire_test.sfn (new) |
| Runtime (Sailfin) | runtime/sfn/concurrency/serve.sfn (Wave 2: sfn_serve_framed; Wave 3: body drain), runtime/sfn/adapters/http.sfn (Wave 4: sfn_http_request_raw) |
| Runtime (C) | none — no symbol flips, no header edits, no shared-layout hazards |
| Compiler | compiler/src/llvm/expression_lowering/native/core.sfn (~line 1415, Wave 2 gate only); compiler/src/llvm/runtime_helpers.sfn only if a serve_framed registry row is wanted for defense-in-depth (optional) |
| Examples | examples/web/http-server.sfn (rewrite), examples/README.md (capabilities note) |
| Tests | compiler/tests/e2e/test_http_capsule_serve.sh (new), compiler/tests/e2e/test_runtime_serve.sh (unchanged — must stay green), per-wave capsule unit tests |
| Docs | docs/status.md (http capsule row), site/src/content/docs/docs/reference/standard-library.md (sfn/http section: API, v0 limits), runtime/sfn/concurrency/serve.sfn header comment (framed contract) |