WebRTC rooms
battery/rtc is a signaling server for browser-to-browser
WebRTC: rooms, peers, and an addressed relay for SDP offers, answers, and
ICE candidates, carried over a core/stream.StateChannel WebSocket. It
also hands each peer its ICE server list, minting per-peer time-limited
TURN credentials when you configure a TURN server. Media never crosses
the Go process: audio and video flow peer to peer between browsers.
The browser half is the rtc runtime module,
__gofastr.connectRoom(url, opts), which runs one RTCPeerConnection
per remote peer with perfect negotiation and reconnects with the same
generation model as __gofastr.connectWebSocket.
It is a battery: construct it with rtc.New, register it with
app.RegisterPlugin, and it mounts its endpoint, takes the app's fanout,
and closes with the app.
What this is not
- No server-side media. No SFU, no MCU, no forwarding, no recording.
Everyone connects to everyone: a full mesh.
MaxPeersdefaults to 8 because mesh cost is O(n²) connections; an SFU is a different product. - No SDP parsing. The server relays signaling JSON between peers. It never reads, stores, or logs an SDP body or an ICE candidate.
- No client identity. A browser names nothing about itself; see the security model below.
A media server, if you ever want one, would be another peer that speaks this same protocol.
The security model (read this first)
Identity is server-derived. Never client-supplied.
Every peer's room, role, id, user, and display name come from the
rtc.Join your Config.Authorize returns. Authorize reads cookies,
sessions, or handler.GetUser(r.Context()), whatever you already trust,
and its return value is trusted as-is. A client can trigger a join; it
cannot shape one. There is no ?user= or ?name= the wire accepts.
Join.Room is an authorization decision, not a label: an Authorize
that copies ?room= lets any signed-in user name any room, so check
membership there when rooms are private. Join.PeerID left empty
mints an unguessable id per socket, which is right for a user with
several tabs; a signaling reconnect then lands under a new id, and the
browser module rebuilds every connection (each remote saw the old id
leave). Set a stable per-tab PeerID only when you can mint one the
client cannot forge, and a reconnect becomes a rejoin that keeps its
connected peer connections.
A joined socket keeps checking its principal. Before acting on each
inbound frame, on an idle ticker (30 seconds), and before each TURN
refresh, the socket re-establishes its principal
(handler.RecheckPrincipal) and, under ServeHTTP, runs Authorize
again on the refreshed request. A refusal, or an Authorize that now
names another room, closes the socket with code 4401, so a signed-out or
revoked user stops signaling, stops receiving room events and gets no
fresh TURN credential.
Refusals reach the browser. An error from Authorize, a bad
Join, a full room, or a closed signaler keeps its HTTP status for a
plain request (curl, a Go peer). A WebSocket upgrade request is
accepted and closed with code 4000+status (4409 room full, 4403
forbidden, the status of an *rtc.HTTPError), because a browser
cannot read the status of a failed handshake: the WHATWG spec withholds
it so a page cannot probe the network. The ws runtime module classes
a 4xx code (4400 to 4499) as refused, keeps the status on
status.refused, and does not reconnect, so a signed-out or over-cap
tab shows a message instead of re-dialing every 30 s for ever. A 5xx
code (an *rtc.HTTPError with status 503, "not now") is an ordinary
transport error and is retried with the usual backoff.
The TURN secret never leaves the server. With
Config.TURN set, the package mints credentials in the coturn
static-auth-secret convention (the TURN REST API): username is
"<unix-expiry>:<peer id>", credential is
base64(HMAC-SHA1(secret, username)). The secret stays in the Go
process. Each peer receives only its own credential, inside its own
snapshot; peer A's credential is never marshaled for anyone but A.
TURN.TTL bounds the lifetime (default 1h, minimum 1m), so a leaked
credential expires on its own. Expiry does not disturb a relay
allocation the peer already holds; it refuses new ones, and an ICE
restart after a network change needs new ones. So every half TTL the
server pushes a freshly minted credential to each peer (the
iceServers event) and the browser module applies it to its live
connections with setConfiguration, so a call that outlives the TTL
still recovers from a network change.
Signals are relayed, not parsed. The data of a signal frame is
size-checked (MaxSignalBytes, default 48 KiB; a one-camera offer is
3 to 8 KiB, a many-codec simulcast offer 15 to 25 KiB) and forwarded to the
named recipient only. The server never decodes SDP or candidates.
Sockets are bounded. Inbound frames are rate-limited
(MaxFramesPerSecond, default 128, token bucket); over the limit the
socket closes. Ping and Pong frames never reach that limit, since the
transport answers them; WS.MaxControlFramesPerSecond (default 32)
caps them and closes with 1008. Outbound, a peer whose send buffer overflows
(WS.SendBuffer, default 128 frames) is closed rather than starved:
signals are not in the snapshot, so a dropped answer could never be
recovered, while a closed socket reconnects, re-hydrates, and the far
side renegotiates. The same rule covers the room's own event queue: it
holds up to 4096 pending events (the defaults put at most 2048 inbound
frames in flight at once, so only a stalled server reaches it), and an
event it cannot take closes every socket that would have received it
instead of vanishing while they stay open. A signal's to must be a
current member of the room and not the sender. A status document must be a JSON object within
MaxStatusBytes (default 1 KiB). Rooms cap at MaxPeers members
(default 8): a join past the cap is refused with 409 before the upgrade,
and the cap is checked again under the lock that registers the peer, so
two joins racing for the last seat end with one member and one closed
socket, never an oversize room.
One principal holds a bounded number of sockets.
MaxSocketsPerUser (default 16, the seat parity of the SSE broker
and the CRUD event stream) caps concurrent signaling sockets per
Join.User across every room. A principal's next socket past the
cap displaces their oldest one, mirroring rtc's own
replace-on-rejoin behaviour, and a peer's departure frees its seat.
A displaced local socket does not announce a departure while another
replica still holds that peer ID. If the surviving remote seat is newer,
local observers receive its reconnect join instead.
A negative value is refused at New.
A join with no user is refused by default. When Authorize returns
a Join with an empty User, the signaler answers 403
(rtc.user_required) before the upgrade. There is no per-user seat to
charge such a socket to, and a shared bucket let one anonymous caller
evict every other one. An app whose callers really are anonymous (a
lobby display name, no account) opts in with
AnonymousSeats: rtc.AnonymousPerSocket: each anonymous socket then
holds its own seat, outside the per-user cap, and is never displaced by
another. MaxPeers still bounds each room. examples/rtc-call runs
this way.
The frame envelopes themselves decode strictly: duplicate or
case-folded keys in a signaling frame are refused, the same
no-ambiguity rule every client-borne JSON in the framework follows.
The data payload stays opaque as before.
Nothing sensitive is logged. Joins and leaves log at Debug, refusals at Warn with a bounded reason class. Never logged: SDP, candidates, status bodies, credentials, close reasons.
Wiring
import ( "net/http" "os" "github.com/DonaldMurillo/gofastr/battery/auth" "github.com/DonaldMurillo/gofastr/battery/rtc" "github.com/DonaldMurillo/gofastr/core/handler" "github.com/DonaldMurillo/gofastr/core/i18n" ) var signInFirst = i18n.Define("call.sign_in_first", "sign in first") sig := rtc.New(rtc.Config{ ICEServers: []rtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, TURN: &rtc.TURN{URLs: []string{"turn:turn.example.com:3478"}, Secret: os.Getenv("TURN_SECRET")}, Authorize: func(r *http.Request) (rtc.Join, error) { u, ok := handler.GetUser(r.Context()) if !ok { return rtc.Join{}, &rtc.HTTPError{Status: http.StatusUnauthorized, Reason: signInFirst} } user := u.(auth.User) return rtc.Join{Room: r.URL.Query().Get("room"), User: user.GetID(), DisplayName: user.GetEmail()}, nil }, }) app.RegisterPlugin(sig) // GET /__gofastr/rtc?room=…; Init mounts it, wires the app's fanout, closes it on stop
rtc.New panics on an invalid Config with a message prefixed rtc:,
the same posture as relay.New: a bad ICE URL scheme, a negative
limit, or a payload cap the socket cannot carry is a construction-time
programmer error, not a runtime
condition to limp along with.
Authorize is required for Init, Mount, and ServeHTTP (a mounted
endpoint with no gate is a bug, so Mount panics on a nil hook). A
returned error refuses the upgrade with 403, or the status carried by a
*rtc.HTTPError (401, 404, 410 …). Apps that own their route call
sig.Serve(w, r, join) with an already-trusted Join and may leave
Authorize nil; sig.Mount(router) registers the endpoint without the
plugin lifecycle.
rtc.Join fields: Room (required, 1-128 bytes printable ASCII, no
whitespace), Role and User and DisplayName (optional labels the
room sees), PeerID (optional stable id; empty means the server mints
16 hex bytes per socket). Reconnecting with a PeerID that already
exists in the room closes the old socket and replaces it, so a
reconnecting tab reclaims its seat.
The Go API beyond Config
sig.Peers(room) // merged local + live-remote roster, sorted by Order then ID sig.Rooms() // rooms with at least one local member sig.Kick(room, peerID) // close one local socket (a nudge, not a ban: see below); reports whether found on this replica sig.Close() // stop everything; safe to call twice sig.SetFanout(f) // wired for you by Init (or App.Mount) under WithFanout rtc.TURN.Credentials(peerID, now) // the per-peer ICEServer entry, exported for non-browser peers
The Permissions-Policy helper
The framework's default headers close camera=() and microphone=(),
so getUserMedia fails until you open them. rtc.PermissionsPolicy
writes every feature it knows, closed unless named, and opens the
named ones to your own origin:
// "geolocation=(), microphone=(self), camera=(self), display-capture=()" middleware.SecurityHeadersConfig{PermissionsPolicy: rtc.PermissionsPolicy(rtc.Camera, rtc.Microphone)}
An omitted directive is not a closed one: the default allowlist for
camera, microphone, and display-capture is self, so a header
that only listed what it opened would open what it left out. That is
why the helper always writes all four. Open the least the feature
needs: a listen-only viewer surface names rtc.Microphone alone, and
a page that shares a screen names rtc.DisplayCapture, without which
getDisplayMedia is refused.
The wire protocol
Server to client, every message is {"type":T,"sequence":N,"payload":P}
(a StateChannel envelope):
| type | payload | delivered to | ||
|---|---|---|---|---|
snapshot | {"room":"r","self":PeerInfo,"peers":[PeerInfo…],"iceServers":[ICEServer…]} | the connecting peer, once per connect | ||
join | PeerInfo | every peer except the subject | ||
leave | {"id":"p"} | every peer except the subject | ||
status | {"id":"p","status":{…}} | every peer except the subject | ||
signal | `{"from":"p1","to":"p2","type":"offer"\ | "answer"\ | "ice","data":{…}}` | to only |
iceServers | {"iceServers":[ICEServer…]} | every peer, each with its own fresh TURN credential, every half TURN.TTL while TURN is configured |
PeerInfo is {id, role, user, name, order, status}. order is the
join instant in Unix nanoseconds.
Client to server:
| kind | shape | rule |
|---|---|---|
signal | {"kind":"signal","to":"p2","type":"offer","data":{"sdp":…}} or {"type":"ice","data":{"candidate":…}} | to is a current member, not self; data ≤ MaxSignalBytes; the server never parses data |
status | {"kind":"status","data":{…}} | JSON object ≤ MaxStatusBytes; stored on the peer and broadcast |
A refused join on an upgrade request is a completed handshake followed by a close frame with code 4000+status and a short reason; see "Refusals reach the browser" above.
Politeness (both sides derive it, it is never sent): for the pair
(A, B), the peer with the greater order is polite; on a tie, the
greater id (string compare) is polite. The polite peer yields in an SDP
collision, exactly as the W3C perfect-negotiation pattern defines.
The browser module
await __gofastr.loadModule('rtc'); const room = __gofastr.connectRoom('/__gofastr/rtc?room=standup', { channels: ['chat'], onTrack(peer, ev) { videoFor(peer.id).srcObject = ev.streams[0]; }, onMessage(peer, ch, ev) { appendChat(peer.name, ev.data); }, onPeerLeave(peer) { videoFor(peer.id).remove(); }, }); const media = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); media.getTracks().forEach((t) => room.addTrack(t, media)); room.send('chat', 'hello');
The module is demand-loaded, has no DOM marker, and loads ws itself.
The browser APIs it relies on (implicit rollback, argument-free
setLocalDescription, negotiated channels, restartIce,
setConfiguration) are in current Chrome, Safari, and both Firefox
ESR lines; this repo's suites drive Chromium only.
It runs one RTCPeerConnection per remote peer with perfect
negotiation (the polite/impolite rule above), trickle ICE, and
negotiated data channels: each entry in channels becomes a channel
with negotiated: true and id = its index, so both sides create it and
no ondatachannel is needed. A string entry is a reliable, ordered
channel by that label. An object entry {label, ...RTCDataChannelInit}
sets the rest: {label: 'pose', ordered: false, maxRetransmits: 0} is
the shape for telemetry that must never queue behind a stale frame
(check channel.bufferedAmount before each send and drop the frame
when it is backed up; the module does not).
Declare at least one data channel even if the product is video only.
The first negotiation of a pair happens when one side has something to
negotiate; with a channel, that is the moment the older peer sees the
join, so a track added later renegotiates at once. With no channel
and no track on the older side, the newer peer waits out a 1500 ms
fallback before it offers.
The hooks:
| Hook | Fires when |
|---|---|
onSnapshot({room, self, peers}) | after each hydration |
onPeer(peer) / onPeerLeave(peer) | a remote peer appears / is gone |
onTrack(peer, ev) | an RTCTrackEvent on a peer's connection |
onChannel(peer, ch) / onMessage(peer, ch, ev) | a data channel opened / received on one |
onPeerState(peer, state) | the peer's connectionState changed |
onStatus(peer, status) | the peer's status document changed |
onPhase(phase) | connecting → open → hydrated → closed:<class>, the same classes as the ws module; closed:refused is final, with the status on room.status.refused |
A peer object is {id, role, name, user, order, status, pc, polite,
channels, state}. The room handle adds:
| Member | What it does |
|---|---|
room.self, room.peers | your PeerInfo (null before hydration), a Map of peers |
room.status | {generation, phase, peers, refused}, the live status object |
room.addTrack(track, stream) | add to every current and future peer connection |
room.removeTrack(track) | remove from every peer connection |
room.replaceTrack(oldTrack, newTrack) | swap a sender's track on every connection with no renegotiation (camera switch, screen share); future connections get the new one; null stops sending and forgets the track |
room.send(label, data) | to every open channel with that label; strings and binary (ArrayBuffer, a typed array, Blob) pass through, anything else is JSON-encoded; returns the count sent. Channels are pinned to binaryType = 'arraybuffer', so onMessage sees one shape on every browser |
room.setStatus(obj) | publish your status; room.self.status updates at once, and it is resent after each reconnect |
room.close() | leave |
Reconnects. The signaling WebSocket reconnects with a new
generation, like every ws consumer. On the snapshot after a
reconnect, peers that are gone close (onPeerLeave), peers still
connected or connecting with stable signaling keep their peer connection (no
renegotiation storm), anything else is torn down and rebuilt with the
tracks and channels re-added, and the remembered status is resent.
An unanswered renegotiation offer therefore cannot pin an impolite peer
on a connection that would ignore the remote peer's replacement offer.
The other side saw you leave and rejoin and rebuilt its connection, so its
next offer restarts ICE and DTLS on your kept one; media carries on,
but the old data channel objects close with the old transport, and the
module recreates them on the live connection. A status a peer already
carried when you met it (in a snapshot or a join) arrives through
onStatus too, so a late joiner or a reloaded page sees "muted"
without waiting for the next toggle. The ICE list a snapshot or an
iceServers event carries is applied to every live connection, so a
kept connection restarts ICE with a credential that has not expired.
The module never logs; no SDP,
candidate, credential, or close reason reaches the console or a
status object.
Collisions. Both sides can offer at once. The newer peer of a pair is polite and does not make the first offer at all; it waits for the older peer, with a 1500 ms fallback that fires only while nothing has arrived. If two first offers still cross, the polite side discards its never-negotiated connection and answers from a fresh one rather than rolling back: a first-offer rollback leaves Chrome offering forever without the data channel. Mid-call collisions roll back as the W3C pattern says. More than twelve offers in five seconds on one connection is treated as a loop and the connection is rebuilt.
Multiple replicas
With framework.WithFanout attached (wired automatically by
App.Mount), rooms span replicas on the gofastr.rtc fanout topic,
the same lossy self-healing model presence uses: each replica
broadcasts its full local roster per room on every join and leave and
on a 15 s heartbeat, and mirrors a status change as one event (the
heartbeat is the backstop for a mirror a replica missed, and a beat
that carries a changed status announces it); receivers
keep a TTL'd (45 s) per-(replica, room) table capped at 512 replicas
per room. A status reaches local peers only from the replica whose
copy of the peer is newest, so one from a replica the peer has left is
dropped. A beat naming more than MaxPeers members is dropped whole.
Two replicas can each admit a join before their beats meet, so a beat
can bring the merged room past MaxPeers; every replica then ranks the
merged room by join order and closes its own peers past the cap with
4409 room full, so the latest seats lose. A beat cannot restate a peer this replica holds (local wins on the
join mirror as on the leave mirror). A join mirror for a peer this
replica holds displaces the local socket only when the mirror is newer
than that socket (every lane message carries the sender's clock): one
that arrives late, after the peer already reconnected here, is the old
seat announcing itself and is dropped, so a bus delivering one
replica's lane behind a client's reconnect cannot kick the live socket.
Replica clocks need to agree to within that reconnect time, the usual
NTP posture; a sender whose clock lags by more than that has its genuine
move-away mirror dropped too, so the peer's old socket here stays in the
roster until it closes, and that close publishes no leave because the
remote seat holds the id. A local socket that closes while a live remote seat holds
the same id publishes no leave: the merged roster never lost the peer.
When that remote seat is newer than the closed socket (the peer
reconnected on another replica and its roster beat arrived while the
old socket still held the id here), the close announces the newer seat
as a join instead, so local peers reconnect to it.
Local sockets hear what changed in the merged remote view, never the raw
lane message: a join goes out once per seat (a roster beat that lists a
peer before its own join mirror arrives leaves the mirror nothing to
say), and again only when the peer reconnects with the same id on
another replica, which the module handles as a reconnect. A leave goes
out only when no replica holds the id any more, so a crashed replica's
expiry, or an old seat's leave mirror, says nothing about a peer that
reconnected elsewhere with a stable PeerID. When replicas disagree on
an id, the latest seat stands for it. A peer that moves to another
replica frees its seat on this one, so it never counts against
MaxSocketsPerUser here. A room's local state is dropped
RoomIdleTTL (default 1m) after its last local peer leaves, on its own
timer, whether the last peer left or migrated to another replica; what
other replicas hold for that name stays under the remote TTL. Signals
addressed to a peer on another replica are published on the fanout and
delivered by the owning replica. A signal the lane loses, because the
backend refused or timed out the publish or the publish queue dropped
it on overflow, closes the sending peer's socket, as a local overflow
does: the client reconnects and renegotiates instead of waiting on an
answer that never comes. The receiving side gets the same treatment:
each socket numbers the signals it sends to each remote peer, and when
the owning replica sees a number skipped (its fanout subscriber queue
dropped one on overflow), it closes the target's socket. A loss is seen
when the next signal of the pair arrives. A crashed replica's peers vanish from
rosters within the TTL. Close stops the heartbeat, waits for a
roster already being published, then publishes empty rosters as the
replica's last messages, so a rolling restart converges promptly. Without a fanout, nothing runs and
behaviour is byte-for-byte the single-replica result.
Deployment
- HTTPS/WSS.
getUserMediais a secure-context API. Browsers refuse it on plain HTTP except localhost. - STUN gets you most direct connections: one public STUN server in
Config.ICEServersis the minimum. - TURN relays for peers behind symmetric NAT or hard firewalls.
Run coturn with
use-auth-secretandstatic-auth-secret=<secret>(the first turns the REST-API credential mode on, the second supplies the value; without the flag every minted credential is refused) and pass the same secret inConfig.TURN.Secret; the package mints the per-peer credentials coturn expects. A TURN server relays media: it costs bandwidth, and it is the only place your media touches a server you run, but the Go process still never sees it. - Mesh limits.
MaxPeers(default 8) caps a room. Video in a full mesh means each browser uploads one stream per remote peer; past a handful of participants you want an SFU, which this package is not.
Knowing whether a call went through TURN
The server never sees media, so it cannot tell a direct path from a
relayed one. The browser can: pc.getStats() reports the selected
candidate pair, and a relay candidate on either end means TURN. Put
that on the status document and it reaches the other peers and the
server's roster:
async function pathOf(pc) { const report = await pc.getStats(); let pair; report.forEach((s) => { if (s.type === 'transport' && s.selectedCandidatePairId) pair = report.get(s.selectedCandidatePairId); }); // Firefox before 153 (both live ESR lines) has no 'transport' stat; // the selected pair is flagged on the pair itself there. if (!pair) report.forEach((s) => { if (s.type === 'candidate-pair' && (s.selected || (s.nominated && s.state === 'succeeded'))) pair = s; }); if (!pair) return null; const local = report.get(pair.localCandidateId), remote = report.get(pair.remoteCandidateId); return { relay: local?.candidateType === 'relay' || remote?.candidateType === 'relay' }; } // in onPeerState, when state === 'connected': pathOf(peer.pc).then((p) => p && room.setStatus({ path: p }));
Server side, sig.Peers(room) returns each member's current status,
so an MCP tool or a support console can answer "is the operator on a
relay" from backend state without the browser being asked. It is the
browser's own report, not something the server verified: fine for a
support view, never an input to an authorization or billing decision.
Keep the document to allow-listed values; it is a status, not a log.
See also
- Reactivity model places WebSockets and signaling in the delivery ladder.
- Core packages documents
core/stream.StateChannel, the transport under this package. - Horizontal scaling covers the fanout seam.
examples/webmcp-remote-assistis the hand-rolled single-session reference this package generalizes.
Common mistakes
- Leaving
Authorizefor later.Mountpanics without it, on purpose: an ungated signaling endpoint lets any visitor join any room name and spend your TURN relay budget. - Forgetting the Permissions-Policy. The default headers close
camera and microphone;
getUserMediafails with a console error nobody connects to the header. Usertc.PermissionsPolicyand open only what the page needs. - Saying nothing when the camera is refused. A denied permission,
a machine with no camera, or a device another app holds all reject
getUserMedia, and a page that only records the error name leaves the user with a button that did nothing. Render a notice the script fills from the rejection (thertc-callexample's#call-notice), and do the same forclosed:refused. - Expecting the server to see media. It cannot help with quality,
record a call, or mute a participant's audio; those are peer-side
(
room.removeTrack) or TURN-side concerns. - Assuming remote audio autoplays. A remote stream with an audio
track plays only after a user gesture on that page; a viewer who
never clicked gets a frozen first frame and a rejected
play(). Callvideo.play()from a click handler, or mute the element when the product is video only (iOS Safari was where this bit first). - Treating
Kickas a ban. It closes one socket on this replica. The browser module reconnects with backoff, so a peer that must stay out is refused byAuthorizeon that reconnect, and the TURN credential it already holds keeps working untilTURN.TTLruns out; set the TTL with that window in mind. - Reading status documents as private.
setStatusis delivered to every peer in the room and stored on the roster the server hands out. It is the right place for "muted", "sharing screen", or "on a relay", and the wrong place for anything one role must not see; that state belongs on your ownStateChannelwith its ownFilterEvent. - Raising
MaxSignalBytespast the socket. The socket read limit is 64 KiB at most, andNewrefuses a payload cap that would not fit it plus a kilobyte of envelope, because the frame would be cut off before the cap was ever consulted. The default already leaves headroom over the largest offers browsers produce. - Putting 30 people in a room. Mesh cost is O(n²) connections and each browser uploads one stream per peer. Keep rooms small; split viewers into listen-only rooms or reach for an SFU.