Zig 0.17.0

WebSocket

A WebSocket is a connection both sides write to. Use one when the browser has things to say all the time: a chat, a cursor, a game. When only the server speaks, and the browser listens, server-sent events are simpler: they are plain HTTP, and the browser connects again by itself.

The code on this page is from examples/guide (source): a small server kept as tests, for what the example apps do not use.

A socket is a route

zig
/// What a main() would do to serve. The tests start it once
/// (`spider.testing.start(run)`) and share it.
pub fn run() !void {
    // One client for the bucket, made once. In an app the values come from
    // the environment: `spider.r2.R2.initFromEnv(io)`.
    // (`io` is the one main() received; this package has no main.)
    const io = std.Options.debug_io;
    store = try spider.r2.R2.init(io, .{
        .account_id = "0a1b2c3d4e5f",
        .access_key = "an-access-key-id",
        .secret_key = "a-secret-access-key",
        .bucket = "guide-files",
        .pub_url = "https://files.guide.example",
    });
    defer store.deinit();

    var server = spider.app(.{});
    defer server.deinit();

    try server
        .use(spider.loggerWith(.{ .quiet_paths = &.{ "/up", "/assets/**" } }))
        .use(spider.forceHttps(.{ .default_base_url = "https://guide.example" }))
        .use(spider.varyHtmx)
        .get("/whoami", whoami, .{ .public = true })
        .post("/notes", addNote, .{ .public = true })
        .post("/hooks/payments", paymentHook, .{ .public = true, .allow_http = true })
        .post("/photos", addPhoto, .{ .public = true })
        .get("/photos/report", photoReport, .{ .public = true })
        .sseWith("/ticks", ticks, .{ .public = true })
        .wsWith("/rooms/:room/chat", chat, .{ .public = true })
        .wsWith("/staff/feed", staffFeed, .{ .authenticated = true })
        .get("/orders.xlsx", exportOrders, .{ .public = true })
        .post("/orders/import", importOrders, .{ .public = true })
        .get("/tickets/:code/qr.svg", ticketQr, .{ .public = true })
        .post("/avatars/sign", signAvatarUpload, .{ .public = true })
        .get("/invoices/:number", invoice, .{ .public = true })
        .post("/reports", keepReport, .{ .public = true })
        .get("/auth/google", googleLogin, .{ .public = true })
        .get("/auth/google/callback", googleCallback, .{ .public = true })
        .get("/shop", shop, .{ .public = true })
        .get("/shop/typo", shopTypo, .{ .public = true })
        .get("/shop/receipt", receipt, .{ .public = true })
        .get("/tasks", tasks, .{ .public = true })
        .post("/tasks", addTask, .{ .public = true })
        .post("/tasks/:id/archive", archiveTask, .{ .public = true })
        .onError(spider.errorHandler(.{}))
        .listen(.{});
}

wsWith takes the same config as any route. The handler gets a *spider.Ws once the socket is open:

zig
/// A chat room: what one connection says, everyone in the same room hears.
fn chat(ws: *spider.Ws) !void {
    const room = ws.param("room") orelse return;
    try ws.join(room);
    try ws.send("welcome");

    // Until the visitor leaves. When the handler returns, the socket closes.
    while (try ws.next()) |message| {
        ws.broadcastTo(room, message.data);
    }
}
  • ws.param("room") is the :room of the path.
  • ws.join(channel) puts the connection on a channel, a name the app makes up. A connection is on one channel at a time.
  • ws.send(text) writes to this connection only.
  • ws.next() waits for the next message; message.data is what the visitor sent. It gives null when the visitor is gone, which ends the loop.
  • ws.broadcastTo(channel, text) writes to everyone on the channel, the sender included. ws.broadcast(text) writes to every connection of the route, whatever their channel.

When the handler returns, the socket closes. A handler that returns an error closes it too, and the error goes to the log.

Each WebSocket route has its own set of connections: a broadcast on /rooms/:room/chat never reaches a socket opened on another route.

The page

The browser has what it needs:

html
<script>
    const scheme = location.protocol === "https:" ? "wss://" : "ws://";
    const socket = new WebSocket(scheme + location.host + "/rooms/zig/chat");

    socket.addEventListener("message", (event) => {
        console.log("heard:", event.data);
    });

    function say(text) {
        socket.send(text);
    }
</script>

Unlike a stream of events, a WebSocket that drops stays closed: a page that has to survive that opens a new one on the close event.

A test for a socket

The test opens three sockets, two in one room and one in another, and checks who hears what. It speaks the protocol by hand, which is a few lines:

zig
/// The little of a WebSocket client a test needs: the handshake, a text
/// message out, a text message in. A browser does all of it by itself.
const Client = struct {
    stream: std.Io.net.Stream,
    reader: std.Io.net.Stream.Reader,
    writer: std.Io.net.Stream.Writer,
    read_buf: [2048]u8 = undefined,
    write_buf: [512]u8 = undefined,

    fn open(self: *Client, app: spider.testing.App, path: []const u8) !void {
        const io = std.testing.io;
        const address = try std.Io.net.IpAddress.parse("127.0.0.1", app.port);
        self.stream = try address.connect(io, .{ .mode = .stream });
        const timeout = std.posix.timeval{ .sec = 3, .usec = 0 };
        try std.posix.setsockopt(self.stream.socket.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&timeout));
        self.reader = self.stream.reader(io, &self.read_buf);
        self.writer = self.stream.writer(io, &self.write_buf);

        try self.writer.interface.print("GET {s} HTTP/1.1\r\nHost: 127.0.0.1\r\n" ++
            "Upgrade: websocket\r\nConnection: Upgrade\r\n" ++
            "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", .{path});
        try self.writer.interface.flush();

        // "HTTP/1.1 101 Switching Protocols", then headers up to a blank line.
        const status = try self.reader.interface.takeDelimiterInclusive('\n');
        try std.testing.expect(std.mem.indexOf(u8, status, " 101 ") != null);
        while ((try self.reader.interface.takeDelimiterInclusive('\n')).len > 2) {}
    }

    fn say(self: *Client, text: []const u8) !void {
        // One text frame. A client masks what it sends; a mask of zeros
        // leaves the bytes as they are.
        try self.writer.interface.writeAll(&.{ 0x81, 0x80 | @as(u8, @intCast(text.len)), 0, 0, 0, 0 });
        try self.writer.interface.writeAll(text);
        try self.writer.interface.flush();
    }

    fn hear(self: *Client) ![]const u8 {
        const head = try self.reader.interface.take(2);
        try std.testing.expectEqual(@as(u8, 0x81), head[0]);
        return self.reader.interface.take(head[1]);
    }

    fn close(self: *Client) void {
        self.stream.close(std.testing.io);
    }
};

test "websocket: what one says, everyone in the same room hears, and nobody else" {
    const app = try spider.testing.start(run);

    var ana: Client = undefined;
    try ana.open(app, "/rooms/zig/chat");
    defer ana.close();
    var bo: Client = undefined;
    try bo.open(app, "/rooms/zig/chat");
    defer bo.close();
    var cy: Client = undefined;
    try cy.open(app, "/rooms/rust/chat");
    defer cy.close();

    // Each one is greeted when its handler starts.
    try std.testing.expectEqualStrings("welcome", try ana.hear());
    try std.testing.expectEqualStrings("welcome", try bo.hear());
    try std.testing.expectEqualStrings("welcome", try cy.hear());

    try ana.say("hello, zig");
    try std.testing.expectEqualStrings("hello, zig", try bo.hear());
    // The one who spoke hears it too.
    try std.testing.expectEqualStrings("hello, zig", try ana.hear());

    // The other room heard nothing: its next message is its own.
    try cy.say("hello, rust");
    try std.testing.expectEqualStrings("hello, rust", try cy.hear());
}

Who may open one

The route's config is checked on the request that asks for the socket. A visitor who does not pass gets a 401 or a 403, and no socket:

zig
test "websocket: a route's access is checked before the socket opens" {
    const app = try spider.testing.start(run);
    var res = try app.with(&.{ upgrade, connection, key, version }).get("/staff/feed");
    defer res.deinit();
    try res.expectStatus(401);
}

A page on another site cannot open a socket to your app with the visitor's cookies: the cross-site check of Security in production refuses it, and it is on by default.

zig
test "websocket: a page on another site cannot open one" {
    const app = try spider.testing.start(run);
    var res = try app.with(&.{ upgrade, connection, key, version, "Origin: https://evil.example", "Sec-Fetch-Site: cross-site" }).get("/rooms/zig/chat");
    defer res.deinit();
    try res.expectStatus(403);
}

ws(path, handler), without a config, declares no access: prefer wsWith, so that the route says who it is for.

Good to know

  • ws.joinUser(id) joins the channel user:<id>: ws.broadcastTo("user:7", text) then reaches every socket of account 7 on the route.
  • message.data is kept until the connection ends. A connection that receives a lot for a long time keeps growing in memory: keep messages small, and close sockets that have been open for days.
  • A message can have up to 16 MiB. One the browser splits into several frames is not put back together, and ends the connection: browsers send ordinary messages in one frame.
  • wsInterval(path, ms, callback) is a route whose sockets only listen: the callback gets the route's hub every ms milliseconds and sends to them.
  • A socket gets its line in the request log when it closes.