Skip to main content

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_ticket from the join grant and waits for the server hello, storing the session_id.
  • Heartbeat — sends ping frames at the server-specified interval (default 15 s) automatically.
  • Sequence tracking — records the highest seq from 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

TypeFieldsPurpose
pingserver_time (echoed from the last pong)Heartbeat.
byeGraceful disconnect before closing the socket.
state_queryRequest a fresh roster_snapshot.
resumesession_id, last_seqResume a previous session. Must be sent within 5 seconds of connecting in resume mode.

Server → client

TypeFieldsPurpose
hellosession_id, resume, server_time, heartbeat_interval_ms (default 15000)Sent once after connect. The SDK stores session_id for resume.
pongserver_timeHeartbeat reply.
roster_snapshotparticipants[] (user_id, display_name, role, muted, speaking, joined_at), epochFull roster; epoch is the roster version counter.
resumedreplayedResume succeeded; replayed buffered frames were re-sent first.
(room event types)event payload, seqIncremental 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):

  1. Reconnect to <signal_url>?resume=1.
  2. Within 5 seconds, send {"type": "resume", "session_id": ..., "last_seq": ...}.
  3. The server replays buffered frames, then sends resumed with the replayed count.

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.