feat: framework WS upgrade — ws_accept + hijack sentinel (http/ws.wo)
- Req grows internal conn field (net.Conn, filled by parse) — handlers touch it only through ws_accept - ws_upgrade_valid: RFC 6455 §4.2.1 (GET, Upgrade token, Connection token list, 24-char key, version 13); ws_accept_key pure (base64(sha1(key+GUID)) — the runtime vector already pins the RFC worked example); ws_accept writes the 101 and returns the fd; hijacked() = the status-101 sentinel - serve.wo: 101 skips serialize AND close — the loop forgets the fd and returns to accept; plain HTTP byte-identical (web-app 26/26) - codec + end-to-end proof land with the chat sample's gate; battery 12/12 (fibers TSan leg flaked empty under load, 10/10 on rerun; web-app restart leg has a pre-existing 0.5s boot race, noted) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9812f3d396
commit
520af5d253
4 changed files with 102 additions and 3 deletions
|
|
@ -9,8 +9,14 @@ pub typedef Req = {
|
|||
query: map<Text, Text>, -- decoded query-string pairs
|
||||
headers: map<Text, Text>, -- names lowercased on read
|
||||
body: Text, -- exactly Content-Length bytes ("" if none)
|
||||
principal: Text -- who this is: "" until an auth middleware
|
||||
principal: Text, -- who this is: "" until an auth middleware
|
||||
-- (http/auth.wo) authenticates the request
|
||||
conn: net.Conn -- the connection the request arrived on.
|
||||
-- INTERNAL plumbing for http/ws.wo's
|
||||
-- upgrade (iteration 24): handlers never
|
||||
-- read or write it except through
|
||||
-- ws_accept; everything else treats Req
|
||||
-- as if this field did not exist
|
||||
}
|
||||
|
||||
pub typedef Resp = {
|
||||
|
|
|
|||
81
docs/examples/writeonce-framework/http/ws.wo
Normal file
81
docs/examples/writeonce-framework/http/ws.wo
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
-- 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: "" };
|
||||
}
|
||||
|
|
@ -150,6 +150,6 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed {
|
|||
}
|
||||
|
||||
let req = Req { method: method, path: path, params: {}, query: query,
|
||||
headers: headers, body: body, principal: "" };
|
||||
headers: headers, body: body, principal: "", conn: c };
|
||||
return Parsed { closed: false, ok: true, req: req, rest: rest };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
|
|||
let c = net.accept(srv);
|
||||
let carry = "";
|
||||
let alive = true;
|
||||
let hijacked = false;
|
||||
while alive {
|
||||
if env.stopping() { alive = false; continue; }
|
||||
let p = try parse_request(c, carry) catch (e) nil; -- an IO trap = gone
|
||||
|
|
@ -85,9 +86,20 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
|
|||
if to_lower(conn) == "close" { keep = false; }
|
||||
}
|
||||
let resp = try d.dispatch(r) catch (e) server_error();
|
||||
-- iteration 24: status 101 is the hijack sentinel (http/ws.wo).
|
||||
-- The handler completed a WebSocket upgrade and now OWNS the fd
|
||||
-- through its own actors: no serialization, no close — the loop
|
||||
-- forgets this connection and returns to accept.
|
||||
if resp.status == 101 {
|
||||
hijacked = true;
|
||||
alive = false;
|
||||
continue;
|
||||
}
|
||||
try net.write(c, serialize(resp, keep, is_head)) catch (e) { alive = false; }
|
||||
if keep == false { alive = false; }
|
||||
}
|
||||
net.close(c);
|
||||
if hijacked == false {
|
||||
net.close(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue