websocket
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
Socketis a connection overnet:ws://over TCP,wss://over TLS. The opening handshake goes throughhttp’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
Connis 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
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:
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
| Function | Does |
|---|---|
connect(url, opts) -> Socket | Connect and finish the handshake (waits, within connect_timeout) |
start_connect(url, opts) -> Socket | Connect and send the upgrade request; poll finishes the handshake |
options() -> Options | The defaults (30 s to connect), to change field by field |
parse_url(text) -> http.Url | The parts of a ws or wss URL |
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
}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:
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=7In 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.
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());
}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):
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
| Method | Does |
|---|---|
s.recv() -> Message | Wait for the next message; each wait at most io_timeout (TimedOut) |
s.recv_for(t) -> ?Message | Wait 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) |
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
| Method | Does |
|---|---|
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 |
// 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.
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).
| Code | Means |
|---|---|
| 1000 | Normal closure |
| 1001 | Going away (a server shutting down, a page closed) |
| 1002 | Protocol error |
| 1003 | A kind of data the endpoint cannot accept |
| 1007 | Invalid data (text that is not UTF-8) |
| 1008 | Policy violation |
| 1009 | Message too big |
| 1011 | Internal error |
| 3000-4999 | Yours to define |
free() without a close handshake just closes the connection: the peer
sees 1006.
Serving
| Function | Does |
|---|---|
accept(tcp, opts) -> Socket | Read the upgrade request on an accepted stream and answer it (waits, within handshake_timeout) |
accept_tls(stream, opts) -> Socket | The same over a stream net.tls_accept made |
start_accept(tcp, opts) -> Socket | The same without waiting: poll finishes the handshake |
start_accept_tls(stream, opts) -> Socket | start_accept over TLS |
server_options() -> ServerOptions | The defaults (10 s for the handshake) |
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;
}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:
| Function | Does |
|---|---|
negotiate(req, &opts) -> Upgrade | Check 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() -> Conn | The server’s frame engine with what was agreed |
up.protocol() -> ?[]const u8, up.deflate() -> ?Deflate | What was agreed |
refuse(&srv, e, now_unix) | Answer a failed negotiate (400, 403 or 426) |
accept_key(key) -> string | Sec-WebSocket-Accept for a Sec-WebSocket-Key |
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
| Function | Does |
|---|---|
conn(role, opts, seed) -> Conn | An engine for a client or a server |
c.feed(data) -> i64 | Bytes that arrived: how many it took (none while a message waits) |
c.next() -> ?Message | The 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, close | As on a Socket, returning false once a Close was sent |
c.phase() -> Phase, c.close_code() -> u16 | Where it is; the peer’s close code once closed |
c.free() | Release its buffers |
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.
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
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.
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: