writeonce/docs/examples/writeonce-serve/http/ws.wo
shoney.arickathil 47a920f19a feat(serve+view): file serving, downloads, supported systems; rename
- rename the two libraries: writeonce-framework -> writeonce-serve
  (`use serve`), wo-html -> writeonce-view (`use view`). Names say the
  ROLE now; every sample, script, gate and live doc follows
- stories/specs/plans keep the old names: they are dated records, and
  both library READMEs carry a "renamed 2026-08-25" note
- serve/http/files.wo: StaticFiles { dir, max_bytes } — traversal
  refused not normalised, extension content types, attachment
  disposition for archives. Lifted out of the shop, which had said in
  a comment that it belonged in the framework
- shop drops its private copy and mounts the framework's
- site: /dl/*path over $WO_DIST (default ./dist), 16 MiB ceiling
- /install gains supported systems — Linux x86-64, glibc >= 2.38,
  not musl — read off `file` and the binaries' GLIBC_ symbol
  versions, not off a wish list; plus GitHub release as primary,
  /dl as mirror, and the sha256 verify step
- site-accept: 17 -> 21 checks (supported systems, gzip download with
  a binary-safe probe, checksum, /dl traversal 404)

Verified on 192.168.0.165: the real 960,820-byte tarball downloads
as application/gzip and its sha256 matches the published digest.

Gates: oop-accept MET, site 21/0, web-app 46/0, fibers 10/0,
db-actor 8/0; shop rebuilt and its /assets served by the framework.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 04:41:00 +02:00

81 lines
3.4 KiB
Text

-- http/ws.wo — the WebSocket upgrade (iteration 24, RFC 6455 §4.2). The
-- framework owns exactly the HANDSHAKE: validating the upgrade request,
-- computing Sec-WebSocket-Accept (base64 of SHA-1 of key + GUID — SHA-1
-- by RFC, not by choice), and writing the 101 on the request's own
-- connection. What happens on the socket AFTERWARDS belongs to the app:
-- `spawn` takes a class literal, so the framework cannot spawn an
-- app-defined connection actor — the app's route handler calls
-- ws_accept, moves the returned fd into ITS actors, and answers the
-- `hijacked()` sentinel so the serve loop leaves the connection alone.
--
-- Handler shape:
-- if ws_upgrade_valid(req) == false { return bad_request("not a websocket upgrade"); }
-- let fd = ws_accept(req);
-- ... spawn reader/writer actors owning fd ...
-- return hijacked();
use net
-- The RFC's fixed GUID, appended to the client's key before hashing.
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
-- A comma-separated header value contains a token, case-insensitively —
-- `Connection: keep-alive, Upgrade` is the shape browsers actually send.
fn header_has_token(value: Text, token: Text) -> Bool {
let parts = split(to_lower(value), ",");
let i = 0;
while i < len(parts) {
if trim(parts[i]) == token { return true; }
i = i + 1;
}
return false;
}
-- RFC 6455 §4.2.1: GET, `Upgrade: websocket`, `Connection` containing
-- `upgrade`, a Sec-WebSocket-Key (16 bytes base64 = exactly 24 chars),
-- and version 13. Anything else is not an upgrade — the handler answers
-- a plain HTTP response instead.
pub fn ws_upgrade_valid(req: Req) -> Bool {
if req.method != "GET" { return false; }
let up = req.headers["upgrade"];
if up == nil { return false; }
if to_lower(trim(up)) != "websocket" { return false; }
let conn = req.headers["connection"];
if conn == nil { return false; }
if header_has_token("${conn}", "upgrade") == false { return false; }
let key = req.headers["sec-websocket-key"];
if key == nil { return false; }
if len(trim(key)) != 24 { return false; }
let ver = req.headers["sec-websocket-version"];
if ver == nil { return false; }
if trim(ver) != "13" { return false; }
return true;
}
-- The accept key, pure: base64(SHA-1(key + GUID)). Split out so a probe
-- can pin the RFC's worked example ("dGhlIHNhbXBsZSBub25jZQ==" ->
-- "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") without a socket.
pub fn ws_accept_key(key: Text) -> Text {
return base64_encode(sha1(bytes_of_text("${key}${WS_GUID}")));
}
-- Write the 101 and hand the connection to the caller. The caller MUST
-- have checked ws_upgrade_valid first — this function trusts the headers
-- it reads. After this returns, the serve loop must never touch the fd
-- again: the handler answers hijacked() to make that true.
pub fn ws_accept(req: Req) -> net.Conn {
let key = req.headers["sec-websocket-key"];
let accept = ws_accept_key(trim("${key}"));
let resp = "HTTP/1.1 101 Switching Protocols\r\n";
resp = resp .. "Upgrade: websocket\r\n";
resp = resp .. "Connection: Upgrade\r\n";
resp = resp .. "Sec-WebSocket-Accept: ${accept}\r\n\r\n";
net.write(req.conn, resp);
return req.conn;
}
-- The hijack sentinel: status 101 tells serve.wo the connection left the
-- HTTP world — no serialization, no close, straight back to accept.
pub fn hijacked() -> Resp {
let h: map<Text, Text> = {};
return Resp { status: 101, headers: h, body: "" };
}