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
/// 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:
/// 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:roomof 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.datais what the visitor sent. It givesnullwhen 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:
<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:
/// 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:
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.
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 channeluser:<id>:ws.broadcastTo("user:7", text)then reaches every socket of account 7 on the route.message.datais 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 everymsmilliseconds and sends to them.- A socket gets its line in the request log when it closes.