← server · API reference · version 0.7.5
5 file(s), 892 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Server struct | 49 | — | 174 186 187 195 204 205 … (24 total) |
WebSocket struct | 55 | — | 475 478 485 492 499 506 … (8 total) |
Request struct | 61 | — | 244 246 260 262 273 281 … (25 total) |
listen fn | 174 | SRV-001 | 177 25 23 24 25 |
try_listen fn | 186 | SRV-001 | 175 177 25 38 39 43 … (7 total) |
listen_on fn | 195 | — | 198 25 |
try_listen_on fn | 204 | — | 196 198 25 |
listen_tls fn | 215 | — | 218 25 |
try_listen_tls fn | 224 | — | 216 218 25 |
try_listen_tls_on fn | 230 | — | 26 |
bound fn | 236 | — | 176 197 217 26 25 39 … (7 total) |
next_nonblocking fn | 244 | — | 26 |
respond fn | 273 | — | 27 |
respond_typed fn | 281 | — | 656 27 |
respond_bytes fn | 299 | — | 27 |
header fn | 310 | SRV-002 | 355 404 28 35 36 38 … (12 total) |
respond_with_headers fn | 332 | — | 361 366 374 381 396 406 … (9 total) |
cors_headers fn | 340 | — | 392 28 |
serve_range fn | 354 | SRV-003 | 410 29 62 63 67 68 … (7 total) |
serve_data fn | 389 | SRV-003 | 29 78 80 83 85 |
respond_html fn | 423 | — | 650 27 |
respond_css fn | 433 | — | 28 |
respond_js fn | 440 | — | 28 |
respond_loft fn | 448 | — | 28 |
respond_404 fn | 457 | — | 661 28 |
close fn | 464 | — | 513 26 45 48 |
ws_upgrade fn | 475 | — | 30 |
send fn | 492 | — | 30 |
send_binary fn | 499 | — | 30 |
last_opcode fn | 506 | — | 30 |
close fn | 513 | — | 464 26 45 48 |
WsEvent struct | 582 | — | 595 617 622 626 636 24 … (7 total) |
run fn | 595 | — | 31 |
poll_event fn | 617 | — | 599 31 |
respond_html fn | 650 | — | 423 27 |
respond_typed fn | 656 | — | 281 27 |
respond_404 fn | 661 | — | 457 28 |
send_to fn | 667 | — | 31 |
broadcast fn | 674 | — | 31 |
disconnect fn | 680 | — | 31 |
src/server.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// --- HTTP Server + WebSocket ---
//
// Two stacks:
// 1. Single-client HTTP / WebSocket: `listen` + `next` + `ws_upgrade`.
// Use this for one-off request handlers. ONE connection is served at a
// time: `next` blocks until a client arrives and `respond` closes it.
// 2. Multi-client WebSocket: `srv.run(on_event)`.
// The clean event-driven surface used by multiplayer servers.
// Loft programs register ONE handler `fn(WsEvent)` and Rust
// drives the accept loop, per-client polling, and disconnect
// detection. Disconnects are silently absorbed — the loft
// program is not notified. See the `run` doc block below.
//
// --- Using it (stack 1) ---
//
// srv = server::try_listen_on("127.0.0.1", 8080);
// if !srv.bound() { return; } // the port was taken; nothing halted
//
// while running {
// req = srv.next(); // blocks until someone connects
// if req.path == "/" {
// req.respond_html("<h1>hello</h1>");
// } else if req.path == "/style.css" {
// req.respond_css("body {{ color: red }}");
// } else {
// req.respond_404();
// }
// }
// srv.close();
//
// Exactly ONE respond per request — it is what closes the connection. Bind to
// `"127.0.0.1"` unless the server is meant to be reachable from the network;
// plain `listen` takes every interface.
//
// ⚠ A CSS OR JS BODY WRITTEN AS A LOFT LITERAL NEEDS ITS BRACES DOUBLED. `{` opens
// an interpolation in loft text, so `"body { color: red }"` is a parse error and
// `"body {{ color: red }}"` is the string above. This bites `respond_css` and
// `respond_js` hardest, because those bodies are mostly braces.
//
// `for req in srv { … }` is the same loop: `next` is an `operator next`, the one
// `for` calls, and the walk ends when it answers null.
// --- Types (must come before all functions) ---
// A listening server, as a native socket handle. `listen` opens one (`listen_on`,
// `listen_tls` and the `try_` forms too), and `next` waits for its next request.
pub struct Server {
pub handle: integer
}
// An upgraded WebSocket connection, as a native handle. `ws_upgrade` makes one from the
// current request; `next` reads a message and `send` writes one.
pub struct WebSocket {
pub ws_id: integer
}
// One HTTP request: its method, path and body, and its headers as `Key: Value` lines.
// `next` on a `Server` answers one, and `respond` or one of its variants answers it.
pub struct Request {
pub method: text,
pub path: text,
pub body: text,
pub headers: vector<text>
}
// --- Internal natives ---
fn tcp_listen(port: integer) -> integer;
#native
fn tcp_listen_on(addr: text, port: integer) -> integer;
#native
fn tcp_listen_tls(port: integer, cert: text, key: text) -> integer;
#native
fn tcp_listen_tls_on(addr: text, port: integer, cert: text, key: text) -> integer;
#native
fn tcp_accept(handle: integer) -> boolean;
#native
fn tcp_accept_nonblocking(handle: integer) -> boolean;
#native
fn tcp_method() -> text;
#native
fn tcp_path() -> text;
#native
fn tcp_body() -> text;
#native
// Request headers of the current request as newline "Key: Value" text.
fn tcp_headers() -> text;
#native
fn tcp_respond(status: integer, body: text);
#native
// Same as `n_tcp_respond` but the caller picks the Content-Type
// header (e.g. `"text/html; charset=utf-8"`). Empty string falls
// back to `"text/plain; charset=utf-8"`. TTT v3 needs this to
// serve the index HTML, the loft client source, and the WASM
// payload from the same loft program that hosts the WebSocket
// game protocol.
fn tcp_respond_typed(status: integer, body: text, content_type: text);
#native
// Respond with a RAW BYTE body — the one response that does not go through `text`.
fn tcp_respond_bytes(status: integer, body: vector<u8>, content_type: text);
#native
// Respond with caller-controlled headers (a vector serialized to newline text).
fn tcp_respond_full(status: integer, body: text, headers: text);
#native
// Binary-safe byte slice [off, off+len) of body — for cutting range slices.
fn tcp_slice(body: text, off: integer, len: integer) -> text;
#native
fn tcp_close(handle: integer);
#native
fn ws_upgrade_native() -> integer;
#native "n_ws_upgrade"
fn ws_recv_native(handle: integer) -> boolean;
#native "n_ws_recv"
fn ws_message_native() -> text;
#native "n_ws_message"
// Opcode of the last frame surfaced by `ws_recv_native`.
// 1 = text, 2 = binary, 8 = close, 9 = ping, 10 = pong.
// Loft programs that handle binary frames check this after a
// successful recv to decide whether the message bytes are text
// (utf-8) or a binary blob.
fn ws_opcode_native() -> integer;
#native "n_ws_opcode"
fn ws_send_native(handle: integer, msg: text) -> boolean;
#native "n_ws_send"
// Send the byte buffer as a binary WebSocket frame (opcode 2)
// instead of a text frame (opcode 1). Same payload encoding —
// loft `text` is a byte buffer; this just changes the frame
// type the peer sees. Used by TTT v5 + plan-36 for
// world_snapshot / world_delta blobs.
fn ws_send_binary_native(handle: integer, msg: text) -> boolean;
#native "n_ws_send_binary"
fn ws_close_native(handle: integer);
#native "n_ws_close"
// --- HTTP Server API ---
// Bind a TCP listener on `port` and return the server.
//
// Failing to take the port is FATAL here, deliberately. The native listener answers
// -1 when the bind fails, and a `Server` built on -1 is indistinguishable from a
// healthy one at every call site: `run` spins forever on an empty event pump, so the
// process reports itself up and serves nobody. Worse, that state is invisible from
// OUTSIDE the process too — a readiness probe that connects to the port reaches
// whoever DID win the bind and reports success — so the failure surfaces somewhere
// else entirely, as a client that hangs or talks to a stranger's server.
//
// Use `try_listen` when losing the port is an outcome you mean to handle: retrying on
// another port, or scanning a range.
// Example: @SRV-001 — halting door vs recoverable door, and why `bound()` is not optional.
pub fn listen(port: integer) -> Server {
srv = try_listen(port);
if !srv.bound() {
panic("server::listen: cannot bind port {port} (already in use, or not permitted) — use try_listen() to handle this");
}
srv
}
// Like `listen`, but hands back an UNBOUND server instead of halting when the port
// cannot be taken. Check `bound()` before using it: every other method on an unbound
// server does nothing, quietly.
// Example: @SRV-001 — the retry shape: try_listen, then bound(), then use it.
pub fn try_listen(port: integer) -> Server {
Server { handle: tcp_listen(port) }
}
// Bind on a chosen address rather than the wildcard `0.0.0.0` that plain `listen` uses.
// `"127.0.0.1"` restricts the server to loopback — unreachable from the network — which is
// the safe default a machine sitting behind a reverse proxy or an SSH tunnel wants, and the
// one `listen` deliberately does NOT give (it binds every interface). An empty `addr` means
// `0.0.0.0`. Halts if the bind fails; `try_listen_on` hands back an unbound server instead.
pub fn listen_on(addr: text, port: integer) -> Server {
srv = try_listen_on(addr, port);
if !srv.bound() {
panic("server::listen_on: cannot bind {addr}:{port} (in use, or not permitted) — use try_listen_on() to handle this");
}
srv
}
// `listen_on` without the halt: an unbound server comes back instead. Check
// `bound()` — every other method on an unbound server does nothing, quietly.
pub fn try_listen_on(addr: text, port: integer) -> Server {
Server { handle: tcp_listen_on(addr, port) }
}
// Serve TLS (https:// / wss://). `cert` is a PEM certificate CHAIN (leaf first, then any
// intermediates — what an ACME client writes as fullchain.pem); `key` is the PEM private
// key. Both are the file CONTENTS, not paths, so the caller reads them however it likes
// (`file(path).content()`), keeping this layer free of any filesystem policy. The op surface
// is identical to a plaintext server — accept, respond, WebSocket upgrade all work the same,
// just over TLS. Halts if the certificate/key is unusable or the bind fails (never a silent
// fallback to cleartext — a TLS server that quietly served http would be the worst surprise).
pub fn listen_tls(port: integer, cert: text, key: text) -> Server {
srv = try_listen_tls(port, cert, key);
if !srv.bound() {
panic("server::listen_tls: cannot bind port {port} over TLS (bad cert/key, port in use, or not permitted) — use try_listen_tls() to handle this");
}
srv
}
// `listen_tls` without the halt. Check `bound()`; a bad certificate and a taken
// port are the same answer here, so log the port before deciding which it was.
pub fn try_listen_tls(port: integer, cert: text, key: text) -> Server {
Server { handle: tcp_listen_tls(port, cert, key) }
}
// TLS on a chosen bind address — `listen_tls` + `listen_on` combined. An empty `addr` means
// `0.0.0.0`.
pub fn try_listen_tls_on(addr: text, port: integer, cert: text, key: text) -> Server {
Server { handle: tcp_listen_tls_on(addr, port, cert, key) }
}
// True when this server actually owns its port. Always true for a `listen()` result
// (that halts otherwise) — it is the check that makes a `try_listen()` result usable.
pub fn bound(self: Server) -> boolean {
self.handle >= 0
}
// Non-blocking variant of `next()`. Returns null immediately if no
// connection is pending. Use this when the server interleaves HTTP
// accept with multi-client WS polling on a single port (single_port
// audience demo / future TTT-style multi-tab apps).
pub fn next_nonblocking(self: Server) -> Request? {
if !tcp_accept_nonblocking(self.handle) { return null; }
Request {
method: tcp_method(),
path: tcp_path(),
body: tcp_body(),
headers: tcp_headers().split('\n')
}
}
// BLOCK until a client connects, and answer its request. The heart of stack 1:
// call it in a loop, respond exactly once, and the connection closes.
//
// Null when the listener can no longer accept, which ends a `for req in srv`.
// `next_nonblocking` is the variant that answers null immediately when nobody is
// waiting.
pub operator next(self: Server) -> Request? {
if !tcp_accept(self.handle) { return null; }
Request {
method: tcp_method(),
path: tcp_path(),
body: tcp_body(),
headers: tcp_headers().split('\n')
}
}
// Answer with `text/plain` and CLOSE the connection. Exactly one respond per
// request — a second is not an error, it just has no connection left to write
// to. `respond_typed` is this with a Content-Type you choose.
pub fn respond(self: Request, status: integer, body: text) {
_ = self;
tcp_respond(status, body);
}
// Send an HTTP response with a caller-specified Content-Type and
// close the connection. Use for HTML / CSS / JSON / WASM payloads;
// `respond` defaults to `text/plain`.
pub fn respond_typed(self: Request, status: integer, body: text, content_type: text) {
_ = self;
tcp_respond_typed(status, body, content_type);
}
// Send a response whose body is RAW BYTES — an image, a font, a wasm module, any
// payload that is not text.
//
// ⚠⚠ USE THIS FOR ANYTHING BINARY; the text responses cannot carry it and FAIL SILENTLY.
// `respond`/`respond_typed` take loft `text`, and a `text` built from non-UTF-8 bytes is
// the EMPTY text (`text_from_bytes` documents exactly that). So serving a PNG through
// them answers **200 OK with Content-Length: 0** — a success the caller cannot
// distinguish from an empty file, and a broken image with no error anywhere. Measured
// 2026-08-21: the loft review viewer had been serving every `doc/images/*.png` that way.
//
// `content_type` empty defaults to `application/octet-stream`, which is the honest
// answer for bytes of unknown kind — name the real type when you know it
// (`image/png`, `font/woff2`, `application/wasm`).
pub fn respond_bytes(self: Request, status: integer, body: vector<u8>, content_type: text) {
_ = self;
tcp_respond_bytes(status, body, content_type);
}
// --- #517 server: request-header lookup + full-header responses ---
// Look up a request header case-insensitively (e.g. "Range", "Origin",
// "If-None-Match"); returns "" if absent. Values containing ':' (an Origin URL,
// a date) are rejoined intact.
// Example: @SRV-002 — why a hand-rolled `h.split(':')[1]` over `headers` loses the port.
pub fn header(self: Request, name: text) -> text {
prefix = name.to_lowercase() + ":";
for h in self.headers {
if h.to_lowercase().starts_with(prefix) {
parts = h.split(':');
value = "";
i = 1;
while i < parts.len() {
if i > 1 { value += ":"; }
value += parts[i] ?? "";
i += 1;
}
return value.trim();
}
}
""
}
// Send a response with caller-controlled headers (Content-Range, ETag, the CORS
// headers, …). `headers` is a vector of "Key: Value" lines; Content-Length is
// auto-added from the body unless a header supplies one; the body is written
// verbatim (binary-safe).
pub fn respond_with_headers(self: Request, status: integer, body: text, headers: vector<text>) {
_ = self;
tcp_respond_full(status, body, headers.join("\n"));
}
// Standard CORS headers so a browser client can read Content-Range /
// Content-Length / ETag on a cross-origin byte-range fetch. `origin` is the
// allowed origin ("*" for any).
pub fn cors_headers(origin: text) -> vector<text> {
[
"Access-Control-Allow-Origin: {origin}",
"Access-Control-Expose-Headers: Content-Range, Content-Length, ETag, Accept-Ranges",
"Access-Control-Allow-Headers: Range, If-Range, If-None-Match",
"Access-Control-Allow-Methods: GET, HEAD, OPTIONS"
]
}
// Serve a byte range of `body` when the request carries a Range header, adding
// `extra` headers (CORS / ETag) to the 206 or 416. Returns true if a range
// response was sent; false when there is no Range header (the caller serves the
// full body). Single-range "bytes=off-last" / open-ended "bytes=off-" only.
// Example: @SRV-003 — the boolean says WHO answered; false is the case you must handle.
pub fn serve_range(self: Request, body: text, extra: vector<text>) -> boolean {
spec = self.header("Range");
if spec.len() == 0 { return false; }
total = body.len();
bad: vector<text> = ["Content-Range: bytes */{total}"];
for e in extra { bad += [e]; }
if !spec.starts_with("bytes=") {
self.respond_with_headers(416, "", bad);
return true;
}
bounds = spec.replace("bytes=", "").split('-');
if bounds.len() < 2 {
self.respond_with_headers(416, "", bad);
return true;
}
off = ((bounds[0] ?? "") as integer?) ?? -1;
lt = bounds[1] ?? "";
last = -1;
if lt.len() == 0 { last = total - 1; } else { last = (lt as integer?) ?? -1; }
if off < 0 || last < off || off >= total {
self.respond_with_headers(416, "", bad);
return true;
}
if last >= total { last = total - 1; }
slice = tcp_slice(body, off, last - off + 1);
hdrs: vector<text> = ["Content-Range: bytes {off}-{last}/{total}", "Accept-Ranges: bytes"];
for e in extra { hdrs += [e]; }
self.respond_with_headers(206, slice, hdrs);
true
}
// One-call helper to serve a (possibly binary) resource with full byte-range +
// CORS + HEAD + conditional support — the server counterpart to the web client's
// http_get_range / http_size. `etag` "" skips conditional; `origin` "" skips CORS.
// Example: @SRV-003 — this one takes every branch, so it is always true and always final.
pub fn serve_data(self: Request, body: text, etag: text, origin: text) -> boolean {
cors: vector<text> = [];
if origin.len() > 0 {
for h in cors_headers(origin) { cors += [h]; }
}
m = self.method;
if m == "OPTIONS" {
self.respond_with_headers(204, "", cors);
return true;
}
total = body.len();
base: vector<text> = [];
for h in cors { base += [h]; }
if etag.len() > 0 {
base += ["ETag: {etag}"];
inm = self.header("If-None-Match");
if inm.len() > 0 && inm == etag {
self.respond_with_headers(304, "", base);
return true;
}
}
if self.serve_range(body, base) { return true; }
hdrs: vector<text> = ["Accept-Ranges: bytes"];
for h in base { hdrs += [h]; }
if m == "HEAD" {
hdrs += ["Content-Length: {total}"];
self.respond_with_headers(200, "", hdrs);
return true;
}
self.respond_with_headers(200, body, hdrs);
true
}
// Convenience wrappers for the common cases TTT v3 needs.
pub fn respond_html(self: Request, body: text) {
_ = self;
tcp_respond_typed(200, body, "text/html; charset=utf-8");
}
// 200 as `text/css`.
//
// ⚠ A stylesheet written as a loft literal needs its braces DOUBLED — `{` opens
// an interpolation, so `"body {{ color: red }}"` is what produces `body {`.
// Reading the file instead (`file(path).content()`) sidesteps it entirely.
pub fn respond_css(self: Request, body: text) {
_ = self;
tcp_respond_typed(200, body, "text/css; charset=utf-8");
}
// 200 as `application/javascript`. Same brace-doubling trap as `respond_css`,
// and script bodies hit it on the first function they declare.
pub fn respond_js(self: Request, body: text) {
_ = self;
tcp_respond_typed(200, body, "application/javascript; charset=utf-8");
}
// 200, serving loft SOURCE. There is no registered MIME type for it, so this
// goes out as `text/plain` — which is what makes it read in a browser tab
// rather than download.
pub fn respond_loft(self: Request, body: text) {
_ = self;
// No registered MIME for loft source; text/plain reads cleanly in a
// browser tab and matches what `curl` / fetch consumers expect.
tcp_respond_typed(200, body, "text/plain; charset=utf-8");
}
// The not-found answer. Worth calling on every unmatched path: a request with
// no respond at all leaves the client waiting for a body that never comes.
pub fn respond_404(self: Request) {
_ = self;
tcp_respond(404, "Not Found");
}
// Give the port back. A server that is never closed holds its port until the
// process exits, which is what makes a restarted program fail to bind.
pub fn close(self: Server) {
tcp_close(self.handle);
}
// --- Single-client WebSocket API ---
// Turn the connection the last `next` handed over into a WebSocket. Answers
// null if the request was not a valid upgrade — check before using it.
//
// Takes no request argument because it acts on the one parked connection: this
// is the single-client stack, where there is only ever one.
pub fn ws_upgrade() -> WebSocket? {
h = ws_upgrade_native();
if h < 0 { return null; }
WebSocket { ws_id: h }
}
// The next frame's payload, or null when the socket is done. Nullable here,
// which is what lets a `while` over it end cleanly.
//
// Ask `last_opcode()` afterwards to tell a text frame from a binary one.
pub operator next(self: WebSocket) -> text? {
if !ws_recv_native(self.ws_id) { return null; }
ws_message_native()
}
// Send a TEXT frame. False means the peer is gone — the return is the
// disconnect notice, since nothing else reports one.
pub fn send(self: WebSocket, msg: text) -> boolean {
ws_send_native(self.ws_id, msg)
}
// Send `msg` as a binary WebSocket frame (opcode 2). Loft
// `text` is a byte buffer — pack u8/u16/i32/etc. into it via
// `to_bytes` style code, then ship it.
pub fn send_binary(self: WebSocket, msg: text) -> boolean {
ws_send_binary_native(self.ws_id, msg)
}
// Opcode of the last frame returned by `next` on this socket.
// Use after `next` to decide whether to interpret the payload
// as utf-8 text or as a binary blob.
pub fn last_opcode(self: WebSocket) -> integer {
_ = self;
ws_opcode_native()
}
// Close the socket. The peer sees a clean close rather than a dropped
// connection, which is the difference between a reconnect and an error.
pub fn close(self: WebSocket) {
ws_close_native(self.ws_id);
}
// --- Multi-client WebSocket primitives (internal) ---
//
// These wrap the per-slot accept / poll / iterate primitives that
// the event pump uses internally. They are NOT part of the public
// surface — multi-client servers use `Server::run` instead.
fn ws_accept_native(handle: integer) -> integer;
#native "n_ws_accept_nonblocking"
fn ws_clients_len_native() -> integer;
#native "n_ws_clients_len"
fn ws_client_active_native(id: integer) -> boolean;
#native "n_ws_client_active"
// Event-pump primitives. `ws_next_event_native` returns true if
// it loaded an event into the thread-local slots (kind / cid /
// payload), false if nothing was pending. `ws_idle_sleep_ms`
// yields the CPU briefly when a drain pass produced zero events.
fn ws_next_event_native(handle: integer) -> boolean;
#native "n_ws_next_event"
fn ws_event_kind_native() -> integer;
#native "n_ws_event_kind"
fn ws_event_client_id_native() -> integer;
#native "n_ws_event_client_id"
fn ws_event_payload_native() -> text;
#native "n_ws_event_payload"
fn ws_broadcast_native(handle: integer, msg: text) -> integer;
#native "n_ws_broadcast"
fn ws_idle_sleep_ms_native(ms: integer);
#native "n_ws_idle_sleep_ms"
// --- Multi-client WebSocket API (clean) ---
//
// The public surface for multi-client servers. Loft programs
// register a single event handler via `run` and Rust drives the
// rest: accept, per-client polling, frame parsing, disconnect
// detection.
//
// srv.run(fn(ev: WsEvent) {
// if ev.connected {
// /* welcome / MAP send / seat */
// } else if ev.message {
// /* dispatch by ev.msg_id */
// }
// });
//
// One handler instead of two means the application's `state`
// capture lives in a single closure — mutations through that
// closure are visible across every invocation.
//
// Disconnects are absorbed silently — the loft program is never
// notified. This was an explicit design choice: per-client state
// cleanup happens lazily on next access, not on a disconnect
// callback.
// Event delivered to the on_event handler. `connected` and
// `message` are mutually-exclusive booleans for fast dispatch
// without an integer enum. Wire format `<msg_id>:<payload>`
// is parsed by `run` so msg_id and payload come pre-split.
pub struct WsEvent {
pub connected: boolean,
pub message: boolean,
pub http: boolean, // true for a plain HTTP request (serve a page etc.)
pub cid: integer, // WS client id; -1 for http events
pub msg_id: integer, // 0 for connected / http events
pub payload: text, // "" for connected / http events
pub path: text // request path for http events; "" otherwise
}
// Block forever, draining events and calling the handler.
// Malformed message frames are logged at warn level and silently
// dropped — the handler never sees them.
pub fn run(self: Server, on_event: fn(WsEvent)) {
while true {
drained = 0;
while true {
ev = self.poll_event();
if ev == null { break; }
on_event(ev);
drained = drained + 1;
}
if drained == 0 { ws_idle_sleep_ms_native(2); }
}
}
// Non-blocking single-event drain — returns the next WsEvent, or null
// when nothing is pending. Lets a server that also needs a periodic
// tick (timers, decay, snapshots) run its OWN loop while still using the
// efficient Rust-side event pump: drain `poll_event` in a batch, do the
// periodic work, idle-sleep when a pass produced nothing. `run` above is
// the convenience wrapper for servers that only react to events.
//
// Disconnect (kind 2) and malformed message frames are skipped internally
// (the caller never sees them), so a null return always means "idle".
pub fn poll_event(self: Server) -> WsEvent? {
while ws_next_event_native(self.handle) {
kind = ws_event_kind_native();
cid = ws_event_client_id_native();
if kind == 0 {
return WsEvent { connected: true, message: false, http: false,
cid: cid, msg_id: 0, payload: "", path: "" };
} else if kind == 3 {
// HTTP request — payload carries the path; reply via respond_*.
return WsEvent { connected: false, message: false, http: true,
cid: cid, msg_id: 0, payload: "", path: ws_event_payload_native() };
} else if kind == 1 {
raw = ws_event_payload_native();
i = raw.find(":");
if i != null {
// A non-numeric id prefix is as malformed as a missing ":" —
// warn-and-drop below rather than masking it as id 0.
mid = raw[..i] as integer?;
if mid != null {
return WsEvent { connected: false, message: true, http: false,
cid: cid, msg_id: mid ?? 0,
payload: raw[i + 1..], path: "" };
}
}
log_warn("server: dropped malformed frame from {cid}: {raw}");
}
// kind == 2 (Disconnected) absorbed silently; keep draining.
}
null
}
// Reply to the HTTP request currently parked by a `http` WsEvent and
// close it. Call exactly once from the handler for an `ev.http` event.
pub fn respond_html(self: Server, body: text) {
_ = self;
tcp_respond_typed(200, body, "text/html; charset=utf-8");
}
// The `Server` twin of `Request::respond_typed`, for replying to the request
// parked by an `http` WsEvent inside a `run` handler.
pub fn respond_typed(self: Server, status: integer, body: text, content_type: text) {
_ = self;
tcp_respond_typed(status, body, content_type);
}
// The same not-found answer, from inside a `run` handler.
pub fn respond_404(self: Server) {
_ = self;
tcp_respond_typed(404, "Not Found", "text/plain; charset=utf-8");
}
// Send a frame to a specific client. Returns true on success.
pub fn send_to(self: Server, client_id: integer, msg: text) -> boolean {
_ = self;
ws_send_native(client_id, msg)
}
// Send a frame to every active client. Returns the count of
// successful sends. Iteration happens entirely in Rust.
pub fn broadcast(self: Server, msg: text) -> integer {
ws_broadcast_native(self.handle, msg)
}
// Forcefully close one client's connection. The slot is freed
// for reuse by future accepts.
pub fn disconnect(self: Server, client_id: integer) {
_ = self;
ws_close_native(client_id);
}
tests/c84_surface.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @C84 — `server` is transport primitives, and a framework is a SEPARATE library over it.
//
// loft's DESIGN_DECISIONS.md C84 (amended 2026-09-29): `server` answers requests and runs
// WebSockets — `listen` (TLS included), `next`, `respond*`, the WebSocket calls and the event
// pump. A route table, middleware, authentication, sessions and certificate automation are
// each library-sized and live ABOVE `server` (loft's WEB_STACK.md: `webapp`, `auth`, `acme`,
// `sql`), so a program that only answers requests never carries their dependencies.
//
// This reads the package's own public surface and fails on any name outside the transport
// set below. A new primitive is added to that set on purpose — the edit is the review, and
// the question to ask is whether it moves or answers bytes (`reload_tls` would) or decides
// what a request means (a router, a login, a session would not).
//
// @falsified-at: hand-measured (2026-09-29) — a scratch copy of this package with
// `pub struct Session` and `pub fn route` appended fails with "…above it (webapp, auth,
// acme): Session route"; the `seen > 30` control failed first when the path was wrong
// (0 names read), so a surface this cannot read cannot pass. Both backends.
fn primitive(name: text) -> boolean {
set = [
"Server", "WebSocket", "Request", "WsEvent",
"listen", "try_listen", "listen_on", "try_listen_on", "listen_tls", "try_listen_tls",
"try_listen_tls_on", "bound", "next", "next_nonblocking", "close",
"respond", "respond_typed", "respond_bytes", "respond_with_headers", "respond_html",
"respond_css", "respond_js", "respond_loft", "respond_404", "header", "cors_headers",
"serve_range", "serve_data",
"ws_upgrade", "send", "send_binary", "last_opcode",
"run", "poll_event", "send_to", "broadcast", "disconnect",
];
for p in set {
if p == name { return true; }
}
false
}
fn test_server_exports_only_transport_primitives() {
outside = "";
seen = 0;
for l in file("../src/server.loft").lines() {
for kw in ["pub fn ", "pub operator ", "pub struct ", "pub enum ", "pub type "] {
if l.starts_with(kw) {
rest = l[len(kw)..];
stop = len(rest);
for c in ["(", " ", "<", "{{", ";"] {
at = rest.find(c) ?? stop;
if at < stop { stop = at; }
}
name = rest[..stop];
seen += 1;
if !primitive(name) { outside += " {name}"; }
}
}
}
assert(seen > 30, "read the surface: {seen} public names in ../src/server.loft");
assert(
outside == "",
"C84: `server` is transport primitives; these belong in a library above it (webapp, auth, acme):{outside}"
);
}
tests/server.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Server smoke test: verify structs and listen/close work.
// Full integration requires concurrent client+server (parallel {} with threading).
//
// TTT v5: this file also gates that the binary-WS surface compiles
// — `send_binary` on WebSocket and `last_opcode` on WebSocket are
// declared in lib/server/src/server.loft. The compiler refuses
// to build this file if either regresses.
use server::*;
// @SRV-001 — bind honestly, or say so. `listen` HALTS when the port is not free, and
// that is the design, not a rough edge: a `Server` built on a failed bind is
// indistinguishable from a healthy one at every call site, so the process would report
// itself up and serve nobody — invisible from outside too, because a readiness probe
// that connects to the port reaches whoever DID win the bind. When losing the port is
// an outcome you mean to handle (retry, scan a range), take the recoverable door:
// `try_listen` never halts, and `bound()` is the check that makes its result usable.
// Every other method on an unbound server quietly does nothing, so skipping `bound()`
// buys back exactly the silence `listen` exists to prevent.
fn main() {
// Verify Server struct creation
srv = server::listen(18082);
assert(srv.handle >= 0, "listen returned handle={srv.handle}");
assert(srv.bound(), "a successful listen reports bound()");
// A port that is already taken must be REPORTED, not papered over. Before
// `bound()` / `try_listen` existed, `listen` handed back a Server whose handle was
// -1 and every call on it quietly did nothing — `run` spun forever on an empty
// event pump, so the process claimed to be up and served nobody. It was invisible
// from outside too: a readiness probe that connects to the port reaches whoever DID
// win the bind and reports success.
//
// `listen` now halts on that, which cannot be asserted from inside the same process
// (it ends it); the exit-1 and the panic text are covered by the library's own
// manual check. What IS assertable, and what a caller who wants to retry needs, is
// the recoverable half.
taken = server::try_listen(18082);
assert(!taken.bound(), "try_listen on a taken port must report bound()==false");
assert(taken.handle < 0, "an unbound server keeps a negative handle");
// A free port still works through the same door.
spare = server::try_listen(18083);
assert(spare.bound(), "try_listen on a free port must report bound()==true");
spare.close();
// Close immediately — no accept needed
srv.close();
assert(true, "server created and closed");
}
tests/worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `server` library (@PLN141). Each `test_*` below is a REAL,
// run-every-CI demonstration of a function whose correct use is not obvious from its
// signature alone; the function documents itself by CITING the tag with
// `// Example: @SRV-00N`, and `check_doc_drift.sh examples` fails if a citation goes
// dangling (no test carries the tag). The bind-honestly example @SRV-001 lives in
// server.loft, on the smoke test that already demonstrated it.
//
// These build a `Request` by hand rather than accepting one off a socket, because what
// they pin is a DECISION the request layer makes — which header matched, whether the
// helper already answered — not the bytes that reach a client. That is on purpose: the
// decision is the half a caller gets wrong, and it is the half a single-process test can
// hold honestly. The wire itself stays the integration suite's job.
use server::*;
// @SRV-002 — `header` reads the wire, not your spelling of it. HTTP header names are
// case-insensitive and a client picks its own casing, so a caller comparing
// `h.starts_with("Origin:")` against the raw `headers` vector misses `origin:` from the
// very next client. `header` folds case for you. It also rejoins a value that itself
// contains `:` — an `Origin` with a port, a `Date`, an `If-Range` — which is exactly
// what a hand-rolled `h.split(':')[1]` silently truncates. An absent header answers
// "" and never null, so test it with `.len() == 0` and skip the `??`.
fn test_header_folds_case_and_keeps_colons() {
req = server::Request {
method: "GET",
path: "/asset.bin",
body: "",
headers: ["Host: example.test",
"origin: http://site.test:8080",
"If-None-Match: v3-abc"]
};
// Any casing on either side finds it.
host = req.header("HOST");
assert(host == "example.test", "a header is found whatever the casing: {host}");
// The value's own colons survive: the port is part of the origin, not a separator.
origin = req.header("Origin");
assert(origin == "http://site.test:8080",
"a value containing ':' is rejoined intact: {origin}");
etag = req.header("if-none-match");
assert(etag == "v3-abc", "the conditional header reads back whole: {etag}");
// Absent is "" — an empty answer, not a null one.
absent = req.header("Range");
assert(absent.len() == 0, "an absent header answers an empty text, never null");
}
// @SRV-003 — the return value says WHO ANSWERED. `serve_range` is the one helper here
// that may decline: it answers false when the request carried no `Range` header, and
// false means "I sent nothing — the full body is yours to serve". Ignore that and the
// client waits for a response nobody sent. True means the helper already wrote a 206
// or a 416, so responding again writes a second reply into the same connection.
// `serve_data` is the opposite and always answers true: it takes the whole decision —
// CORS preflight, `If-None-Match`, a byte range, a HEAD, or the plain 200 — so a
// handler that calls it is done with that request. Pick `serve_data` unless you need
// the branch, and treat `serve_range`'s false as the only case you must handle.
fn test_serve_range_declines_and_serve_data_never_does() {
body = "0123456789";
extra: vector<text> = [];
// No Range header: the helper declines, and the caller owes the full body.
plain = server::Request { method: "GET", path: "/d", body: "", headers: ["Host: x"] };
handled = plain.serve_range(body, extra);
assert(!handled, "no Range header — serve_range declines, YOU serve the full body");
// A Range header: the helper wrote the 206 itself; do not respond again.
ranged = server::Request { method: "GET", path: "/d", body: "",
headers: ["Range: bytes=2-5"] };
did = ranged.serve_range(body, extra);
assert(did, "a Range header — serve_range already answered with a 206");
// An unsatisfiable or unknown range is still ANSWERED (with a 416), so it is still
// true. The boolean reports who replied, never whether the client got what it asked.
refused = server::Request { method: "GET", path: "/d", body: "",
headers: ["Range: bytes=99-200"] };
did_refuse = refused.serve_range(body, extra);
assert(did_refuse, "an unsatisfiable range is answered with a 416 — still true");
// serve_data takes every branch, so it is always true and always final.
preflight = server::Request { method: "OPTIONS", path: "/d", body: "",
headers: ["Origin: http://site.test"] };
assert(preflight.serve_data(body, "", "*"), "serve_data answers the CORS preflight");
head = server::Request { method: "HEAD", path: "/d", body: "", headers: ["Host: x"] };
assert(head.serve_data(body, "", ""), "serve_data answers a HEAD with the length only");
fresh = server::Request { method: "GET", path: "/d", body: "",
headers: ["If-None-Match: v3"] };
assert(fresh.serve_data(body, "v3", ""), "serve_data answers an unchanged etag with a 304");
full = server::Request { method: "GET", path: "/d", body: "", headers: ["Host: x"] };
assert(full.serve_data(body, "", ""), "and a plain GET with the whole body");
}
tests/ws_event.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// A handler serves an HTTP event by its path (routing's tile server does): every field of
// `WsEvent` is readable outside this library, `path` included.
use server::*;
fn test_an_http_event_says_its_path() {
e = WsEvent { connected: false, message: false, http: true, cid: -1, msg_id: 0, payload: "", path: "/tiles/3" };
assert(e.http && e.path == "/tiles/3", "an http event carries its path, got {e.path}");
}