web source

← web · API reference · version 0.4.5

4 file(s), 646 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
HttpResponse struct8—15 40 41 57 63 69 … (13 total)
ok fn15—none in this package
http_get fn57—none in this package
http_post fn63—none in this package
http_put fn69—none in this package
http_delete fn75—none in this package
http_get_h fn84—none in this package
http_post_h fn90—none in this package
http_put_h fn96—none in this package
http_delete_h fn102—none in this package
http_get_range fn113—153
http_get_range_h fn120—none in this package
http_size fn150—none in this package
WsHandler struct174—231 234 241 249 256 265 … (9 total)
ws_handler fn231WEB-00383 86
send fn241WEB-00399 100
send_binary fn249—none in this package
last_opcode fn256—none in this package
pump fn265—100
try_recv fn279—101 102
close fn285—104
sleep_ms fn293—none in this package
pack_reset fn319WEB-00125 55 11 21 31 40 … (10 total)
pack_u8 fn323—26 56 57 12 13 41 … (13 total)
pack_u16_le fn327—27 22
pack_u32_le fn332—32
pack_take fn338WEB-00128 58 14 23 33 42 … (12 total)
byte_at fn346WEB-00218 19 20 29 31 32 … (19 total)
WsGroupEvent struct373—407 411 413
WsGroup struct379—393 395 399 407
ws_group fn393—none in this package
add fn399—none in this package
poll fn407—none in this package
frame_yield fn419—none in this package

src/web.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// --- HTTP Client + WebSocket Client ---
// Blocking HTTP client for consuming web services.
// Polling WebSocket client for browser-and-native communication.

// The result of an HTTP request.
pub struct HttpResponse {
  pub status: integer,
  pub body: text,
  pub headers: vector<text>
}

// Returns true when the status code is in the 200-299 range.
pub fn ok(self: HttpResponse) -> boolean {
  self.status >= 200 && self.status < 300
}

// --- Internal native functions returning primitives ---

fn http_do(method: text, url: text, body: text, headers: text) -> integer;
#native

fn http_body() -> text;
#native

// Response headers from the last request, as newline-separated "Key: Value".
fn http_headers() -> text;
#native

// Internal: resolve a URL's byte size via a HEAD request (Content-Length).
// Returns -1 if the request failed or the server omitted the length on HEAD;
// the public `http_size` falls back to a range read in that case.
fn http_head_size(url: text) -> integer;
#native "n_http_size"

// Build the full response from the last native call: status, binary-safe body
// (raw bytes, NUL-preserving via the pack/byte_at convention), and response
// headers split into a vector.
fn make_response(status: integer) -> HttpResponse {
  HttpResponse { status: status, body: http_body(), headers: http_headers().split('\n') }
}

// Internal: serialize headers vector to newline-separated "Key: Value" text.
fn join_headers(headers: vector<text>) -> text {
  result = "";
  for h in headers {
    if result.len() > 0 { result += "\n"; }
    result += h;
  }
  result
}

// --- Simple HTTP functions (no custom headers) ---

// Send a GET request.
pub fn http_get(url: text) -> HttpResponse {
  status = http_do("GET", url, "", "");
  make_response(status)
}

// Send a POST request with a body.
pub fn http_post(url: text, body: text) -> HttpResponse {
  status = http_do("POST", url, body, "");
  make_response(status)
}

// Send a PUT request with a body.
pub fn http_put(url: text, body: text) -> HttpResponse {
  status = http_do("PUT", url, body, "");
  make_response(status)
}

// Send a DELETE request.
pub fn http_delete(url: text) -> HttpResponse {
  status = http_do("DELETE", url, "", "");
  make_response(status)
}

// --- HTTP functions with custom headers ---
// Headers are passed as a vector of "Key: Value" strings.

// GET with custom headers.
pub fn http_get_h(url: text, headers: vector<text>) -> HttpResponse {
  status = http_do("GET", url, "", join_headers(headers));
  make_response(status)
}

// POST with custom headers.
pub fn http_post_h(url: text, body: text, headers: vector<text>) -> HttpResponse {
  status = http_do("POST", url, body, join_headers(headers));
  make_response(status)
}

// PUT with custom headers.
pub fn http_put_h(url: text, body: text, headers: vector<text>) -> HttpResponse {
  status = http_do("PUT", url, body, join_headers(headers));
  make_response(status)
}

// DELETE with custom headers.
pub fn http_delete_h(url: text, headers: vector<text>) -> HttpResponse {
  status = http_do("DELETE", url, "", join_headers(headers));
  make_response(status)
}

// --- Byte-range reads (partial content) ---

// Read the byte range [offset, offset+len) of a remote file via a Range
// request. Returns status 206 (Partial Content) with the raw bytes in `body`
// when the server honours ranges; a server that ignores Range returns 200 with
// the full body. Read the bytes with `byte_at(i, response.body)`.
pub fn http_get_range(url: text, offset: integer, len: integer) -> HttpResponse {
  last = offset + len - 1;
  status = http_do("GET", url, "", "Range: bytes={offset}-{last}");
  make_response(status)
}

// Byte-range read with extra custom headers (e.g. If-Range / If-None-Match).
pub fn http_get_range_h(url: text, offset: integer, len: integer, headers: vector<text>) -> HttpResponse {
  last = offset + len - 1;
  range = "Range: bytes={offset}-{last}";
  all = join_headers(headers);
  combined = if all.len() > 0 { "{all}\n{range}" } else { range };
  status = http_do("GET", url, "", combined);
  make_response(status)
}

// --- Size query ---

// Extract the total size from a "Content-Range: bytes X-Y/TOTAL" header line,
// or -1 if no such header is present.
fn content_range_total(headers: vector<text>) -> integer {
  for h in headers {
    if h.to_lowercase().starts_with("content-range:") {
      parts = h.split('/');
      if parts.len() >= 2 {
        return (parts[parts.len() - 1] ?? "").trim() as integer ?? -1;
      }
    }
  }
  -1
}

// Resolve a URL's total byte size without downloading it. Tries a HEAD request
// (Content-Length); if the server omits the length on HEAD (some CDNs — e.g.
// github-raw — only report it on GET), falls back to a 1-byte range read and
// takes the total from the Content-Range header. Returns -1 if size is
// unavailable.
pub fn http_size(url: text) -> integer {
  n = http_head_size(url);
  if n >= 0 { return n; }
  r = http_get_range(url, 0, 1);
  if r.status == 206 || r.status == 200 {
    return content_range_total(r.headers);
  }
  -1
}

// --- WebSocket client ---
//
// Handler-style API: the programmer opens a session with `ws_handler`,
// then drives event delivery by calling `pump(handler, on_message)`
// from a main loop.  The library manages the connection lifecycle:
// connect at registration time, auto-reconnect on transient failure
// (exponential backoff capped at 10 seconds), close on `close()`.
//
// `pump` takes its callback per call, so a handler holds no closure: the
// callback typically captures a `Reference<T>` to state held in a store, and
// updates that state through it for each message.

// An open WebSocket session, from `ws_handler`.  It is a slot number on the
// native side; the connection behind it reconnects on its own.
pub struct WsHandler {
  pub id: integer            // Rust-side connection slot
}

// --- Internal natives (lifecycle primitives) ---

fn ws_client_connect_native(url: text) -> integer;
#native "n_ws_connect"

fn ws_client_send_native(handle: integer, msg: text) -> boolean;
#native "n_ws_client_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 binary world data.
fn ws_client_send_binary_native(handle: integer, msg: text) -> boolean;
#native "n_ws_client_send_binary"

// Returns true when a message has been delivered to the receive
// buffer; false when the queue is empty or the connection is currently
// down.  After true, call ws_client_message_native() to read the payload.
fn ws_client_recv_native(handle: integer) -> boolean;
#native "n_ws_client_recv"

fn ws_client_message_native() -> text;
#native "n_ws_client_message"

// Opcode of the last frame surfaced by `ws_client_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_client_opcode_native() -> integer;
#native "n_ws_client_opcode"

fn ws_client_close_native(handle: integer);
#native "n_ws_client_close"

// --- Public API ---

// Open a WebSocket session.  Returns null only if the URL is
// malformed; transient connection failures (server not yet up,
// network blip) are absorbed by the auto-reconnect loop in the Rust
// layer, so this call always returns a usable handle for any
// well-formed `ws://...` or `wss://...` URL.
//
// `wss://` (TLS) works on BOTH targets: the browser uses the platform
// WebSocket, and the native client dials TLS through rustls with
// certificate validation ON by default (default port 443).  For a self-signed dev endpoint,
// set `LOFT_WS_INSECURE_SKIP_VERIFY=1` in the native process — an
// explicit opt-out, never a URL option, so it cannot be baked into a
// deployed config by accident.
// Example: @WEB-003 — a handle is not a connection; ask the traffic, not the handle.
//
// ⚠ The return is `WsHandler?` because null is the one distinction this call
// exists to make: test it with `== null`.
pub fn ws_handler(url: text) -> WsHandler? {
  id = ws_client_connect_native(url);
  if id < 0 { return null; }
  WsHandler { id: id }
}

// Send a text message to the server.  Returns true on success; false
// means the connection is currently in backoff / reconnecting and the
// caller may retry after the next `pump()`.
// Example: @WEB-003 — this false, not a null handle, is what reports the link down.
pub fn send(self: WsHandler, msg: text) -> boolean {
  ws_client_send_native(self.id, msg)
}

// Send `msg` as a binary WebSocket frame (opcode 2).  Loft `text`
// is a byte buffer — build it with `pack_reset` / `pack_u8` / … /
// `pack_take`, then ship it.  Returns true on success; false means
// the connection is currently in backoff / reconnecting.
pub fn send_binary(self: WsHandler, msg: text) -> boolean {
  ws_client_send_binary_native(self.id, msg)
}

// Opcode of the last frame surfaced by `try_recv` / `pump` on this
// handler.  Use after a successful read to decide whether to
// interpret the payload as utf-8 text or as a binary blob.
pub fn last_opcode(self: WsHandler) -> integer {
  _ = self;
  ws_client_opcode_native()
}

// Drain any pending received messages, firing `on_message` for each.
// Returns the count of messages delivered this call.  Call this from
// a main loop (and yield to the host between iterations on the browser
// target) to drive event delivery without async.
pub fn pump(self: WsHandler, on_message: fn(text)) -> integer {
  count = 0;
  while ws_client_recv_native(self.id) {
    on_message(ws_client_message_native());
    count += 1;
  }
  count
}

// Non-blocking single-message receive.  Returns the next pending
// message text if one is available, null otherwise.  Equivalent to a
// single iteration of pump without the callback — useful when the
// receiver wants to drive its own loop and accumulate messages
// locally without capturing state inside a closure.
pub fn try_recv(self: WsHandler) -> text? {
  if !ws_client_recv_native(self.id) { return null; }
  ws_client_message_native()
}

// Permanently close the session (no further reconnect attempts).
pub fn close(self: WsHandler) {
  ws_client_close_native(self.id);
}

// Block the calling thread for `ms` milliseconds.  Convenience for
// tests and pacing loops; non-positive values are a no-op.  In the
// browser it returns at once — the host event loop paces there, so
// call `frame_yield` instead.
pub fn sleep_ms(ms: integer);
#native

// --- Binary buffer construction ---
//
// Loft `text` is a UTF-8 byte buffer; a NUL byte (codepoint 0) is the
// `character` null sentinel, so a `text` built via `"{c}{c}…"`
// interpolation silently drops zero bytes.  Binary protocols that
// include zero bytes (e.g. the v5 5-byte blob header `[type:u8]
// [session:u32-LE]` for any small session id) cannot be assembled
// that way.
//
// `pack_*` are a thread-local builder pattern: `pack_reset()` empties
// the buffer, `pack_u8` / `pack_u16_le` / `pack_u32_le` append fields
// by type, and `pack_take()` returns a `text` whose bytes carry the
// binary payload verbatim — NUL bytes preserved.  The buffer survives
// across calls within a single thread; reset before each blob.  The
// returned `text` lives until the next `pack_take()`, so `send_binary`
// it (or copy it) before reusing the builder.
//
// On the receive side, `byte_at(i, t)` returns the single byte at
// offset `i` of `t` (regardless of UTF-8 interpretation).  Use it to
// decode binary frames returned by `try_recv` / `pump` field-by-field.

// Empty the per-thread pack buffer.  Call before each blob.
// Example: @WEB-001 — reset, append in wire order, take (which moves, not reads).
pub fn pack_reset();
#native

// Append a single byte to the pack buffer: the low 8 bits of `b`.
pub fn pack_u8(b: integer);
#native

// Append the low 16 bits of `v`, little-endian.
pub fn pack_u16_le(v: integer);
#native

// Append the low 32 bits of `v`, little-endian — so -1 packs as
// FF FF FF FF, the same bytes as 0xFFFFFFFF.
pub fn pack_u32_le(v: integer);
#native

// Take the contents of the pack buffer and return them as a `text`
// whose UTF-8 bytes are exactly the buffer bytes.  Resets the buffer.
// Example: @WEB-001 — the three-step protocol, and the NUL that interpolation drops.
pub fn pack_take() -> text;
#native

// Read the byte at `idx` of the text payload.  Out-of-range (negative
// or past the end) returns -1.  Use to decode binary frames received
// via `try_recv` / `pump`.  Argument order is (idx, t), unlike the
// (text, index) order elsewhere.
// Example: @WEB-002 — why the bounds test is `< 0` and never `== 0`.
pub fn byte_at(idx: integer, t: text) -> integer;
#native

// --- Multiplexed client receiver (WsGroup) ---
//
// Watch multiple WsHandler connections and receive from whichever has
// data ready — one scan across all handles instead of polling each
// individually.  The key difference: handles with no data are skipped
// after one short timeout, so N idle sockets cost one timeout total
// instead of N timeouts.
//
//   group = web::ws_group();
//   group.add(h1);
//   group.add(h2);
//   for _ in 0..1000 {
//     ev = group.poll();
//     if ev.handle < 0 { break; }
//     // ev.message has the data
//   }
//
// The group's list lives on the native side and there is one per thread:
// `ws_group()` empties it, so a second group replaces the first.  In the
// browser `poll` always answers -1: receive with each handler's `try_recv`
// there.

// What one `poll` found: the handler slot that had a message and the
// message, or `handle == -1` and "" when none had.
pub struct WsGroupEvent {
  pub handle: integer,
  pub message: text
}

// A set of handlers to receive from in one scan; see above.
pub struct WsGroup {
  _dummy: integer   // no state on loft side; Rust owns the list
}

fn ws_group_clear_native();
#native "n_ws_group_clear"

fn ws_group_add_native(handle: integer);
#native "n_ws_group_add"

fn ws_group_poll_native() -> integer;
#native "n_ws_group_poll"

// Create a new (empty) group.
pub fn ws_group() -> WsGroup {
  ws_group_clear_native();
  WsGroup { _dummy: 0 }
}

// Add a handler to the group.
pub fn add(self: WsGroup, h: WsHandler) {
  _ = self;
  ws_group_add_native(h.id);
}

// Poll all handles round-robin.  Returns a WsGroupEvent with
// handle >= 0 and the message if one is ready; handle == -1
// means nothing is available right now.
pub fn poll(self: WsGroup) -> WsGroupEvent {
  _ = self;
  h = ws_group_poll_native();
  if h < 0 {
    return WsGroupEvent { handle: -1, message: "" };
  }
  WsGroupEvent { handle: h, message: ws_client_message_native() }
}

// Yield one frame to the host event loop: a no-op natively, and in the
// browser the suspend point that lets the event loop run.  Call once per WS poll
// iteration so the browser event loop can deliver WebSocket messages.
pub fn frame_yield();
#native "n_yield_frame"

tests/byte_at.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Unit tests for `byte_at(idx, text) -> integer` — the raw-byte
// accessor on text values.  Used by binary-protocol parsers
// (game_protocol, server) to read individual bytes out of a
// `pack_take()` buffer or a WebSocket binary frame.
//
// `byte_at` returns the byte value (0..255) at `idx` in the
// underlying UTF-8 bytes of `t`.  Out-of-range returns -1 (no panic)
// — NOT 0, which is a byte a binary frame legitimately carries; see
// `test_byte_at_out_of_range` below and @WEB-002.

use web::*;

fn test_byte_at_ascii() {
  s = "ABC";
  assert(web::byte_at(0, s) == 0x41, "byte 0 = 0x41 'A'");
  assert(web::byte_at(1, s) == 0x42, "byte 1 = 0x42 'B'");
  assert(web::byte_at(2, s) == 0x43, "byte 2 = 0x43 'C'");
}

fn test_byte_at_after_pack() {
  // Real-world shape: pack_u8 + pack_u16_le, then byte_at to verify.
  web::pack_reset();
  web::pack_u8(0xAB);
  web::pack_u16_le(0x1234);
  buf = web::pack_take();
  assert(web::byte_at(0, buf) == 0xAB, "packed byte 0 = 0xAB");
  // little-endian: low byte first
  assert(web::byte_at(1, buf) == 0x34, "packed byte 1 = 0x34 (LE low)");
  assert(web::byte_at(2, buf) == 0x12, "packed byte 2 = 0x12 (LE high)");
}

fn test_byte_at_empty() {
  // Out-of-range on empty text: returns the OOB sentinel (currently
  // observed as u32::MAX / 4294967295).  Valid bytes are 0..=255 so
  // the test asserts "not a valid byte value" robustly.
  v0 = web::byte_at(0, "");
  v5 = web::byte_at(5, "");
  assert(v0 < 0 || v0 > 255, "byte 0 of empty must be OOB sentinel, got {v0 ?? -1}");
  assert(v5 < 0 || v5 > 255, "byte 5 of empty must be OOB sentinel, got {v5 ?? -1}");
}

fn test_byte_at_out_of_range() {
  s = "Hi";
  v2 = web::byte_at(2, s);
  v100 = web::byte_at(100, s);
  assert(v2 < 0 || v2 > 255, "byte 2 past len must be OOB sentinel, got {v2 ?? -1}");
  assert(v100 < 0 || v100 > 255, "byte 100 past len must be OOB sentinel, got {v100 ?? -1}");
}

fn test_byte_at_extremes() {
  // Bytes at the 0x00 and 0xFF boundary.
  web::pack_reset();
  web::pack_u8(0);
  web::pack_u8(255);
  buf = web::pack_take();
  assert(web::byte_at(0, buf) == 0,   "byte value 0");
  assert(web::byte_at(1, buf) == 255, "byte value 255");
}

tests/pack.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Local cdylib smoke — exercises the binary-packing helpers
// without touching the network.  The network-dependent HTTP
// tests live in tests-network/http.loft and are not run in CI.

use web::*;

fn test_pack_u8_roundtrip() {
  web::pack_reset();
  web::pack_u8(0x41);
  web::pack_u8(0x42);
  s = web::pack_take();
  assert(s.size() == 2, "2 bytes, got {s.size()}");
  assert(s[0] == 'A', "byte 0 = A");
  assert(s[1] == 'B', "byte 1 = B");
}

fn test_pack_u16_le() {
  web::pack_reset();
  web::pack_u16_le(0x4142);
  s = web::pack_take();
  assert(s.size() == 2, "2 bytes, got {s.size()}");
  // little-endian: low byte first
  assert(s[0] == 'B', "byte 0 = B (0x42)");
  assert(s[1] == 'A', "byte 1 = A (0x41)");
}

fn test_pack_u32_le() {
  web::pack_reset();
  web::pack_u32_le(0x41424344);
  s = web::pack_take();
  assert(s.size() == 4, "4 bytes, got {s.size()}");
  assert(s[0] == 'D', "byte 0 = D");
  assert(s[3] == 'A', "byte 3 = A");
}

fn test_pack_reset_clears() {
  web::pack_reset();
  web::pack_u8(0x41);
  s1 = web::pack_take();
  web::pack_reset();
  s2 = web::pack_take();
  assert(s1.size() == 1, "first take 1 byte");
  assert(s2.len() == 0, "after reset, len 0, got {s2.len()}");
}

// @PLN110 regression: pack_take() returns a BINARY buffer, so its length is a BYTE
// count — size(), not len() (the character count post-flip).  Pack the two bytes of a
// UTF-8 "é" (0xC3 0xA9): they form ONE character but TWO bytes.  size()==2 is correct;
// len() would read 1 and mis-measure the buffer.
fn test_pack_byte_count_is_bytes_not_chars() {
  web::pack_reset();
  web::pack_u8(0xC3);
  web::pack_u8(0xA9);
  s = web::pack_take();
  assert(s.size() == 2, "packed buffer is 2 BYTES, got {s.size()}");
  assert(s.len() == 1, "...which is 1 UTF-8 character (proves size != len here)");
}

tests/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `web` 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: @WEB-00N`, and `check_doc_drift.sh examples` fails if a citation goes
// dangling (no test carries the tag).  These are teaching examples, distinct from the
// unit suites in pack.loft / byte_at.loft.
//
// All three run without a network, like the rest of this package's CI tests: the
// HTTP examples live in tests-network/ and are not run here.  @WEB-003 is the
// interesting case — the contract it pins is precisely that a DOWN server is not an
// error, so having no server is what makes it demonstrable.
use web::*;

// @WEB-001 — the pack builder exists because interpolation cannot carry a zero byte.
// Loft `text` is a UTF-8 byte buffer and codepoint 0 is the `character` null sentinel,
// so a blob assembled as `"{a}{b}{c}"` SILENTLY drops every NUL — and a binary header
// with a small integer field (a type byte, a session id) is mostly NULs.  The builder
// is a three-step protocol on a per-thread buffer: `pack_reset` empties it, the
// `pack_u8` / `pack_u16_le` / `pack_u32_le` calls append in wire order, and `pack_take`
// hands the bytes back AND resets — so `take` is a move, not a read, and calling it
// twice answers an empty buffer the second time.  The returned `text` is only valid
// until the next `pack_take`, so send or copy it before building the next blob.  Measure
// it with `size()` (bytes); `len()` counts characters and will disagree.
fn test_pack_protocol_carries_the_zero_byte() {
  // A three-byte frame whose middle field is zero — the shape interpolation loses.
  web::pack_reset();
  web::pack_u8(0x01);
  web::pack_u8(0x00);
  web::pack_u8(0x02);
  blob = web::pack_take();
  assert(blob.size() == 3, "all three bytes survive the builder: {blob.size()}");
  assert(web::byte_at(1, blob) == 0, "including the NUL in the middle");
  // The same three bytes by interpolation: the zero is gone, and nothing said so.
  one = 1 as character;
  zero = 0 as character;
  two = 2 as character;
  interpolated = "{one}{zero}{two}";
  assert(interpolated.size() == 2,
         "interpolation silently drops the NUL — 2 bytes, not 3: {interpolated.size()}");
  // `take` is a move: the buffer is empty afterwards, so build the next blob from
  // `pack_reset` and never expect a second take to repeat the first.
  again = web::pack_take();
  assert(again.size() == 0, "pack_take resets — a second take answers nothing");
}

// @WEB-002 — an out-of-range `byte_at` answers -1, and that matters BECAUSE 0 is a
// value the buffer legitimately carries.  Testing `byte_at(i, buf) == 0` as a bounds
// check reads a real NUL field as the end of the frame, and reads an overrun as a NUL
// field — both wrong, in opposite directions, in exactly the binary protocols this
// helper exists for.  Test `< 0` instead.  Note the argument order too: `(idx, text)`,
// not the `(text, idx)` every other accessor takes — it is fixed by what the C-ABI
// auto-marshal supports, so it will not change to match.
fn test_byte_at_reports_out_of_range_as_minus_one() {
  web::pack_reset();
  web::pack_u8(0x41);
  web::pack_u8(0x00);
  buf = web::pack_take();
  // In range: an honest zero at index 1.
  assert(web::byte_at(0, buf) == 0x41, "byte 0 is 'A'");
  assert(web::byte_at(1, buf) == 0, "byte 1 is a real NUL field, value 0");
  // Out of range on either side: -1, distinguishable from that NUL.
  assert(web::byte_at(2, buf) == -1, "one past the end answers -1, not 0");
  assert(web::byte_at(-1, buf) == -1, "a negative index answers -1 too");
  // The bounds test that works, and the one that does not.
  assert(web::byte_at(2, buf) < 0, "`< 0` separates an overrun from a NUL field");
  assert(web::byte_at(1, buf) == web::byte_at(2, buf) + 1, "…which `== 0` cannot do");
}

// @WEB-003 — a handle is not a connection.  `ws_handler` answers null for ONE reason:
// a malformed URL.  A well-formed URL whose server is down, not started yet, or
// briefly unreachable still answers a usable handle, because reconnecting (with
// exponential backoff) is the library's job, not the caller's.  So a non-null handle
// says the URL parsed — never that anyone is listening, and a caller that treats it as
// "connected" reports itself up against a server that does not exist.  What DOES report
// the link state is the traffic: `send` answers false while the connection is in
// backoff (retry after the next `pump`), and `try_recv` answers null for "nothing
// pending", which an idle healthy link answers just as often as a down one.
fn test_ws_handler_is_not_a_connection() {
  // The one null case: a URL that cannot be parsed at all.
  malformed = web::ws_handler("not-a-url");
  assert(malformed == null, "a malformed URL is the only thing that answers null");
  // A well-formed URL with nobody listening: still a handle, by design.
  down = web::ws_handler("ws://127.0.0.1:47823/");
  // ⚠ The GUARD CLAUSE is the example, not ceremony around it.  `ws_handler` answers
  // `WsHandler?` (0.4.0), so the null it is documented to return is now in the type —
  // and a bare `assert(down != null, …)` does NOT make the handle usable: an assert is
  // not a narrowing guard, so every method call after it stores a nullable into a
  // non-null parameter.  `if down == null { … return; }` IS one: the fall-through
  // proves `down` non-null for the rest of this block, which is what lets the calls
  // below read as they do.
  if down == null {
    assert(false, "a well-formed URL always answers a handle, server or no server");
    return;
  }
  // Ask the traffic, not the handle.
  sent = down.send("hello");
  assert(!sent, "send answers false while reconnecting — retry after the next pump");
  pending = down.try_recv();
  assert(pending == null, "try_recv answers null for idle, which says nothing about health");
  // `close` is what actually ends it: it stops the reconnect loop for good.
  down.close();
}