Signaling with the Dart SDK
The signaling WebSocket delivers the room roster and real-time room events (join/leave, mute, role changes), with heartbeat and session resume. The SDK's SignalingConnection implements the full protocol — most apps never touch it directly, because client.joinRoom() creates and manages it and RoomHandle.events re-emits everything as typed RoomEvents.
What the SDK does for you
SignalingConnection implements this state machine (see ADR 0022 §1):
disconnected ──connect()──> connecting ──hello──> connected
^ │
│ (ws close)
│ │
└──(max retries)── resuming <──(has sessionId)───┘
│
(resumed msg)
│
v
connected
- Handshake — connects with the one-time
signal_ticketfrom the join grant and waits for the serverhello, storing thesession_id. - Heartbeat — sends
pingframes at the server-specified interval (default 15 s) automatically. - Sequence tracking — records the highest
seqfrom forwarded room events. - Session resume — on an unexpected close, reconnects in resume mode with exponential backoff (1 s, 2 s, 4 s, at most 3 attempts) and replays missed events. A graceful close never triggers reconnection.
Consuming events
Through RoomHandle (the normal path):
final handle = await client.joinRoom(roomId);
handle.events.listen((event) {
switch (event) {
case RosterSnapshot(:final participants): // full roster
case ParticipantJoined(:final participant):
case ParticipantLeft(:final participant):
case Muted(): // you were muted by a moderator
case RoleChanged(:final newRole): // 'host' | 'speaker' | 'listener'
case RecordingStarted():
case RoomClosing():
case Disconnected(:final reason):
// 'signaling_closed' means resume attempts were exhausted —
// rejoin with client.joinRoom() to get a fresh ticket.
default:
break;
}
});
When all resume attempts fail, the connection settles in disconnected and the handle emits Disconnected('signaling_closed'). Recover by calling client.joinRoom() again — resume state cannot outlive the retry budget, and a fresh join issues a new one-time ticket.
Using SignalingConnection directly
For advanced cases (e.g. a custom media stack), drive the connection yourself with the join grant:
final grant = await client.rooms.join(roomId);
final signaling = SignalingConnection(grant.signalUrl, grant.signalTicket);
signaling.events.listen((event) {
switch (event) {
case SignalingHello(:final message): // handshake done; message.sessionId
case SignalingRosterSnapshot(:final message): // message.participants
case SignalingRoomEvent(:final message): // message.type, message.event, message.seq
case SignalingResumed(:final message): // message.replayed frames were re-sent
case SignalingStateChanged(:final state): // SignalingState enum
case SignalingError(:final error): // transport-level error
}
});
signaling.connect();
signaling.requestRoster(); // ask for a fresh roster_snapshot
// ...
signaling.disconnect(); // sends `bye`, closes with code 1000
signaling.dispose(); // release the event stream
Useful accessors: signaling.state (SignalingState.disconnected / .connecting / .connected / .resuming), signaling.sessionId, and signaling.lastSeq. Heartbeat and resume are automatic in this mode too.
Under the hood: the wire protocol
This is the protocol SignalingConnection implements (source: contracts/ws-protocol/signaling.json).
Connecting
The join grant provides signal_url and a one-time signal_ticket (st_...):
- Fresh connection:
GET <signal_url>?ticket=st_... - Resume mode:
GET <signal_url>?resume=1(see below)
Message envelope
Every frame is a JSON object with a type field. Room events forwarded from the server additionally carry a per-session seq number used for resume.
Client → server
| Type | Fields | Purpose |
|---|---|---|
ping | server_time (echoed from the last pong) | Heartbeat. |
bye | — | Graceful disconnect before closing the socket. |
state_query | — | Request a fresh roster_snapshot. |
resume | session_id, last_seq | Resume a previous session. Must be sent within 5 seconds of connecting in resume mode. |
Server → client
| Type | Fields | Purpose |
|---|---|---|
hello | session_id, resume, server_time, heartbeat_interval_ms (default 15000) | Sent once after connect. The SDK stores session_id for resume. |
pong | server_time | Heartbeat reply. |
roster_snapshot | participants[] (user_id, display_name, role, muted, speaking, joined_at), epoch | Full roster; epoch is the roster version counter. |
resumed | replayed | Resume succeeded; replayed buffered frames were re-sent first. |
| (room event types) | event payload, seq | Incremental room events forwarded from room pub/sub. The SDK tracks the highest seq. |
Heartbeat
After hello, the client sends ping frames at the server-specified interval (default 15 s), echoing the last server_time it received. The SDK's internal timer does exactly this.
Session resume
On an unexpected close (not a bye/code-1000 closure):
- Reconnect to
<signal_url>?resume=1. - Within 5 seconds, send
{"type": "resume", "session_id": ..., "last_seq": ...}. - The server replays buffered frames, then sends
resumedwith thereplayedcount.
The retry policy is exponential backoff — 1 s, 2 s, 4 s, up to 3 attempts — identical across all official SDKs. When retries are exhausted, rejoin the room via POST /v1/rooms/{id}/join to obtain a fresh signal_ticket.
Disconnecting
Send {"type": "bye"}, close the socket with code 1000, and stop the heartbeat — then call POST /v1/rooms/{id}/leave so the room service releases the participant slot. RoomHandle.leave() and SignalingConnection.disconnect() do this for you.