websocket

tape
import websocket from "websocket";
import io from "io";

var s: websocket.Socket = websocket.connect("wss://echo.example/chat", websocket.options()) or {
    io.eprintln("connect failed: {err.desc()}");
    return 1;
};
defer s.free();
s.send_text("hello") or { return 1; };
let m: websocket.Message = s.recv() or { return 1; };
io.println("got {m.kind.desc()}: {m.data}");
s.close(1000, "bye") or { return 1; };

websocket speaks WebSocket (RFC 6455) as a client and as a server, with the permessage-deflate compression extension (RFC 7692). It comes in two layers:

  • a Socket is a connection over net: ws:// over TCP, wss:// over TLS. The opening handshake goes through http’s engines. Every call either never waits (poll, start_connect, start_accept: for a game loop) or waits for you (connect, accept, wait_open, recv);
  • a Conn is the frame engine on its own: you feed it the bytes that arrived and send the bytes it queues, and it gives you whole messages. It never touches a socket or a clock, so it runs over any transport, and its tests run in memory.

Not covered (yet): WebSocket over HTTP/2 (RFC 8441), extensions other than permessage-deflate, proxies, redirects of the handshake, a server that owns its listener (you accept connections, websocket takes them from there), and streaming one huge message as it arrives (messages arrive whole, up to a size limit).

Messages

tape
pub enum Kind : u8 { Text = 1; Binary = 2; Close = 8; Ping = 9; Pong = 10; }   // k.desc() is "text", ...
pub struct Message {
    kind: Kind;
    data: []const u8;   // the payload; a Close's reason
    code: u16;          // a Close's status code
}

A text message’s data is always valid UTF-8 (the engine checks it). A Message is a view into the socket’s buffers: it is valid until your next poll or recv on that socket. Copy what you keep:

tape
let m: websocket.Message = s.recv() or { return 1; };
let kept: string = str.from_view(m.data);

Pings and Pongs are delivered too; a Ping has already been answered (auto_pong, on by default), and a Pong is the answer to your ping, handy for measuring the round trip. A Close is the last message: its code is what the peer sent (1005 when its Close had no code, 1006 when the connection ended without a Close).

Connecting

FunctionDoes
connect(url, opts) -> SocketConnect and finish the handshake (waits, within connect_timeout)
start_connect(url, opts) -> SocketConnect and send the upgrade request; poll finishes the handshake
options() -> OptionsThe defaults (30 s to connect), to change field by field
parse_url(text) -> http.UrlThe parts of a ws or wss URL
tape
pub struct Options {
    protocols: []const []const u8 = [];     // subprotocols to offer, preferred first
    headers: []const http.Field = [];       // more request headers: Origin, Authorization, Cookie, ...
    deflate: bool = false;                  // offer permessage-deflate
    max_message: i64 = 16777216;            // the largest message accepted, bytes
    max_head: i64 = 16384;                  // the largest handshake response head, bytes
    auto_pong: bool = true;                 // answer Pings yourself when false
    connect_timeout: ?time.Duration = null; // options() sets 30 s
    io_timeout: ?time.Duration = null;      // each wait of recv and of a send; null: none
    trust: ?*const x509.TrustStore = null;  // wss: null is tape's bundled roots
}
tape
var o: websocket.Options = websocket.options();
o.protocols = ["chat.v2", "chat.v1"];
o.headers = [http.Field { name: "Authorization"; value: "Bearer abc123"; }];
o.deflate = true;
var s: websocket.Socket = websocket.connect("ws://127.0.0.1:9000/lobby?room=3", o) or {
    io.eprintln("{err.desc()}");
    return 1;
};
defer s.free();
if (s.protocol() != null) { io.println("speaking {s.protocol()!}"); }
if (s.compressed()) { io.println("compressed"); }

wss:// checks the server’s certificate against tape’s bundled roots or opts.trust, as http does; build a net.Trust for a private CA. The headers Host, Upgrade, Connection, Content-Length, Transfer-Encoding and every Sec-WebSocket-* are the client’s to send: giving one traps.

parse_url takes ws:// and wss:// (any case), with the default ports 80 and 443; a fragment (#...) or user@ is BadUrl, any other scheme UnsupportedScheme:

tape
let u: http.Url = websocket.parse_url("wss://[::1]:9443/feed?id=7") or { return 1; };
io.println("{u.scheme} {u.host} {u.port} {u.target}");   // wss ::1 9443 /feed?id=7

In a game loop

poll() never waits: it reads what has arrived, answers Pings, and gives you the next whole message or null. Call it each frame until it returns null. A socket from start_connect is Connecting until the server’s answer has come; poll returns null meanwhile.

tape
var s: websocket.Socket = websocket.start_connect("ws://game.example:7000/play", websocket.options()) or { return 1; };
defer s.free();
var clock: time.FrameClock = time.frame_clock(time.from_hz(60.0));
while (s.phase() != websocket.Phase.Closed) {
    while (true) {
        let m: ?websocket.Message = s.poll() or {
            io.eprintln("connection failed: {err.desc()}");
            return 1;
        };
        if (m == null) { break; }
        if (m!.kind == websocket.Kind.Binary) { apply_snapshot(m!.data); }
        if (m!.kind == websocket.Kind.Close) { io.println("server closed: {m!.code}"); }
    }
    if (s.phase() == websocket.Phase.Open) {
        s.send_binary(encode_input()) or {};
    }
    clock.tick();
    while (clock.step()) { simulate(clock.dt()); }
    render(clock.alpha());
}
tape
pub enum Phase : u8 {
    Connecting = 0;     // the opening handshake is under way
    Open = 1;           // messages both ways
    Closing = 2;        // you sent Close; the peer's has not come yet
    Closed = 3;         // both Closes, a failure, or the connection ended
}

Sending writes the frame to the socket before it returns. It waits only when the operating system’s send buffer is full (at most io_timeout), so a small message from a game loop returns at once.

For many sockets at once, poll_item(want) gives a socket’s net.PollItem for net.poll or a net.Poller; poll a socket’s transport only when buffered() is false (bytes already read, decrypted or a message waiting make it true):

tape
var items: [2]net.PollItem = [a.poll_item(net.READABLE), b.poll_item(net.READABLE)];
if (!a.buffered() && !b.buffered()) { net.poll(items[0..2], time.millis(16)) or { return 1; }; }

Waiting

MethodDoes
s.recv() -> MessageWait for the next message; each wait at most io_timeout (TimedOut)
s.recv_for(t) -> ?MessageWait at most t in all; null when nothing came
s.wait_open(t)Wait until the handshake is done, at most t (null: as long as it takes)
tape
while (true) {
    let m: ?websocket.Message = s.recv_for(time.millis(50)) or { return 1; };
    if (m == null) { do_housekeeping(); continue; }
    if (m!.kind == websocket.Kind.Close) { break; }
    handle(m!);
}

After the Close has been returned (or an error), poll returns null and recv fails with Io { NotConnected }: the connection is over.

Sending

MethodDoes
s.send_text(text)One text message (traps unless text is UTF-8)
s.send_binary(data)One binary message
s.send_part(kind, data, last)A message in frames: the same kind each time, last on the final one
s.ping(data)A Ping of at most 125 bytes; its Pong comes from poll/recv
s.pong(data)An unsolicited Pong (a heartbeat), at most 125 bytes
s.close(code, reason)Start the close handshake
tape
// a large file as one message in 64 KiB frames
var buf: [65536]u8;
while (true) {
    let n: i64 = f.read(buf[0..65536]) or { return 1; };
    s.send_part(websocket.Kind.Binary, buf[0..n], n == 0) or { return 1; };
    if (n == 0) { break; }
}

Pings may go between the parts of a message; another message may not (traps). A text message’s parts may split a character, the whole message may not.

Sending before the handshake is done, or after a Close was sent, fails with Io { NotConnected } (the peer may have closed first: that is not a bug in your program).

Closing

close(code, reason) sends your Close and moves to Closing; keep calling poll or recv until the peer’s Close comes back (messages it sent before may come first). When the peer closes first, the socket answers with the same code and returns the peer’s Close. Either way the socket closes the connection once the handshake is over; free() releases it.

tape
s.close(1000, "bye") or {};
while (true) {
    let m: websocket.Message = s.recv() or { break; };
    if (m.kind == websocket.Kind.Close) { break; }
}
s.free();

code is 1000-1003, 1007-1014 or 3000-4999 (your application’s own), and reason UTF-8 of at most 123 bytes; anything else traps. 1005 sends a Close without a code (and without a reason).

CodeMeans
1000Normal closure
1001Going away (a server shutting down, a page closed)
1002Protocol error
1003A kind of data the endpoint cannot accept
1007Invalid data (text that is not UTF-8)
1008Policy violation
1009Message too big
1011Internal error
3000-4999Yours to define

free() without a close handshake just closes the connection: the peer sees 1006.

Serving

FunctionDoes
accept(tcp, opts) -> SocketRead the upgrade request on an accepted stream and answer it (waits, within handshake_timeout)
accept_tls(stream, opts) -> SocketThe same over a stream net.tls_accept made
start_accept(tcp, opts) -> SocketThe same without waiting: poll finishes the handshake
start_accept_tls(stream, opts) -> Socketstart_accept over TLS
server_options() -> ServerOptionsThe defaults (10 s for the handshake)
tape
pub struct ServerOptions {
    protocols: []const []const u8 = [];     // ours, preferred first: the first the client offers is chosen
    protocol_required: bool = false;        // refuse a client that offers none of ours (400)
    origins: []const []const u8 = [];       // accepted Origin values (ignoring case); empty: any
    headers: []const http.Field = [];       // more headers on the 101 answer
    deflate: bool = false;                  // accept permessage-deflate when offered
    max_message: i64 = 16777216;
    max_head: i64 = 16384;                  // the largest request head, bytes
    auto_pong: bool = true;
    handshake_timeout: ?time.Duration = null;   // server_options() sets 10 s
    io_timeout: ?time.Duration = null;
}
tape
var l: net.TcpListener = net.listen(net.any_v4(9000)) or { return 1; };
defer l.close();
var so: websocket.ServerOptions = websocket.server_options();
so.protocols = ["chat.v1"];
so.origins = ["https://game.example"];
while (true) {
    let a: net.Accepted = l.accept() or { continue; };
    var s: websocket.Socket = websocket.accept(a.stream, so) or {
        io.eprintln("refused: {err.desc()}");
        continue;
    };
    let ua: ?[]const u8 = s.header("User-Agent");
    if (ua != null) { io.println("{s.target()} from {ua!}"); }
    serve(&s);      // recv and send until the Close
    s.free();
}

The socket owns the stream from the call on. A request that is not a WebSocket upgrade is answered 400, a wrong Sec-WebSocket-Version 426 with Sec-WebSocket-Version: 13, an Origin not in origins 403, and the error is returned.

s.target() is the path and query the client asked for, and s.header(name) a header of the other side’s handshake (on a server the request’s: Origin, Cookie, User-Agent; on a client the 101’s).

A server for several clients at once polls them in one loop (each from start_accept), or gives each connection to a worker thread (a Socket is used by one thread at a time).

With your own HTTP server

A program that already runs an http.Server loop (pages and WebSocket on one port) upgrades a request itself:

FunctionDoes
negotiate(req, &opts) -> UpgradeCheck a request head and choose the subprotocol and compression
up.respond(&srv, now_unix)Answer 101 Switching Protocols; the server becomes a tunnel
up.conn() -> ConnThe server’s frame engine with what was agreed
up.protocol() -> ?[]const u8, up.deflate() -> ?DeflateWhat was agreed
refuse(&srv, e, now_unix)Answer a failed negotiate (400, 403 or 426)
accept_key(key) -> stringSec-WebSocket-Accept for a Sec-WebSocket-Key
tape
let req: ?*const http.RequestHead = srv.request();
if (req != null && req!.upgrade && bytes.eq(req!.target, "/ws")) {
    let up: websocket.Upgrade = websocket.negotiate(req!, &opts) or {
        websocket.refuse(&srv, err, now);
        return;
    };
    up.respond(&srv, now);
    conn.ws = up.conn();
    // from here: the bytes srv.feed did not take, then everything the
    // socket receives, go to conn.ws.feed; conn.ws.output() goes out
}

accept_key("dGhlIHNhbXBsZSBub25jZQ==") is "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" (RFC 6455’s example).

The frame engine

FunctionDoes
conn(role, opts, seed) -> ConnAn engine for a client or a server
c.feed(data) -> i64Bytes that arrived: how many it took (none while a message waits)
c.next() -> ?MessageThe next whole message, once
c.output() -> []const u8, c.advance_output(n)What to send, and how much you sent
c.end_of_input()The transport ended: a Close 1006 follows
c.send_text, send_binary, send_part, ping, pong, closeAs on a Socket, returning false once a Close was sent
c.phase() -> Phase, c.close_code() -> u16Where it is; the peer’s close code once closed
c.free()Release its buffers
tape
pub enum Role : u8 { Client = 1; Server = 2; }
pub struct ConnOptions {
    max_message: i64 = 16777216;
    auto_pong: bool = true;
    deflate: ?Deflate = null;      // permessage-deflate, as the handshake agreed
}
pub struct Deflate {
    server_no_context_takeover: bool = false;
    client_no_context_takeover: bool = false;
    server_max_window_bits: i64 = 15;
    client_max_window_bits: i64 = 15;
}

A client masks every frame it sends with keys from a CSPRNG seeded by seed: 32 or more bytes from os.random_bytes (a server ignores it). The loop: feed what arrived, take every message, send the output.

tape
var seed: [32]u8;
os.random_bytes(seed[0..32]) or { return 1; };
var c: websocket.Conn = websocket.conn(websocket.Role.Client, websocket.ConnOptions { }, seed[0..32]);
defer c.free();
c.send_text("hello");
transport_send(c.output());
c.advance_output(c.output().len);

let got: []const u8 = transport_receive();
var at: i64 = 0;
while (at < got.len) {
    let n: i64 = c.feed(got[at..got.len]) or { break; };   // our Close is in output()
    at = at + n;
    var m: ?websocket.Message = c.next();
    while (m != null) {
        io.println("{m!.kind.desc()}: {m!.data}");
        m = c.next();
    }
}
transport_send(c.output());     // Pongs, the answer to a Close
c.advance_output(c.output().len);

The engine checks every frame: reserved bits, unknown opcodes, control frames fragmented or over 125 bytes, masking the wrong way for the direction, continuation frames without a message (or a new message inside one), UTF-8 in text and close reasons, close codes, the message size (refused at the frame header, before the payload comes) and compressed data. A broken frame is a ProtocolError; the engine queues a Close with its code (close_code_of(e): 1007 for bad data, 1009 for too large, 1002 otherwise) and returns the same error from every later feed.

Compression

With deflate on both sides (Options.deflate, ServerOptions.deflate) the handshake agrees on permessage-deflate and s.compressed() is true. Messages of 64 bytes or more are then compressed, each one ending with a sync flush, and the compressor’s window carries over from message to message unless the peer asked otherwise (*_no_context_takeover). Messages sent with send_part and control frames go uncompressed. Inflated messages count against max_message, so a small message that inflates to gigabytes fails as MessageTooLarge.

Compression costs memory: about 600 KiB per connection for the compressor and decompressor. Leave it off for many small messages, or many connections.

Errors

tape
pub tagged WsError {
    Io { e: io.IoError; }              // the connection: refused, reset, timed out, NotConnected
    Tls { e: tls.TlsError; }           // wss: the certificate, the handshake, the records
    Http { e: http.ProtocolError; }    // the handshake's HTTP
    Refused { status: i64; }           // the server answered the upgrade with this status, not 101
    Handshake { e: HandshakeError; }
    Protocol { e: ProtocolError; }     // the peer broke the protocol; our Close went with its code
}

err.desc() gives the text. HandshakeError: BadUrl, UnsupportedScheme, NotUpgraded, BadAccept, BadSubprotocol, BadExtension (the server’s answer did not match what was offered), NotWebSocket, BadVersion, BadKey, NoSubprotocol, BadOrigin (a request a server refused). ProtocolError: ReservedBits, BadOpcode, FragmentedControl, ControlTooLong, BadMask, BadLength, UnexpectedContinuation, ExpectedContinuation, BadUtf8, BadCloseFrame, MessageTooLarge, BadCompression.

tape
let s: websocket.Socket = websocket.connect(url, o) or {
    match (err) {
        websocket.WsError.Refused { status } => { io.eprintln("the server said {status}"); }
        websocket.WsError.Tls { e } => { io.eprintln("TLS: {e.desc()}"); }
        websocket.WsError.Io { e } => { io.eprintln("network: {e.desc()}"); }
        websocket.WsError.Http { e } => { io.eprintln("HTTP: {e.desc()}"); }
        websocket.WsError.Handshake { e } => { io.eprintln("handshake: {e.desc()}"); }
        websocket.WsError.Protocol { e } => { io.eprintln("protocol: {e.desc()}"); }
    }
    return 1;
};

A Socket is a reference to its state: copies share it, and you free() it once. One thread uses it at a time.

Last modified: