Skip to content

Channels

Channels provide a higher-level abstraction over raw WebSockets. Instead of working with frames directly, you define topics and events. Clients join topics, push events, and receive broadcasts — all over a single WebSocket connection. The design is inspired by Phoenix Channels and uses a compatible JSON wire protocol.

A channel system has four layers:

  1. Socket — a per-connection wrapper around a WebSocket, tracking which topics the client has joined (up to 16 topics per connection)
  2. Channel definition — a ChannelDef that maps a topic pattern to join/leave/event handlers
  3. PubSub — a thread-safe topic registry that tracks which WebSocket connections are subscribed to which topics (up to 64 topics, 256 subscribers each)
  4. Channel middleware — wires everything together, handling the JSON wire protocol, join/leave lifecycle, heartbeats, and event routing
  1. Define a channel. Create a ChannelDef with a topic pattern, join handler, and event handlers.

    const zzz = @import("zzz");
    fn roomJoin(_: *zzz.Socket, _: []const u8, _: []const u8) zzz.JoinResult {
    // Return .ok to allow, .@"error" to reject
    return .ok;
    }
    fn roomLeave(_: *zzz.Socket, _: []const u8) void {
    // Cleanup when a client leaves the topic
    }
    fn handleNewMsg(socket: *zzz.Socket, topic: []const u8, _: []const u8, payload: []const u8) void {
    // Broadcast the message to all subscribers of this topic
    socket.broadcast(topic, "new_msg", payload);
    }
    const room_channel: zzz.ChannelDef = .{
    .topic_pattern = "room:*",
    .join = &roomJoin,
    .leave = &roomLeave,
    .handlers = &.{
    .{ .event = "new_msg", .handler = &handleNewMsg },
    },
    };
  2. Register the channel route. Use Router.channel to create a WebSocket endpoint that speaks the channel protocol.

    pub const ctrl = zzz.Controller.define(.{}, &.{
    zzz.Router.channel("/socket", .{
    .channels = &.{room_channel},
    }),
    });
  3. Connect from the client. Use the zzz.js client to connect and join the topic.

Topic patterns determine which topics a channel definition handles. There are two forms:

PatternMatches
"room:*"Any topic starting with room:, such as room:lobby, room:123, room:
"notifications"Only the exact string notifications

Wildcard patterns use the prefix:* syntax. The * matches any suffix after the colon.

pub const ChannelDef = struct {
topic_pattern: []const u8,
join: *const fn (*Socket, []const u8, []const u8) JoinResult,
leave: ?*const fn (*Socket, []const u8) void = null,
handlers: []const EventHandler = &.{},
};
FieldDescription
topic_patternTopic pattern to match (e.g., "room:*" or "notifications")
joinCalled when a client requests to join. Receives the socket, topic, and join payload (JSON string). Return .ok to allow or .@"error" to reject. Defaults to always allowing.
leaveCalled when a client leaves or disconnects. Receives the socket and topic. Optional.
handlersArray of EventHandler structs mapping event names to handler functions.
pub const EventHandler = struct {
event: []const u8,
handler: *const fn (*Socket, []const u8, []const u8, []const u8) void,
};

The handler function receives four arguments: (socket, topic, event, payload_json).

Socket is the per-connection handle passed to all channel callbacks. It wraps a *WebSocket and provides channel-specific operations.

MethodDescription
socket.push(topic, event, payload_json)Send a message to this socket only
socket.reply(topic, ref, status, payload_json)Reply to a client request (resolves the client-side Promise)
socket.broadcast(topic, event, payload_json)Send to all subscribers of the topic, including the sender
socket.broadcastFrom(topic, event, payload_json)Send to all subscribers except the sender
socket.pushTo(topic, event, target_ws, payload_json)Send to a specific subscriber
MethodDescription
socket.param(name)Get a route parameter (from the WebSocket URL pattern)
socket.getAssign(key)Get an assign value set by earlier middleware
MethodDescription
socket.isJoined(topic)Check if this socket is currently in a topic

The join callback is the right place to authorize access to a topic. You can inspect assigns set by authentication middleware, check the payload, or validate the topic name:

fn roomJoin(socket: *zzz.Socket, topic: []const u8, payload: []const u8) zzz.JoinResult {
// Check if user is authenticated
const user = socket.getAssign("user_id") orelse return .@"error";
// Allow joining
return .ok;
}

When you return .@"error", the client receives an error reply with {"reason":"join rejected"}, and the client-side join Promise rejects.

There are three ways to send messages to topic subscribers:

fn handleNewMsg(socket: *zzz.Socket, topic: []const u8, _: []const u8, payload: []const u8) void {
// Everyone in the topic sees this, including the sender
socket.broadcast(topic, "new_msg", payload);
}

The PubSub module is the underlying registry that tracks topic subscriptions. The channel middleware manages subscriptions automatically during join/leave, but you can also use PubSub directly for advanced scenarios:

const PubSub = zzz.PubSub;
// Subscribe a WebSocket to a topic
_ = PubSub.subscribe("alerts", ws);
// Broadcast raw text to all subscribers
PubSub.broadcast("alerts", "{\"event\":\"alert\",\"data\":\"fire!\"}");
// Broadcast to all except a specific WebSocket
PubSub.broadcastFrom("alerts", message, sender_ws);
// Get subscriber count
const count = PubSub.subscriberCount("alerts");
// Unsubscribe
PubSub.unsubscribe("alerts", ws);
// Unsubscribe from all topics (typically on disconnect)
PubSub.unsubscribeAll(ws);

The channel system uses a JSON wire protocol. Every message is a JSON object with four fields:

{
"topic": "room:lobby",
"event": "new_msg",
"payload": {"body": "hello"},
"ref": "1"
}
FieldDescription
topicThe channel topic (e.g., "room:lobby")
eventThe event name (e.g., "new_msg", "phx_join", "phx_leave", "heartbeat")
payloadA JSON object with event-specific data
refA unique string reference for request/reply correlation; null for server-initiated pushes
EventDirectionDescription
phx_joinClient to serverJoin a topic
phx_leaveClient to serverLeave a topic
heartbeatClient to serverKeep the connection alive
phx_replyServer to clientReply to a join, leave, or custom event
FieldTypeDefaultDescription
channels[]const ChannelDef(required)Array of channel definitions to register
heartbeat_timeout_su3260Seconds before a connection without heartbeats is considered dead
const std = @import("std");
const zzz = @import("zzz");
fn roomJoin(_: *zzz.Socket, _: []const u8, _: []const u8) zzz.JoinResult {
return .ok;
}
fn roomLeave(_: *zzz.Socket, _: []const u8) void {}
fn handleNewMsg(socket: *zzz.Socket, topic: []const u8, _: []const u8, payload: []const u8) void {
socket.broadcast(topic, "new_msg", payload);
}
const room_channel: zzz.ChannelDef = .{
.topic_pattern = "room:*",
.join = &roomJoin,
.leave = &roomLeave,
.handlers = &.{
.{ .event = "new_msg", .handler = &handleNewMsg },
},
};
pub const ctrl = zzz.Controller.define(.{}, &.{
zzz.Router.channel("/socket", .{
.channels = &.{room_channel},
}),
});