Voxal · WebRTC architecture reference
Three planes, drawn from src/main.js. Signalling is a star through the host. Audio is a full mesh, peer to peer, always — no media server touches it under any setting. Camera and screen are their own meshes, and are the only thing that may move to a relay when a room grows.
Reading the diagrams
DataConnection. Control messages only — no media.MediaConnection carrying voice. Always peer to peer.MediaConnection sets, independent of audio.The main case
2–N participants · voice onlyOne person creates a room and becomes the host. The room code is their PeerJS id, so there is nothing to look up. Everyone else connects to the host for control, and to each other for sound.
Figure 1 · Joining
peer-list, not just to the host.Figure 2 · Steady state, 4 people
Figure 3 · Network flow — what each server sees
fetchIceServers() resolves the first source that answers: an embedding page, your organisation, metered.ca, this deployment's /api/ice-servers, then public STUN. The relay is a fallback for blocked networks, not a hop the app prefers.Figure 4 · Losing the host
peer-list and every heartbeat carries deputyId and successorIds, so when the host goes quiet each peer already knows who is next. Audio links to non-host peers are never torn down during a migration — only the star is rebuilt.A heavier case
2 participants · camera + screenNothing about the room changes. Turning a camera on adds a second, entirely separate MediaConnection set — it does not ride the audio one, and it does not renegotiate it.
Figure 5 · One pair, four channels
video-offer that announces a camera travels the signalling star, like every other control message.The routing decision · selectVideoTopology()
| Your setting | 2 participants | 3 or more | Reason code |
|---|---|---|---|
| Allow relay default |
P2P | SFU | preference-allow-sfu |
| Prefer direct | P2P | P2P + asks you | p2p-unsuitable |
| Direct only | P2P | P2P video may fail to establish |
preference-p2p-only |
The function is pure — no network, no DOM — and can only return sfu when the preference is Allow relay. If no SFU is configured on the deployment, every row falls back to P2P with sfu-unavailable. Audio is never passed through this function at all.
Where the mesh breaks
3+ participants · camera + screenAbove two people, a video mesh costs each publisher one full upload per viewer. This is the one place Voxal will move media onto a server — and it moves the picture only.
Figure 6 · The same four people, two ways
Figure 7 · Uplink per publisher, camera at 600 kbps
Figure 8 · The SFU control plane
/api/sfu-session is the cheap, rate-limited "may I?" — it never talks to Cloudflare. /api/sfu-track verifies that token and is the only place the Cloudflare app secret is used. The providerRef that names the track travels over Voxal's own signalling star; no Cloudflare data channel is used for application state.Reference
protocol v3Every message below rides a DataConnection. Peers send to the host; the host is the only party that fans out.
| Message | Direction | What it carries |
|---|---|---|
hello | joiner → host | pseudo, protocolVersion, appVersion |
pseudo-assigned | host → peer | the name the host actually granted, de-duplicated |
peer-list | host → peer | the whole roster, plus hostId, deputyId, successorIds — the authoritative view |
peer-joined / peer-left / peer-renamed | host → all | roster deltas between full lists |
talking | peer → host → all | push-to-talk state |
heartbeat | host ↔ peers | every 2 s, carrying the successor chain again |
video-offer / screen-offer | peer → host → all | topology (p2p or sfu) and, when relayed, providerRef |
video-call-peer | host → P2P sharers | "a newcomer arrived — call them with your camera". Not sent for an SFU sharer: the newcomer subscribes on its own. |
chat / chat-edit | peer → host → all, sender included | the host stamps sender and time, so the echo back is the ack |
chat-history | host → joiner | the transcript so far, from the host's own replica |
redirect | peer → joiner | "the host moved" — sent by a peer someone tried to join after a migration |
Drawn from src/main.js, api/_sfu.js, docs/video-routing.md and docs/turn-and-ice.md. Bitrates are the configured ceilings (OPUS_MAX_BITRATE 32 kbps, CAMERA_MAX_BITRATE 600 kbps, SCREEN_MAX_BITRATE 1.5 Mbps), not measured averages.