server source

← 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.

ItemDefinedWorked exampleCall sites
Server struct49—174 186 187 195 204 205 … (24 total)
WebSocket struct55—475 478 485 492 499 506 … (8 total)
Request struct61—244 246 260 262 273 281 … (25 total)
listen fn174SRV-001177 25 23 24 25
try_listen fn186SRV-001175 177 25 38 39 43 … (7 total)
listen_on fn195—198 25
try_listen_on fn204—196 198 25
listen_tls fn215—218 25
try_listen_tls fn224—216 218 25
try_listen_tls_on fn230—26
bound fn236—176 197 217 26 25 39 … (7 total)
next_nonblocking fn244—26
respond fn273—27
respond_typed fn281—656 27
respond_bytes fn299—27
header fn310SRV-002355 404 28 35 36 38 … (12 total)
respond_with_headers fn332—361 366 374 381 396 406 … (9 total)
cors_headers fn340—392 28
serve_range fn354SRV-003410 29 62 63 67 68 … (7 total)
serve_data fn389SRV-00329 78 80 83 85
respond_html fn423—650 27
respond_css fn433—28
respond_js fn440—28
respond_loft fn448—28
respond_404 fn457—661 28
close fn464—513 26 45 48
ws_upgrade fn475—30
send fn492—30
send_binary fn499—30
last_opcode fn506—30
close fn513—464 26 45 48
WsEvent struct582—595 617 622 626 636 24 … (7 total)
run fn595—31
poll_event fn617—599 31
respond_html fn650—423 27
respond_typed fn656—281 27
respond_404 fn661—457 28
send_to fn667—31
broadcast fn674—31
disconnect fn680—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}");
}