Skip to content

WebSocket

A WebSocket handler is a handler. It takes services by type, sits behind the same middleware, and is registered with app.get like everything else. What it does differently is hand the connection to a loop and return:

fn echo(c: *nilo.Ctx) !void {
    return c.upgrade(echoLoop, {});
}

fn echoLoop(socket: *nilo.Socket) !void {
    while (try socket.receive()) |message| {
        try socket.send(message.kind, message.data);
    }
}

try app.get("/ws", echo);

nilo does the handshake, the frame headers, the masking, the fragment reassembly and the closing handshake. Ping and pong are answered inside receive, so a handler never writes those three branches.

c.upgrade() fails with a 400 if the request isn't a WebSocket handshake, so a browser that wandered onto the URL gets told rather than hung up on. c.upgradeWith(loop, state, .{ .protocol = "chat.v1" }) names a subprotocol.

Why the loop is a separate function

Because a suspended fiber holds its stack, and where a socket waits is what it costs while it waits. A handler that looped in place would be parked inside the request — holding the Ctx, the parsed head and the route match, none of which the loop can reach — for as long as the tab is open. Returning first lets all of that unwind: an idle socket costs 5,183 bytes instead of 9,290 (ADR 062).

Carrying something into the loop

The second argument to upgrade is whatever the handler knows and the loop needs. Services are the common case, and they arrive the same way they arrive anywhere:

fn chat(c: *nilo.Ctx, room: *nilo.Room) !void {
    return c.upgrade(chatLoop, room);
}

fn chatLoop(socket: *nilo.Socket, room: *nilo.Room) !void { … }

Anything else works the same way — a Str off the query, a number off the path. It travels in the connection's own frame, so it is 128 bytes at most; a struct bigger than that goes in c.arena(), which is alive for as long as the loop is, with a pointer carried across. The compiler says so rather than truncating anything. {} is what you pass when there is nothing to carry.

The message loop

socket.receive() the next message, or null when it's over
socket.send(kind, data) one message, .text or .binary
socket.sendText(text) / sendBinary(bytes) the shorthands
socket.print(fmt, args) one text message, formatted
socket.json(value) one text message, serialised
socket.ping(data) for a proxy that drops quiet connections
socket.close(.normal, "") close, saying why — safe to call twice
socket.closedCleanly() whether the other end said goodbye
socket.live() false once the server is stopping, exactly as a stream's is

receive takes no buffer, and that is a memory decision rather than a convenience. The bytes of a message live in a buffer the executor lends this socket while the message is arriving and takes back when the conversation goes quiet, so what a process holds is one buffer per message in flight rather than one per open socket. On the workload WebSockets are for — ten thousand chat tabs with four people typing — those are different numbers by three orders of magnitude.

The ceiling is upgradeWith(loop, state, .{ .max_message = … }) and defaults to 16 KiB. A frame announcing more than that is refused before a byte of its payload is read, with a 1009. Nothing is allocated per message, and no byte of one is copied twice (ADR 046).

receive returns null three ways: the client closed politely, the client vanished — a tab closed, a network gone — or the server is stopping, in which case nilo has already told the client so with a 1001. All three end the loop the same way, so none of them is an error to write a branch for, and a message loop needs no shutdown check of its own. socket.closedCleanly() tells the first apart from the rest afterwards:

while (try socket.receive()) |message| { … }
if (!socket.closedCleanly()) std.log.info("client vanished", .{});

live() is still there for the handler doing work of its own between messages — a long computation, a timer, a queue it drains — which nilo can't see and so can't end on your behalf. Sending on a socket that has already closed writes nothing rather than failing: the other end closing between two of your sends isn't a bug you can prevent, so it isn't one you have to branch on.

Saying something without a buffer of your own

print and json write straight onto the wire, so a formatted message doesn't need a stack buffer you have to guess the size of:

try socket.print("welcome, {d} here", .{room.count()});
try socket.json(.{ .kind = "joined", .who = name, .here = room.count() });

Neither allocates. Both run the format twice — once to size the frame header, once to write the bytes — because a frame states its length before its bytes and there's nowhere to hold them meanwhile that isn't an allocation or a buffer you guessed at. Pass values rather than a window onto memory another fiber is writing, and the two passes agree.

nilo.Room has the same pair, and there they cost nothing extra at all: the message is formatted into the allocation say was going to make anyway.

Closing

Everything protocol-wrong is refused with the right close code before the error comes back — an unmasked frame or a reserved bit is 1002, text that isn't valid UTF-8 is 1007, too big is 1009 — because a connection dropped without a close frame looks to the other end like a crash.

That includes a goodbye that isn't one. A close frame carrying a single byte, a code nobody assigned, or a reason that isn't UTF-8 gets a 1002 rather than being echoed back — echoing it would put the same broken frame on the wire again. Your own reason is cut on a character boundary if it's longer than the 123 bytes a close frame has room for, so it never goes out as half a character.

Your own reasons go through close:

if (!members.allows(user)) return socket.close(.policy, "not a member");

.normal, .going_away, .protocol_error, .unsupported, .invalid_payload, .policy, .too_big, .internal, or a number of your own.

Shared state

A socket handler takes services by type like any other handler, so anything the connections share is an ordinary service:

const Transcript = struct {
    lock: nilo.Mutex = .init,
    messages: std.ArrayList([]u8) = .empty,
};

fn chat(c: *nilo.Ctx, transcript: *Transcript) !void { … }

nilo.Mutex, not std.Thread.Mutex — see Services.

That is for state of your own. Reaching the other connections is not something to hand-roll on top of it — see below.

Sending to a socket you don't hold

nilo.Room is a service like any other: provide one, take it by type, join on the way in and defer leave on the way out.

var room = try nilo.Room.init(gpa);
defer room.deinit();
try app.provide(&room);

fn chat(c: *nilo.Ctx, room: *nilo.Room) !void {
    return c.upgrade(chatLoop, room);
}

fn chatLoop(socket: *nilo.Socket, room: *nilo.Room) !void {
    try room.join(socket);
    defer room.leave(socket);

    while (try socket.receive()) |message| {
        try room.say(message.kind, message.data);
    }
}

That loop is the one an echo server writes. Nothing in it mentions the other connections and nothing handles an incoming broadcast — receive writes those out on the way past, from the fiber that owns the socket. The rest of the API is in the reference. defer room.leave(socket) is the part that isn't optional: Zig has no destructor, and a seat nobody gives up is one the next connection can't have.

Size the room for the crowd it might hold. join and say both cost what the room holds rather than what it was sized for, so an extra thousand empty seats is a memory decision and nothing else — and a say into a room with nobody in it doesn't even allocate (ADR 046).

Why it isn't a lock around a loop, which is worth knowing before you write one in your own code. A connection's write buffer belongs to the fiber serving it, so two fibers writing into it interleave frames — a corrupt stream rather than a slow one. A lock per socket fixes that much and nothing else: the broadcast is then performed by the speaker's fiber, which walks the connections, reaches one whose client has stopped reading, and blocks there. It never gets back to reading its own socket, so everybody's messages stop because one client stopped. Measured, with two healthy clients wanting only to talk to each other and one wedged client in the same room: their messages never arrived at all, at either lock granularity.

So say doesn't write. It rings a bell on each seat, and the connection's own fiber does the writing — which is why one client that stops reading costs that client and nobody else, and why a full backlog is a policy named at the room (.drop_oldest by default, or .drop_newest, with room.missed(&socket) saying how many went) rather than a disconnect (ADR 035). It adds 4 measured bytes per idle connection. The design that needed a second fiber per connection was 8,673 bytes against a per-connection budget that was 8,767 at the time, which is what kept this off the list for two stages (ADR 028).

What else came out of that work is nilo.spawn, for work that is not a request at all.

A connection that goes quiet

There is no read deadline, and there shouldn't be: a socket is allowed to sit quiet — a chat tab with nobody typing is working correctly, and closing it after thirty seconds would be a framework breaking a working connection. What catches a client that vanished without a FIN is .idle_ms, and it's a ping rather than a deadline. Silence asks whether the client is still there; an answer buys another stretch; a client that misses the next one is closed with 1001.

// 30s by default, 0 waits forever
return c.upgradeWith(chatLoop, room, .{ .idle_ms = 60_000 });

Thirty seconds costs a dead connection about a minute to notice and a live one two frames a minute. Proxies that drop quiet connections usually do so at sixty (ADR 022).

Which pages may open it

A browser applies no CORS to a WebSocket. It sends no preflight and it ignores Access-Control-Allow-Origin, so a cors.with(…) in front of an upgrade route sets headers nobody enforces. The handshake is an ordinary GET, so it arrives carrying the session cookie — which means that without a check here, a page on any origin could open your users' sockets and read and write them for as long as its tab was open. There is no browser step that refuses it.

So the default is your own pages: a handshake whose Origin does not name the authority its Host named is a 403. Nothing to write for the ordinary case, where the page and the socket are the same server.

// the page is on another host to the socket
return c.upgradeWith(chatLoop, room, .{ .origins = &.{"https://app.example.com"} });
// a public socket, carrying nothing worth stealing
return c.upgradeWith(feedLoop, {}, .{ .origins = &.{"*"} });

The scheme isn't compared — TLS is terminated in front, so nilo never learns which one the browser used — and a request with no Origin is allowed, because that isn't a browser and has no ambient cookie to be borrowed. curl, wstest and every native client send none (ADR 080).

Testing one

A handler that upgrades never returns a value, so there's nothing for testing.Client to read. testing.Conversation queues the frames a client would send, runs the handshake and the loop, and hands back what the server said (ADR 091):

test "the chat echoes what it is told" {
    var app = nilo.App.init(testing.allocator);
    defer app.deinit();
    try app.get("/chat", chat);

    var talking: nilo.testing.Conversation = try .init(testing.allocator, .{});
    defer talking.deinit();

    try talking.text("hello");
    try talking.close(1000, "bye");

    const talk = try talking.open(&app, "/chat");
    try testing.expect(talk.accepted());
    try testing.expectEqualStrings("hello", talk.at(0).?.bytes);
    try testing.expectEqual(@as(u16, 1000), talk.closedWith().?);
}

text, binary, ping, pong, close, fragments and raw are what you can send; talk.at(n), talk.first(.pong) and talk.closedWith() are what came back. setHeader puts an Origin or a cookie on the handshake, which is how the origin check gets tested.

The frames are queued before the server runs, so a test can't read what the server said and then decide what to send next — and a conversation between two sockets, which is what a Room is, needs two connections and can't be driven from here at all.

What isn't here

permessage-deflate. It's negotiated in the handshake, and a compressor per connection is a 64 KB window against a budget of 4,669 bytes — so it needs a design rather than a switch.

One number worth knowing before a chat server meets its users: an open socket is an open connection, and max_connections bounds those at 10,000 by default. A tab that is connected and silent still counts. Raise it — and multiply by the 5,183 bytes an idle WebSocket costs — before that is the limit you find out about (Deploying).

zig build run-chat is a working one, browser page included.