P2P Chat Room¶
A fully decentralised chat room running entirely in the browser. Peers connect over WebRTC using A11's signalling infrastructure. The room creator becomes the host — an A11 service exposing actions that other peers call to send messages, set names, and receive the replicated event log. When the host leaves, the peer with the lowest ID takes over seamlessly.
Privacy guarantee: a11x is performatively not nosy. It hands out anonymous identities with time-limited TURN credentials and routes signalling messages between them. It never receives room identifiers, peer lists, or any data that could correlate which peers belong to the same room. TURN relays only DTLS-encrypted frames. Logs do not correlate client identities with IP addresses.
Try it¶
Create a room and share the link, or paste a host ID to join an existing room. The participant list shows each peer's connection type: green for a direct STUN path, orange for TURN relay.
The source is
js/demo/p2p_chat/.
1. Anonymous identity and signalling¶
Each peer independently claims an anonymous identity from a11x. The request carries no room identifier, no peer list — nothing that could tell a11x which room the peer intends to join or who else is in it:
The exchange generates a random identity, issues a short-lived claim (10-minute TTL) with TURN credentials, and returns a signalling URL. It does not track rooms, does not maintain peer lists, and does not log any correlation between the anonymous identity and the caller's IP address.
Room coordination is entirely client-side: the share URL encodes the host's
peer ID (?host=<peerId>), so a joiner knows who to WebRTC-connect to
without asking a11x.
Anonymous TURN access is limited to 10 minutes and, where the TURN server supports it, to 100 KiB/s bandwidth.
Keeping credentials current¶
The anonymous endpoint mints a new identity on every call, and the renewal
and ICE endpoints an account holder uses (/v1/identities/.../claim/renew,
/v1/ice-servers) need an API key. So an anonymous room has three separate
lifetimes to respect:
| What | Lives | What the demo does |
|---|---|---|
| Signalling connection | Past the claim, until it drops | Polls it and re-makes it with backoff |
| TURN credentials | 10 minutes, expiry inside username |
Replaces them before they lapse |
| The identity itself | As long as the tab holds its socket | Keeps it; the share URL stays valid |
Replacement credentials come from a throwaway claim, and only its
ice_servers are kept. TURN credentials are bearer credentials -- the relay
checks the HMAC in credential against the expiry in username and knows
nothing about a11x identities -- so they serve the identity already in use.
An endpoint holding lapsed credentials gathers no relay candidates at all,
which is why the room replaces them rather than waiting to find out.
A signalling connection outlives the claim that opened it for a while, and a new one under a lapsed claim is refused with HTTP 400. So an anonymous identity is reachable for about ten minutes. After that:
- Peers already connected keep talking. A WebRTC data channel needs no signalling once it is up.
- Nobody new can join, and the host says so rather than retrying a rejection for ever.
- A peer that needs a new connection claims a fresh identity and rejoins under it, recording the identity it left behind as departed.
Carrying a room past that window needs the peers to agree on new identities
before the old ones lapse -- a rekey event in the log, announced over the
connections that are still up. The demo does not do this.
2. The host as an A11 service¶
The room creator becomes the initial host. It runs an ActionRegistry with
four actions, each implemented as a handler that reads inputs and writes
outputs through AsyncNode:
| Action | Inputs | Outputs | Purpose |
|---|---|---|---|
send_message |
text (text/plain, unary) |
status (JSON, unary) |
Append a message to the event log |
set_name |
name (text/plain, unary) |
status (JSON, unary) |
Set the calling peer's display name |
replicate |
— | events (JSON, streaming) |
Stream the full log then live events |
get_peers |
— | peers (JSON, unary) |
List participants with names |
Each incoming peer gets a Session with StreamMode.ACCEPT. The host
creates the session, adds the stream, and the registry dispatches action
calls automatically.
The library provides the offer side of WebRTC through
WebRtcWireStream.createClient. The answer side is the demo's own
WebRtcServer, which wraps the peer's data channel as a ChannelWireStream
with ChannelEndpointRole.SERVER. That adapter reads a single data channel,
so the peer asks for one with desiredChannels: 1; the client default of 8
stripes packets round-robin across every open channel.
3. Calling actions from peers¶
A non-host peer connects to the host's signalling identity, establishes a
WebRTC data channel, and wraps it in a Session with StreamMode.START.
Sending a message is a standard A11 action call:
const action = registry.makeAction('send_message', {
nodeMap: session.getNodeMap(),
stream: webrtcStream,
session,
});
await action.call();
const input = await action.getInput('text');
await input.finalize('Hello, room!');
await action.waitForDispatch(10_000);
The peer registers the same schemas client-side (without handlers) so the session's node map and action framing work identically on both ends.
4. Event-log replication¶
The host maintains an ordered event log:
type RoomEvent =
| { type: 'join'; peerId: string; timestamp: number }
| { type: 'leave'; peerId: string; timestamp: number }
| { type: 'message'; peerId: string; text: string; timestamp: number }
| { type: 'name_change'; peerId: string; name: string; timestamp: number };
When a peer calls the replicate action, the host sends the full log
through the events output, then keeps the stream open and pushes new
events as they occur. Every peer holds a local copy sufficient to derive
the complete room state — participants, names, and messages. A joiner
receives the full history before any live events, so the chat scroll is
populated immediately.
The log is capped at 300 messages. Join, leave, and name-change events for active participants are always retained.
The replicate handler stays suspended for as long as the peer is
connected. An action's output ports close when its handler returns, so a
handler that pushes live values holds itself open — here on the peer's
departure and on action.signal.
5. Departures, liveness, and failover¶
A peer leaves in one of three ways, and the room converges on all of them within ten seconds:
| Departure | Signal | Measured in Chromium |
|---|---|---|
| Peer closes its tab | Channel close, seen by the host's adapter | ~100 ms |
| Host closes its tab | The channel's close event, confirmed by one ping |
~1.5 s to elect, ~2.5 s to a working room |
| Renderer crash or a network that stops answering | RTCPeerConnection reaching failed |
~7 s |
| Silent peer: no close, no traffic | messageTimeoutMs on the stream |
PEER_TIMEOUT_MS, 10 s |
pagehide fires on close, on navigation, and when the page enters the
bfcache, including on mobile Safari where beforeunload does not. The
handler calls ChatHost.shutdown or ChatPeer.disconnect, both of which
abort their sessions with a status. The abort marker travels on the channel
that is still open, so the other side ends the session, cancels the actions
in flight, and broadcasts the leave event.
Both ends race Session.done() against WireStream.wait(), so a channel
that dies without a marker removes the peer as soon as the stream reports
it, rather than on a session timeout.
Peers ping their host every three seconds with the __ping builtin, which
every ActionRegistry answers without a registration. The ping and its
answer are activity in both directions, keeping an idle room inside the
ten-second window; a ping that goes unanswered within that window counts as
the host being gone. A tab hidden long enough for the browser to throttle
its timers past ten seconds is dropped from the room, and reloading the page
rejoins from the share URL.
When the host goes, every peer records a leave for it, so it drops out of
the participant lists and out of the elections that follow. They then
compute the same new host: the peer with the lexicographically smallest ID
still present. No negotiation round is needed — every peer arrives at the
same answer from its own log.
After a 1-second convergence delay:
- The elected peer builds a new
ChatHostfrom its local event log and waits for its listener to bind before calling itself the host. - Other peers dial the elected peer, retrying with backoff for
RECONNECT_DEADLINE_MS, then callreplicateto resume receiving events. Their share URL follows the new host. - A candidate that never answers is recorded as departed too, and the election runs again over what is left. Peers apply that rule in the same order, so they converge on one host rather than several.
Replicated events carry an identity — type, peer, timestamp and payload — so the new host's log replays into a peer that already holds part of it without duplicating anything.
Reconnection is the same machinery everywhere: retry in
retry.ts
gives claiming an identity, binding a listener, and dialling a host one
policy, the one a11.client.hosting applies to a hosted agent.
6. Connection monitoring¶
Each peer polls RTCPeerConnection.getStats() every 3 seconds. The selected
candidate pair reveals the active ICE path:
| Candidate type | Classification | Badge |
|---|---|---|
host, srflx, prflx |
Direct (STUN) | ● green |
relay |
TURN relayed | ◆ orange + warning |
TURN usage is prominently displayed with an orange badge and a "TURN relayed" label. The badge updates dynamically as ICE candidates change — if a peer re-signals and switches from relay to direct, the badge turns green.
7. Privacy model¶
The demo is designed so that a11x is structurally unable to correlate peers into rooms:
| What a11x sees | What a11x does NOT see |
|---|---|
| Anonymous identity claims | Room identifiers |
| Signalling messages between pairs of identities | Which identities form a room |
| TURN relay traffic (DTLS-encrypted) | Message content, names, or chat history |
The POST /v1/anonymous/claim request body is empty — no room ID, no peer
list, no metadata. Peers discover each other through the share URL
(?host=<peerId>) which never touches a11x. TURN only relays DTLS-encrypted
frames; the relay server cannot inspect the plaintext.