Standard library Β· The web
web/ws
Imported as import "web/ws" as ws, its names are then ws.β¦. Every signature below is the one the checker infers.
WebSocket (RFC 6455) on top of the socket builtins: the handshake, and text or binary frames in both directions. Scope is deliberate β frames up to 64 KiB, no fragmentation, no extensions β but it is the real protocol, and browsers and websocat talk to it.
Reading does not care which kind a frame is: ws_take hands back the payload bytes whatever the opcode, and a Rill string is a byte string. Writing says which β ws_write sends text, ws_write_bin binary β because the receiver on the other side is usually a browser, and a browser hands the two to its page as different things.
# not run # the server side, one connection: the upgrade, then frames raw = ws.fill(conn, "", 1) if ws.ws_accept(conn, raw) then (msg, rest) = ws.ws_take(conn, "") ws.ws_write(conn, "echo: " + msg) ws.ws_close(conn) else sock_close(conn) # the client side sock_write(client, ws.ws_handshake_request("127.0.0.1", "/live")) sock_write(client, ws.client_frame("hello")) (back, _) = ws.ws_take(client, "") # "echo: hello"
Functions
fn header_value(raw: Str, name: Str) -> Str
Header names are case-insensitive (RFC 7230), and that is not a courtesy: HTTP/2 sends every header lowercased, and anything that has been through a proxy speaking it β Cloudflare, nginx with an h2 front β arrives as sec-websocket-key:. Finding the name case-blind cost a production valley an evening of 502s; the value is read from the original bytes at the same offset, because a base64 key is exactly the kind of value case matters to.
ws.header_value("GET / HTTP/1.1\r\nSec-WebSocket-Key: abc==\r\n\r\n", "sec-websocket-key: ") # => abc==
fn accept_key(key: Str) -> Str
ws.accept_key("dGhlIHNhbXBsZSBub25jZQ==") # => s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
fn ws_wanted(request_raw: Str) -> Bool
Is this somebody opening a socket, or somebody asking a question?
The key is what says so, and the name has to be looked for without regard to case: HTTP/2 and every proxy in front of it β and Node's own client β carry header names in lower case, while curl and the browsers send them capitalised. A server that looks for one spelling turns half the world away, and does it silently, which is how this was found.
ws.ws_wanted("GET /live HTTP/1.1\r\nUpgrade: websocket\r\nSec-WebSocket-Key: x\r\n\r\n") # => true ws.ws_wanted("GET / HTTP/1.1\r\nHost: h\r\n\r\n") # => false
fn ws_accept(conn: Int, request_raw: Str) -> Bool
Answers a client's upgrade request. Returns whether it was one.
# not run raw = ws.fill(conn, "", 1) if ws.ws_accept(conn, raw) then live(conn) else sock_close(conn)
fn fill(conn: Int, buf: Str, want: Int) -> Str
Reads until at least want bytes are buffered, or the peer stops talking.
# not run raw = ws.fill(conn, "", 1) # at least one byte, or "" when the peer is gone
fn ws_take(conn: Int, acc: Str) -> (Str, Str)
One frame, and whatever arrived after it.
A read is not a frame. TCP hands over bytes, so one read can carry half a frame, or three of them, and a reader that assumes otherwise works perfectly until a message is split across two segments β after which every following frame is read from the middle of the last one and the connection quietly turns to noise. It also silently dropped whatever came in the same read as the frame it returned, which is every message after the first when a client sends two of them quickly.
So the leftovers are handed back with the message and passed in next time. ("", rest) means the peer has stopped talking.
# not run (msg, rest) = ws.ws_take(conn, "") # one frame's payload, and the bytes after it (next, rest2) = ws.ws_take(conn, rest) # the leftovers go back in
fn ws_read(conn: Int) -> Str
A text frame's payload, or "" once the connection is done. Keeps nothing between calls, so it is only safe where one message arrives at a time β ws_take is what a server should use.
# not run text = ws.ws_read(conn) # one text frame, where one message arrives at a time
fn ws_frame_op(op: Int, body: Str) -> Str
A frame the server sends: final, unmasked. 129 is FIN+text, 130 FIN+binary; the length walk is the same either way.
import "text/str" as str str.hex_encode(ws.ws_frame_op(130, "ab")) # => 82026162
fn ws_frame(text: Str) -> Str
import "text/str" as str str.hex_encode(ws.ws_frame("hi")) # => 81026869
fn ws_frame_bin(body: Str) -> Str
import "text/str" as str str.hex_encode(ws.ws_frame_bin(chr(0) + chr(255))) # => 820200ff
fn ws_write(conn: Int, text: Str) -> Int
# not run ws.ws_write(conn, "a text frame")
fn ws_write_bin(conn: Int, body: Str) -> Int
# not run
ws.ws_write_bin(conn, bytes)
fn ws_close(conn: Int) -> Unit
# not run ws.ws_close(conn) # a close frame, then the socket
fn client_frame(text: Str) -> Str
Clients must mask; a fixed key is fine, the point is XOR not secrecy.
import "text/str" as str #ws.client_frame("hi") # => 8 str.hex_encode(ws.client_frame("hi"))[0:4] # => 8182
fn client_frame_bin(body: Str) -> Str
import "text/str" as str str.hex_encode(ws.client_frame_bin("x"))[0:4] # => 8281
fn ws_handshake_request(host: Str, path: Str) -> Str
starts_with(ws.ws_handshake_request("h", "/p"), "GET /p HTTP/1.1\r\nHost: h\r\nUpgrade: websocket\r\n") # => true