oficina / meet Public

room.js: the browser client for the call

The SSE client, the per-target outbound queue with seq and gen,
perfect negotiation, roster reconciliation, resume through the probe,
the terminal states, the admission queue and the tiles. No library.

Deviations from the brief and the handoffs, with reasons:

- Step 2 (the probe branch in stream.go) was already done by Task 4;
  nothing here touches stream.go, signal.go, the hub, handlers,
  devices.js or join.html.
- The brief's target was 600 to 800 lines; this is ~1220, of which
  ~200 are comment and ~110 blank. The extra is guard clauses and
  the status ladder, not extra behaviour.
- window.meet.state().peers returns an array of peer ids rather than
  Object.keys(pcs): pcs is a Map, so the ids are the same and the
  shape the Playwright gate reads is unchanged.
- closePC takes a second argument saying whether to forget the peer's
  seq counters. A generation rebuild keeps them (spec 4.6 says they
  never reset); a departure forgets them, so the resume cursor does
  not grow with every peer that has ever been in the room.
- `full` is retried: a member who sees it waits five seconds and posts
  join again, per 4.3's "can wait and retry", because there is
  otherwise no way out of that sentence but a reload.
- A browser that joined with no local media adds recvonly
  transceivers, so negotiationneeded fires and the pair still
  connects; without a track of its own nothing would ever offer.
- The mute and camera toggles keep one fixed accessible name and move
  only aria-pressed (Task 7 review addendum). Since that leaves no
  changing text to read the state from, meet.css gives a pressed
  toggle a heavier label inside an inset outline - a cue that is not
  colour, which is what AGENTS.md's rule is protecting.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev 9911b2c507eef1cc3d73ad4f1c3bfcb31e0f5872 parent 8cafec1
3 files changed, +1310 −3
  • internal/meet/static/meet.css +58 −0
  • internal/meet/static/room.js +1218 −3
  • internal/meettest/room_test.go +34 −0

CI passed — run details

diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css
index e0288bb..aa5160c 100644
--- a/internal/meet/static/meet.css
+++ b/internal/meet/static/meet.css
@@ -93,3 +93,61 @@
color: var(--rst-text-muted);
min-height: 1.2em;
}
+
+/* The tile caption carries the name, and, when the call to that peer
+ has failed, the sentence and the retry beside it — plus the removal
+ control a member gets. It is one flex row so those never stack
+ oddly on a narrow tile. */
+
+.meet-tile-name {
+ display: flex;
+ align-items: center;
+ gap: var(--rst-sp-2);
+ max-inline-size: calc(100% - 2 * var(--rst-sp-2));
+}
+
+.meet-tile-name > span:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.meet-tile-name button,
+.meet-tile-name a {
+ font: inherit;
+ color: var(--rst-text);
+ background: none;
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ padding: 0 var(--rst-sp-1);
+ cursor: pointer;
+}
+
+/* Never colour alone: a failed connection says so in words, and the
+ weight is only a second cue on top of the sentence. */
+.meet-tile-note {
+ color: var(--rst-text);
+ font-weight: 600;
+}
+
+.meet-tile-self video {
+ /* A self view reads as a mirror; every other tile does not. */
+ transform: scaleX(-1);
+}
+
+/* The toggles keep one accessible name and move aria-pressed, so the
+ engaged state needs a cue that is not colour: a heavier label inside
+ an inset outline. */
+.meet-controls button[aria-pressed='true'] {
+ font-weight: 700;
+ outline: 2px solid var(--rst-text);
+ outline-offset: -3px;
+}
+
+.meet-queue li button {
+ flex: none;
+}
+
+.meet-status a {
+ margin-inline-start: var(--rst-sp-1);
+}
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
index 03dd93e..8cb0f70 100644
--- a/internal/meet/static/room.js
+++ b/internal/meet/static/room.js
@@ -1,9 +1,1224 @@
-// room.js: placeholder. Task 8 replaces this with the real signalling,
-// resume and mesh logic; for now it only reveals the scripted room so a
-// browser with script sees more than the scriptless fallback.
+// room.js: the browser half of a call.
+//
+// Spec sections this implements, and the order they appear below:
+//
+// §4.1 transport one SSE stream down, POST /signal up
+// §4.2 peer identity the server issues `peer`; one peer per session
+// per room, and nothing here ever displaces
+// another tab automatically
+// §4.3 messages the event table, including the roster as the
+// authority and the admission queue
+// §4.5 resume the probe, the replay, the four refusals and
+// the terminal reasons
+// §4.6 delivery per-target outbound queue with `seq`, pair
+// generations, inbound dedupe, candidate buffer
+// §5.1 negotiation the W3C perfect-negotiation pattern
+// §5.5 the floor no ICE servers in this slice; host candidates
+// only (slice 3 adds GET /ice)
+//
+// House rules this file is written under: no library, no inline script
+// (the page's CSP is `default-src 'self'`), nothing built with
+// innerHTML, and every string a peer or the server sends is written
+// with textContent. `data-is-member` on #room is a rendering hint only
+// — the server re-derives authority on every request, and the roster
+// it sends is what this file actually believes.
(() => {
'use strict';
+
const root = document.getElementById('room');
if (!root) return;
+ // Revealing the room is the first job: the server renders it hidden
+ // so a scriptless browser sees the fallback paragraph instead.
root.hidden = false;
+
+ const sqid = root.dataset.sqid || '';
+ const selfName = root.dataset.selfName || '';
+ const memberHint = root.dataset.isMember === '1';
+
+ const $ = (id) => document.getElementById(id);
+ const tilesEl = $('tiles');
+ const queueEl = $('queue');
+ const knocksEl = $('knocks');
+ const statusEl = $('status');
+ const muteButton = $('mute');
+ const cameraButton = $('camera');
+ const leaveButton = $('leave');
+
+ const DEVICE_KEY = 'meet.devices';
+ const BACKOFF_MIN = 500;
+ const BACKOFF_MAX = 30000;
+ // §4.6: a target whose POSTs have failed for this long is treated as
+ // unreachable — the pair is reset and the connection rebuilt.
+ const SEND_DEADLINE = 30000;
+ const MAX_BUFFERED_CANDIDATES = 256;
+ // §4.3: `full` is not a refusal, it is "not yet" — a member waits and
+ // retries the join on this interval.
+ const FULL_RETRY = 5000;
+ const HAS_SINK_ID = typeof HTMLMediaElement !== 'undefined' &&
+ typeof HTMLMediaElement.prototype.setSinkId === 'function';
+
+ // ---------------------------------------------------------------
+ // State (§4.2, §4.6)
+ // ---------------------------------------------------------------
+
+ const state = {
+ peer: null, // this stream's server-issued peer id
+ boot: root.dataset.boot || '',
+ admitted: false, // a roster has arrived, so we are in the mesh
+ joined: false, // `join` has been accepted at least once
+ full: false, // the last `full` event, cleared by a roster
+ stopped: false, // terminal, or waiting on a takeover: no reconnect
+ leaveSent: false, // §4.3: `leave` is not idempotent
+ kind: memberHint ? 'member' : 'guest',
+ };
+
+ const pcs = new Map(); // peer id -> connection record
+ const gen = new Map(); // peer id -> pair generation (§4.6)
+ const seqOut = new Map(); // peer id -> last seq we sent (never reset)
+ const lastIn = new Map(); // peer id -> highest seq received (resume cursor)
+ const queue = new Map(); // peer id -> [{seq, body}] outbound, in order
+ const inflight = new Set();// peer ids with a POST in flight
+ const names = new Map(); // peer id -> display name, from the server only
+ const frames = new Map(); // peer id -> frames rendered, for the test hook
+ const tiles = new Map(); // tile key -> {figure, video}
+
+ let waiting = []; // the admission queue, members only (§4.3)
+ let lastRoster = null;
+ let local = null; // the local MediaStream, or null
+ let prefs = loadPrefs();
+ let es = null; // the live EventSource
+ let backoff = BACKOFF_MIN;
+ let reconnectTimer = 0;
+ let statsTimer = 0;
+ let fullTimer = 0;
+
+ // ---------------------------------------------------------------
+ // Preferences, stored by the holding screen (devices.js)
+ // ---------------------------------------------------------------
+
+ function loadPrefs() {
+ try {
+ return JSON.parse(localStorage.getItem(DEVICE_KEY) || '{}') || {};
+ } catch {
+ return {};
+ }
+ }
+
+ function savePrefs() {
+ try {
+ localStorage.setItem(DEVICE_KEY, JSON.stringify(prefs));
+ } catch {
+ // Private browsing or a full quota: the toggle still works for
+ // this call, it just will not be remembered for the next one.
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Local media (§3.3's stored choices, §5.1's tracks)
+ // ---------------------------------------------------------------
+
+ async function startLocal() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
+ const exact = (id) => ({ deviceId: { exact: id } });
+ const attempts = [
+ { audio: prefs.mic ? exact(prefs.mic) : true, video: prefs.camera ? exact(prefs.camera) : true },
+ { audio: true, video: true },
+ ];
+ for (const constraints of attempts) {
+ try {
+ local = await navigator.mediaDevices.getUserMedia(constraints);
+ break;
+ } catch {
+ // A remembered device may be gone; the second attempt asks for
+ // whatever the browser calls the default. If that fails too we
+ // join with nothing, which the room is built to allow.
+ }
+ }
+ applyToggles();
+ }
+
+ function applyToggles() {
+ if (local) {
+ for (const track of local.getAudioTracks()) track.enabled = !prefs.muted;
+ for (const track of local.getVideoTracks()) track.enabled = !prefs.cameraOff;
+ }
+ // The labels are fixed and the server renders them; only
+ // aria-pressed moves. A toggle whose name changes under the reader
+ // is announced as a different control every time it is used.
+ muteButton.setAttribute('aria-pressed', String(!!prefs.muted));
+ cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff));
+ }
+
+ function stopLocal() {
+ if (!local) return;
+ for (const track of local.getTracks()) track.stop();
+ local = null;
+ }
+
+ // ---------------------------------------------------------------
+ // The stream (§4.1, §4.5)
+ // ---------------------------------------------------------------
+
+ const DOWNSTREAM = ['hello', 'roster', 'peer', 'gone', 'knock', 'knock-gone',
+ 'full', 'lost', 'pair', 'gap', 'state', 'offer', 'answer', 'candidate'];
+
+ function streamPath(peer) {
+ let path = '/m/' + encodeURIComponent(sqid) + '/stream';
+ if (!peer) return path;
+ const last = {};
+ for (const [id, seq] of lastIn) last[id] = seq;
+ return path + '?peer=' + encodeURIComponent(peer) +
+ '&last=' + encodeURIComponent(JSON.stringify(last));
+ }
+
+ function closeStream() {
+ if (!es) return;
+ // Always close by hand. EventSource reconnects on its own, and its
+ // own retry of a fresh (peerless) URL would ask for a second
+ // participation and be refused 409 — reconnection is this file's
+ // job, not the browser's.
+ es.onerror = null;
+ es.close();
+ es = null;
+ }
+
+ // connectStream opens a fresh stream: no `peer`, so the hub issues a
+ // new participation and this browser posts `join` on its `hello`.
+ function connectStream() {
+ if (state.stopped) return;
+ closeStream();
+ state.peer = null;
+ state.joined = false;
+ state.admitted = false;
+ renderStatus();
+ attach(streamPath(null), () => diagnoseFresh());
+ }
+
+ // resume reattaches to the participation we already hold. EventSource
+ // hides the status of a refused stream, so the refusal is read first
+ // with a plain fetch against ?probe=1 — which attaches nothing and,
+ // per the wire contract, must NOT carry Accept: text/event-stream.
+ async function resume() {
+ if (state.stopped) return;
+ closeStream();
+ const peer = state.peer;
+ if (!peer) {
+ connectStream();
+ return;
+ }
+ let res;
+ try {
+ res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer));
+ } catch {
+ later(resume);
+ return;
+ }
+ if (state.stopped) return;
+ if (res.status === 204) {
+ attach(streamPath(peer), () => later(resume));
+ return;
+ }
+ if (res.status === 410) {
+ terminal(res.data.reason || 'ended');
+ return;
+ }
+ if (res.status === 404) {
+ // Either the process restarted (a different boot) or the window
+ // lapsed (the same one). Both answers are the same: drop every
+ // connection and enter afresh — but only after the local
+ // connections are closed, so no media survives a restart.
+ closeAllPCs();
+ state.boot = res.data.boot || state.boot;
+ connectStream();
+ return;
+ }
+ if (res.status === 409) {
+ takeoverNotice();
+ return;
+ }
+ later(resume);
+ }
+
+ // diagnoseFresh runs when a fresh stream failed to open. The peerless
+ // probe says whether this session already holds a peer here (409) or
+ // whether the room is gone (404); a 204 means the refusal was
+ // something the probe cannot see — a full room, or the network.
+ async function diagnoseFresh() {
+ if (state.stopped) return;
+ let res;
+ try {
+ res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1');
+ } catch {
+ later(connectStream);
+ return;
+ }
+ if (state.stopped) return;
+ if (res.status === 409) {
+ takeoverNotice();
+ return;
+ }
+ if (res.status === 410) {
+ terminal(res.data.reason || 'ended');
+ return;
+ }
+ if (res.status === 404) {
+ terminal('gone');
+ return;
+ }
+ later(connectStream);
+ }
+
+ function attach(path, onFail) {
+ const source = new EventSource(path);
+ let progressed = false;
+ es = source;
+ for (const type of DOWNSTREAM) {
+ source.addEventListener(type, (ev) => {
+ if (source !== es) return;
+ progressed = true;
+ let msg;
+ try {
+ msg = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+ handle(type, msg);
+ });
+ }
+ source.onerror = () => {
+ if (source !== es) return;
+ closeStream();
+ if (state.stopped) return;
+ if (progressed) {
+ // The stream lived and then ended: the five-minute TTL, or a
+ // dropped connection. Resume at once, from a clean backoff.
+ backoff = BACKOFF_MIN;
+ if (state.peer) resume();
+ else onFail();
+ return;
+ }
+ // It never spoke, so the refusal is worth diagnosing — but a
+ // resume that is answered 204 and then fails at the EventSource
+ // must not spin, so that one waits.
+ if (state.peer) later(resume);
+ else onFail();
+ };
+ }
+
+ async function probe(path) {
+ // No Accept header: the probe forms are exempt from the stream's
+ // text/event-stream gate and must not claim to be one.
+ const res = await fetch(path, { credentials: 'same-origin', cache: 'no-store' });
+ let data = {};
+ if (res.status !== 204) {
+ try {
+ data = await res.json();
+ } catch {
+ data = {};
+ }
+ }
+ return { status: res.status, data: data || {} };
+ }
+
+ function later(fn) {
+ if (state.stopped) return;
+ clearTimeout(reconnectTimer);
+ renderStatus();
+ reconnectTimer = setTimeout(fn, backoff);
+ backoff = Math.min(backoff * 2, BACKOFF_MAX);
+ }
+
+ // ---------------------------------------------------------------
+ // Events (§4.3)
+ // ---------------------------------------------------------------
+
+ function handle(type, msg) {
+ switch (type) {
+ case 'hello': return onHello(msg);
+ case 'roster': return reconcile(msg.roster || { peers: [] });
+ case 'peer': return onPeer(msg);
+ case 'gone': return onGone(msg);
+ case 'knock': return onKnock(msg);
+ case 'knock-gone': return onKnockGone(msg);
+ case 'full': return onFull();
+ case 'state': return onState(msg);
+ case 'pair': return onPair(msg);
+ case 'gap': return onGap(msg);
+ case 'lost': return onLost(msg);
+ case 'offer': return onOffer(msg);
+ case 'answer': return onAnswer(msg);
+ case 'candidate': return onCandidate(msg);
+ default: return undefined;
+ }
+ }
+
+ function onHello(msg) {
+ backoff = BACKOFF_MIN;
+ const fresh = !state.peer;
+ state.peer = msg.peer || null;
+ state.boot = msg.boot || state.boot;
+ root.dataset.peer = state.peer || '';
+ if (fresh) join();
+ renderStatus();
+ }
+
+ async function join() {
+ const peer = state.peer;
+ const res = await postSignal({ peer, type: 'join' });
+ if (state.stopped || state.peer !== peer) return;
+ if (res.status === 200) {
+ state.joined = true;
+ // A guest gets nothing back from its own join: no roster, no
+ // state. The waiting sentence is this browser's own conclusion,
+ // not an event it waits for.
+ renderStatus();
+ return;
+ }
+ if (res.status === 404) {
+ recoverFromNotFound(res.data.boot);
+ return;
+ }
+ // 429 and the transient failures are retried on the same peer, with
+ // its own wait so a pending stream reconnect is never cancelled;
+ // anything else leaves the sentence alone and the next roster or
+ // knock speaks for itself.
+ if (res.status === 429 || res.status >= 500 || res.status === 0) {
+ await sleep(res.status === 429 ? retryAfter(res.retryAfter) : BACKOFF_MIN);
+ if (!state.stopped && state.peer === peer && !state.joined) join();
+ }
+ }
+
+ function onPeer(msg) {
+ const id = msg.peer;
+ if (!id || id === state.peer) return;
+ if (msg.name) names.set(id, msg.name);
+ const rp = (msg.roster && msg.roster.peers && msg.roster.peers[0]) || {};
+ const g = rp.gen || 1;
+ if (rp.name) names.set(id, rp.name);
+ ensurePC(id, g);
+ renderTiles();
+ }
+
+ function onGone(msg) {
+ if (!msg.from) return;
+ closePC(msg.from, true);
+ dropWaiting(msg.from);
+ renderTiles();
+ renderQueue();
+ }
+
+ function onKnock(msg) {
+ if (!msg.from) return;
+ names.set(msg.from, msg.name || '');
+ if (!waiting.some((w) => w.peer === msg.from)) {
+ waiting = waiting.concat([{ peer: msg.from, name: msg.name || '' }]);
+ }
+ renderQueue();
+ }
+
+ function onKnockGone(msg) {
+ if (!msg.from) return;
+ dropWaiting(msg.from);
+ renderQueue();
+ }
+
+ function dropWaiting(id) {
+ waiting = waiting.filter((w) => w.peer !== id);
+ }
+
+ // §4.3: a member who sees `full` may wait and retry, so this does —
+ // once every five seconds, until a roster says we are in or the
+ // stream ends. Nothing else in the room polls.
+ function onFull() {
+ state.full = true;
+ renderStatus();
+ clearTimeout(fullTimer);
+ fullTimer = setTimeout(() => {
+ if (state.stopped || state.admitted || !state.peer) return;
+ join();
+ }, FULL_RETRY);
+ }
+
+ function onState(msg) {
+ // The only `state` the server sends is waiting:true, on a resume of
+ // a participation that has not been admitted. `waiting` uses
+ // omitempty, so an absent field is false.
+ if (!msg.waiting) return;
+ state.joined = true;
+ state.admitted = false;
+ renderStatus();
+ }
+
+ function onPair(msg) {
+ const id = msg.peer;
+ if (!id || !msg.gen) return;
+ // A generation at or below the local one is a no-op, so duplicates
+ // and reordering are harmless (§4.6).
+ if (msg.gen <= (gen.get(id) || 0)) return;
+ rebuildPC(id, msg.gen);
+ }
+
+ function onGap(msg) {
+ // The replay could not fill this sender's gap, so the pair is
+ // reset: the hub bumps the generation and tells both sides (§4.6).
+ if (msg.from) requestReset(msg.from);
+ }
+
+ function onLost(msg) {
+ const reason = msg.reason || 'ended';
+ if (reason === 'buffer') {
+ // The one reason that is followed by a resume, not a screen.
+ closeStream();
+ resume();
+ return;
+ }
+ terminal(reason);
+ }
+
+ // ---------------------------------------------------------------
+ // Reconciliation (§4.5: the roster is the authority)
+ // ---------------------------------------------------------------
+
+ function reconcile(roster) {
+ lastRoster = roster;
+ state.admitted = true;
+ state.joined = true;
+ state.full = false;
+ const present = new Set();
+ for (const rp of roster.peers || []) {
+ if (!rp || !rp.peer) continue;
+ names.set(rp.peer, rp.name || '');
+ if (rp.peer === state.peer) {
+ // Our own kind, re-derived by the server on every tick: this,
+ // not data-is-member, is what decides whether the member-only
+ // controls are drawn.
+ state.kind = rp.kind || state.kind;
+ continue;
+ }
+ present.add(rp.peer);
+ ensurePC(rp.peer, rp.gen || 1);
+ }
+ for (const id of Array.from(pcs.keys())) {
+ // A connection to an id the roster does not carry is closed: this
+ // is how a browser that missed a `gone` still lets go.
+ if (!present.has(id)) closePC(id, true);
+ }
+ waiting = (roster.waiting || []).map((w) => {
+ names.set(w.peer, w.name || '');
+ return { peer: w.peer, name: w.name || '' };
+ });
+ renderTiles();
+ renderQueue();
+ renderStatus();
+ }
+
+ // ---------------------------------------------------------------
+ // Peer connections and perfect negotiation (§5.1)
+ // ---------------------------------------------------------------
+
+ // ensurePC is the one door into the mesh: a peer with no connection
+ // gets one, and a peer whose generation has moved on is rebuilt under
+ // the new one. Both `roster` and `peer` come through here, so the two
+ // paths cannot drift.
+ function ensurePC(id, g) {
+ if (pcs.has(id)) {
+ if (g > (gen.get(id) || 0)) rebuildPC(id, g);
+ return pcs.get(id);
+ }
+ gen.set(id, g);
+ return createPC(id, g);
+ }
+
+ function rebuildPC(id, g) {
+ // A new generation: close the connection, discard the outbound
+ // queue and the candidate buffer, keep the seq counters (§4.6).
+ closePC(id, false);
+ gen.set(id, g);
+ createPC(id, g);
+ renderTiles();
+ }
+
+ function createPC(id, g) {
+ // No ICE servers in this slice. §5.5's floor: browsers gather host
+ // candidates only, which connects on a permissive local network and
+ // usually not across the internet. Slice 3 adds GET /m/{sqid}/ice
+ // and fills this array from it.
+ const pc = new RTCPeerConnection({ iceServers: [] });
+ const entry = {
+ pc,
+ // §5.1: the peer with the lower id is polite and rolls back on a
+ // collision; the higher is impolite and ignores the offer.
+ polite: String(state.peer) < String(id),
+ makingOffer: false,
+ ignoreOffer: false,
+ pending: [],
+ descSeq: 0,
+ gen: g,
+ stream: null,
+ failed: false,
+ };
+ pcs.set(id, entry);
+
+ if (local) {
+ for (const track of local.getTracks()) pc.addTrack(track, local);
+ } else {
+ // Without a track of our own nothing would fire negotiationneeded
+ // and the pair would never connect, so a browser that joined with
+ // no camera asks explicitly to receive.
+ try {
+ pc.addTransceiver('video', { direction: 'recvonly' });
+ pc.addTransceiver('audio', { direction: 'recvonly' });
+ } catch {
+ // An old browser without addTransceiver simply waits to be
+ // offered to, which the other side will do if it has media.
+ }
+ }
+
+ pc.addEventListener('negotiationneeded', async () => {
+ if (pcs.get(id) !== entry) return;
+ try {
+ entry.makingOffer = true;
+ await pc.setLocalDescription();
+ send(id, 'offer', { description: describe(pc.localDescription) });
+ } catch {
+ // A failed local description is not fatal: the pair recovers
+ // through a reset if it never connects.
+ } finally {
+ entry.makingOffer = false;
+ }
+ });
+
+ pc.addEventListener('icecandidate', ({ candidate }) => {
+ if (pcs.get(id) !== entry || !candidate) return;
+ send(id, 'candidate', { candidate: candidate.toJSON() });
+ });
+
+ pc.addEventListener('track', (ev) => {
+ if (pcs.get(id) !== entry) return;
+ entry.stream = (ev.streams && ev.streams[0]) || entry.stream || new MediaStream([ev.track]);
+ if (!ev.streams || !ev.streams.length) entry.stream.addTrack(ev.track);
+ renderTiles();
+ });
+
+ pc.addEventListener('connectionstatechange', () => {
+ if (pcs.get(id) !== entry) return;
+ entry.failed = pc.connectionState === 'failed';
+ renderTiles();
+ });
+
+ return entry;
+ }
+
+ function describe(desc) {
+ return desc ? { type: desc.type, sdp: desc.sdp } : null;
+ }
+
+ function closePC(id, forget) {
+ const entry = pcs.get(id);
+ if (entry) {
+ try {
+ entry.pc.close();
+ } catch {
+ // Already closed.
+ }
+ pcs.delete(id);
+ }
+ queue.delete(id);
+ if (forget) {
+ gen.delete(id);
+ seqOut.delete(id);
+ lastIn.delete(id);
+ names.delete(id);
+ frames.delete(id);
+ }
+ }
+
+ function closeAllPCs() {
+ for (const id of Array.from(pcs.keys())) closePC(id, true);
+ waiting = [];
+ lastRoster = null;
+ renderTiles();
+ renderQueue();
+ }
+
+ // accept applies §4.6's inbound rules to a targeted message: drop
+ // anything at or below the seq already seen from this sender, then
+ // drop anything below the current pair generation. A generation
+ // *above* the local one means a `pair` was missed, so the connection
+ // is rebuilt before the message is used.
+ function accept(msg) {
+ const from = msg.from;
+ if (!from) return null;
+ const seq = msg.seq || 0;
+ if (seq && seq <= (lastIn.get(from) || 0)) return null;
+ if (seq) lastIn.set(from, seq);
+ const g = msg.gen || 0;
+ const mine = gen.get(from) || 0;
+ if (g && g < mine) return null;
+ if (g && g > mine) rebuildPC(from, g);
+ return pcs.get(from) || null;
+ }
+
+ async function onOffer(msg) {
+ const entry = accept(msg);
+ if (!entry) return;
+ const desc = msg.payload && msg.payload.description;
+ if (!desc || desc.type !== 'offer') return;
+ const collision = entry.makingOffer || entry.pc.signalingState !== 'stable';
+ entry.ignoreOffer = !entry.polite && collision;
+ if (entry.ignoreOffer) return;
+ try {
+ // The polite peer's implicit rollback lives inside this call.
+ await entry.pc.setRemoteDescription(desc);
+ entry.descSeq = msg.seq || entry.descSeq;
+ await drainCandidates(entry);
+ await entry.pc.setLocalDescription();
+ send(msg.from, 'answer', { description: describe(entry.pc.localDescription) });
+ } catch {
+ // A description that will not apply leaves the pair to recover
+ // through a reset rather than throwing into the event handler.
+ }
+ }
+
+ async function onAnswer(msg) {
+ const entry = accept(msg);
+ if (!entry) return;
+ const desc = msg.payload && msg.payload.description;
+ if (!desc || desc.type !== 'answer') return;
+ if (entry.pc.signalingState !== 'have-local-offer') return;
+ try {
+ await entry.pc.setRemoteDescription(desc);
+ entry.descSeq = msg.seq || entry.descSeq;
+ await drainCandidates(entry);
+ } catch {
+ // Same as above: a stale answer is not worth an exception.
+ }
+ }
+
+ async function onCandidate(msg) {
+ const entry = accept(msg);
+ if (!entry) return;
+ const candidate = msg.payload && msg.payload.candidate;
+ if (!candidate) return;
+ // §4.6: a candidate whose seq precedes the current description's
+ // belongs to a description that has been replaced.
+ if (msg.seq && entry.descSeq && msg.seq < entry.descSeq) return;
+ if (!entry.pc.remoteDescription) {
+ entry.pending.push(candidate);
+ if (entry.pending.length > MAX_BUFFERED_CANDIDATES) entry.pending.shift();
+ return;
+ }
+ await addCandidate(entry, candidate);
+ }
+
+ async function drainCandidates(entry) {
+ const held = entry.pending;
+ entry.pending = [];
+ for (const candidate of held) await addCandidate(entry, candidate);
+ }
+
+ async function addCandidate(entry, candidate) {
+ try {
+ await entry.pc.addIceCandidate(candidate);
+ } catch (err) {
+ // The W3C reference swallows exactly this: an ignored offer's
+ // candidates cannot apply, and that is expected, not an error.
+ if (!entry.ignoreOffer) console.warn('meet: candidate', err && err.name);
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Outbound queue (§4.6)
+ // ---------------------------------------------------------------
+
+ function send(to, type, payload) {
+ if (!state.peer) return;
+ const seq = (seqOut.get(to) || 0) + 1;
+ seqOut.set(to, seq);
+ const q = queue.get(to) || [];
+ q.push({ seq, body: { peer: state.peer, type, to, seq, gen: gen.get(to) || 1, payload } });
+ queue.set(to, q);
+ pump(to);
+ }
+
+ // pump owns one POST at a time per target, in order, and does not
+ // start the next message until this one is accepted. Every status the
+ // wire contract names has its own arm here.
+ async function pump(to) {
+ if (inflight.has(to)) return;
+ inflight.add(to);
+ let failingSince = 0;
+ let wait = BACKOFF_MIN;
+ try {
+ for (;;) {
+ const q = queue.get(to);
+ if (!q || !q.length || state.stopped) return;
+ const res = await postSignal(q[0].body);
+ if (queue.get(to) !== q) return; // the queue was dropped underneath us
+ if (res.status === 200) {
+ q.shift();
+ failingSince = 0;
+ wait = BACKOFF_MIN;
+ continue;
+ }
+ if (res.status === 429) {
+ // Retry-After is whole seconds, and the same message is
+ // retried with the same seq.
+ await sleep(retryAfter(res.retryAfter));
+ continue;
+ }
+ if (res.status === 409) {
+ if (res.data.error === 'stale') {
+ // The pair moved on. Drop everything queued for this
+ // target; a `pair` or the next roster rebuilds it, and if
+ // the roster already knows a higher generation, use it now.
+ queue.set(to, []);
+ const known = rosterGen(to);
+ if (known > (gen.get(to) || 0)) rebuildPC(to, known);
+ else requestReset(to);
+ return;
+ }
+ return; // `conflict` belongs to admit, which is not queued here
+ }
+ if (res.status === 404) {
+ recoverFromNotFound(res.data.boot);
+ return;
+ }
+ if (res.status === 400 || res.status === 413) {
+ // Malformed, or over 64 KB: never retried. Drop that one
+ // message and carry on with the rest of the queue.
+ q.shift();
+ continue;
+ }
+ if (!failingSince) failingSince = Date.now();
+ if (Date.now() - failingSince > SEND_DEADLINE) {
+ // §4.6: the target is unreachable. Close the connection, ask
+ // the hub for a fresh generation, and let `pair` or the next
+ // roster build it again.
+ queue.set(to, []);
+ closePC(to, false);
+ renderTiles();
+ requestReset(to);
+ return;
+ }
+ await sleep(wait);
+ wait = Math.min(wait * 2, BACKOFF_MAX);
+ }
+ } finally {
+ inflight.delete(to);
+ }
+ }
+
+ function rosterGen(id) {
+ if (!lastRoster) return 0;
+ for (const rp of lastRoster.peers || []) if (rp.peer === id) return rp.gen || 0;
+ return 0;
+ }
+
+ function retryAfter(header) {
+ const n = parseInt(header || '', 10);
+ return Number.isFinite(n) && n > 0 ? Math.min(n, 30) * 1000 : 1000;
+ }
+
+ function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ // postSignal is the one place this file talks upstream. csrf.Protect
+ // is origin-based, so a same-origin fetch needs no token.
+ async function postSignal(body, keepalive) {
+ try {
+ const res = await fetch('/m/' + encodeURIComponent(sqid) + '/signal', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ keepalive: !!keepalive,
+ });
+ let data = {};
+ if (res.status !== 200 && res.status !== 204) {
+ try {
+ data = await res.json();
+ } catch {
+ data = {};
+ }
+ }
+ return { status: res.status, data: data || {}, retryAfter: res.headers.get('Retry-After') };
+ } catch {
+ return { status: 0, data: {}, retryAfter: null };
+ }
+ }
+
+ function requestReset(to) {
+ postSignal({ peer: state.peer, type: 'reset', to });
+ }
+
+ // recoverFromNotFound is the 404 from either endpoint: this
+ // participation no longer exists. `boot` splits a process restart
+ // from a lapsed resume window; the recovery is the same either way,
+ // and the difference is only worth carrying so the fresh stream
+ // starts from the right boot id.
+ function recoverFromNotFound(boot) {
+ if (state.stopped) return;
+ if (boot) state.boot = boot;
+ closeStream();
+ closeAllPCs();
+ state.peer = null;
+ connectStream();
+ }
+
+ // ---------------------------------------------------------------
+ // Terminal states and the takeover notice (§4.5, §4.2)
+ // ---------------------------------------------------------------
+
+ const TERMINAL = {
+ replaced: 'This meeting is open in another tab.',
+ standing: 'You no longer have access to this meeting.',
+ expired: 'Your invitation to this meeting has expired.',
+ removed: 'Someone removed you from this meeting.',
+ ended: 'This meeting has ended.',
+ gone: 'This meeting is no longer open.',
+ };
+
+ function terminal(reason) {
+ if (state.stopped) return;
+ state.stopped = true;
+ clearTimeout(reconnectTimer);
+ clearTimeout(fullTimer);
+ closeStream();
+ closeAllPCs();
+ stopLocal();
+ renderTiles();
+ controlsEnabled(false);
+ say(TERMINAL[reason] || TERMINAL.ended);
+ if (reason === 'replaced') addTakeoverLink();
+ }
+
+ // takeoverNotice is the 409: this session already holds a peer in the
+ // room, from another tab. Nothing automatic may displace it (§4.2),
+ // so this stops and offers the confirm page.
+ function takeoverNotice() {
+ if (state.stopped) return;
+ state.stopped = true;
+ clearTimeout(reconnectTimer);
+ clearTimeout(fullTimer);
+ closeStream();
+ closeAllPCs();
+ stopLocal();
+ renderTiles();
+ controlsEnabled(false);
+ say(TERMINAL.replaced);
+ addTakeoverLink();
+ }
+
+ function addTakeoverLink() {
+ const link = document.createElement('a');
+ link.href = '/m/' + encodeURIComponent(sqid) + '/takeover';
+ link.textContent = 'Use this tab instead';
+ statusEl.append(' ', link);
+ }
+
+ function controlsEnabled(on) {
+ muteButton.disabled = !on;
+ cameraButton.disabled = !on;
+ leaveButton.disabled = !on;
+ }
+
+ // ---------------------------------------------------------------
+ // Rendering (§3.4's room, §4.3's queue)
+ // ---------------------------------------------------------------
+
+ function say(text) {
+ statusEl.replaceChildren();
+ statusEl.textContent = text;
+ }
+
+ function renderStatus() {
+ if (state.stopped) return;
+ if (state.full) {
+ say('The room is full.');
+ return;
+ }
+ if (!state.peer) {
+ say('Connecting to the call.');
+ return;
+ }
+ if (!state.admitted) {
+ say('Waiting for someone to let you in.');
+ return;
+ }
+ const others = pcs.size;
+ if (!others) {
+ say('You are the only one here.');
+ return;
+ }
+ say('');
+ }
+
+ function isMember() {
+ return state.kind === 'member';
+ }
+
+ function renderTiles() {
+ const wanted = [];
+ if (local) wanted.push('self');
+ for (const id of pcs.keys()) wanted.push(id);
+ for (const key of Array.from(tiles.keys())) {
+ if (!wanted.includes(key)) {
+ tiles.get(key).figure.remove();
+ tiles.delete(key);
+ }
+ }
+ if (local) tile('self', selfName, local, true, false);
+ for (const [id, entry] of pcs) {
+ tile(id, names.get(id) || 'Someone', entry.stream, false, entry.failed);
+ }
+ renderStatus();
+ }
+
+ // tile builds or updates one <figure> per participant. Nothing here
+ // uses innerHTML, and every name is written with textContent: names
+ // come from the server's roster, peer and knock events and are other
+ // people's text.
+ function tile(key, name, stream, isSelf, failed) {
+ let t = tiles.get(key);
+ if (!t) {
+ const figure = document.createElement('figure');
+ figure.className = 'meet-tile';
+ const video = document.createElement('video');
+ video.autoplay = true;
+ video.playsInline = true;
+ video.setAttribute('playsinline', '');
+ if (isSelf) {
+ video.muted = true;
+ figure.classList.add('meet-tile-self');
+ }
+ const label = document.createElement('figcaption');
+ label.className = 'meet-tile-name';
+ const nameEl = document.createElement('span');
+ const note = document.createElement('span');
+ note.className = 'meet-tile-note';
+ note.hidden = true;
+ const retry = document.createElement('button');
+ retry.type = 'button';
+ retry.textContent = 'Retry';
+ retry.hidden = true;
+ retry.addEventListener('click', () => restartIce(key));
+ label.append(nameEl, note, retry);
+ figure.append(video, label);
+ tilesEl.append(figure);
+ t = { figure, video, label, nameEl, note, retry, remove: null, stream: null };
+ tiles.set(key, t);
+ if (!isSelf) {
+ watchFrames(key, video);
+ applySink(video);
+ }
+ }
+ t.nameEl.textContent = isSelf ? name + ' (you)' : name;
+ // The removal control comes and goes with our own kind, which the
+ // roster re-states on every tick — a member demoted mid-call loses
+ // it without the tile being rebuilt.
+ if (!isSelf && isMember() && !t.remove) {
+ t.remove = removeLink(key);
+ t.label.append(t.remove);
+ } else if (t.remove && !isMember()) {
+ t.remove.remove();
+ t.remove = null;
+ }
+ if (t.stream !== stream) {
+ // srcObject only: media-src in the page's CSP would block a blob:
+ // URL, and createObjectURL is the wrong tool here anyway.
+ t.video.srcObject = stream || null;
+ t.stream = stream || null;
+ }
+ const show = !!failed;
+ t.note.hidden = !show;
+ t.retry.hidden = !show;
+ t.note.textContent = show ? 'Could not connect.' : '';
+ }
+
+ function removeLink(id) {
+ // §3.5: a destructive action gets its own confirm page URL. A new
+ // tab, because this one is holding a call.
+ const link = document.createElement('a');
+ link.href = '/m/' + encodeURIComponent(sqid) + '/remove/' + encodeURIComponent(id);
+ link.target = '_blank';
+ link.rel = 'noopener';
+ link.className = 'meet-tile-remove';
+ link.textContent = 'Remove';
+ return link;
+ }
+
+ function restartIce(id) {
+ const entry = pcs.get(id);
+ if (!entry) return;
+ entry.failed = false;
+ renderTiles();
+ try {
+ entry.pc.restartIce();
+ } catch {
+ // No restartIce: ask the hub for a fresh generation instead,
+ // which rebuilds the connection from nothing.
+ requestReset(id);
+ }
+ }
+
+ // applySink routes a remote tile's audio to the speaker the holding
+ // screen remembered. Feature-detected on the prototype, because a
+ // browser without setSinkId would otherwise leave that picker as a
+ // control that does nothing.
+ function applySink(video) {
+ if (!prefs.speaker || !HAS_SINK_ID) return;
+ video.setSinkId(prefs.speaker).catch(() => {
+ // The remembered output is gone, or is not permitted: the
+ // browser's default output carries the call instead.
+ });
+ }
+
+ // renderQueue draws the admission queue. Only members are ever sent
+ // knock, knock-gone or roster.waiting, so what is in `waiting` is
+ // already the server's decision about who may see this.
+ function renderQueue() {
+ knocksEl.replaceChildren();
+ for (const w of waiting) {
+ const li = document.createElement('li');
+ const who = document.createElement('span');
+ who.textContent = w.name || 'Someone';
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = 'Admit';
+ button.addEventListener('click', () => admit(w.peer, button));
+ li.append(who, button);
+ knocksEl.append(li);
+ }
+ queueEl.hidden = waiting.length === 0;
+ }
+
+ async function admit(peer, button) {
+ button.disabled = true;
+ // admit names its target in the payload, not in `to` (§4.3).
+ const res = await postSignal({ peer: state.peer, type: 'admit', payload: { peer } });
+ if (res.status === 409) {
+ // The guest is no longer waiting: gone, or admitted by someone
+ // else. The knock-gone that follows removes the row anyway.
+ dropWaiting(peer);
+ renderQueue();
+ return;
+ }
+ if (res.status === 404) {
+ recoverFromNotFound(res.data.boot);
+ return;
+ }
+ if (res.status !== 200) button.disabled = false;
+ }
+
+ // ---------------------------------------------------------------
+ // Controls (§3.4)
+ // ---------------------------------------------------------------
+
+ muteButton.addEventListener('click', () => {
+ prefs.muted = !prefs.muted;
+ savePrefs();
+ applyToggles();
+ });
+
+ cameraButton.addEventListener('click', () => {
+ prefs.cameraOff = !prefs.cameraOff;
+ savePrefs();
+ applyToggles();
+ });
+
+ leaveButton.addEventListener('click', () => {
+ leave();
+ go('/m/' + encodeURIComponent(sqid) + '/join');
+ });
+
+ // go navigates only within this app: a path, never a URL that could
+ // carry a scheme or a host.
+ function go(path) {
+ if (typeof path === 'string' && path.startsWith('/') && !path.startsWith('//')) {
+ location.assign(path);
+ }
+ }
+
+ // leave is not idempotent, so it is sent exactly once, and its
+ // response is never read.
+ function leave() {
+ if (state.leaveSent) return;
+ state.leaveSent = true;
+ state.stopped = true;
+ clearTimeout(reconnectTimer);
+ clearTimeout(fullTimer);
+ if (state.peer) postSignal({ peer: state.peer, type: 'leave' }, true);
+ closeStream();
+ closeAllPCs();
+ stopLocal();
+ }
+
+ window.addEventListener('pagehide', leave);
+
+ // ---------------------------------------------------------------
+ // The test hook (Task 9's Playwright gate)
+ // ---------------------------------------------------------------
+
+ const HAS_RVFC = typeof HTMLVideoElement !== 'undefined' &&
+ typeof HTMLVideoElement.prototype.requestVideoFrameCallback === 'function';
+
+ function watchFrames(id, video) {
+ if (!HAS_RVFC) return;
+ const step = () => {
+ if (!video.isConnected) return;
+ frames.set(id, (frames.get(id) || 0) + 1);
+ video.requestVideoFrameCallback(step);
+ };
+ video.requestVideoFrameCallback(step);
+ }
+
+ async function pollFrames() {
+ // The fallback for a browser without requestVideoFrameCallback:
+ // framesDecoded off the inbound video stats.
+ if (HAS_RVFC) return;
+ for (const [id, entry] of pcs) {
+ try {
+ const stats = await entry.pc.getStats();
+ stats.forEach((report) => {
+ if (report.type === 'inbound-rtp' && report.kind === 'video' &&
+ typeof report.framesDecoded === 'number') {
+ frames.set(id, report.framesDecoded);
+ }
+ });
+ } catch {
+ // getStats on a closing connection: nothing to report.
+ }
+ }
+ }
+
+ window.meet = {
+ state() {
+ return {
+ peer: state.peer,
+ admitted: state.admitted,
+ peers: Array.from(pcs.keys()),
+ frames: new Map(frames),
+ };
+ },
+ };
+
+ // ---------------------------------------------------------------
+ // Start
+ // ---------------------------------------------------------------
+
+ async function start() {
+ controlsEnabled(true);
+ renderStatus();
+ renderQueue();
+ await startLocal();
+ renderTiles();
+ if (!HAS_RVFC) statsTimer = setInterval(pollFrames, 1000);
+ connectStream();
+ }
+
+ window.addEventListener('pagehide', () => clearInterval(statsTimer));
+
+ start();
})();
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go
index 8b1a005..b57b665 100644
--- a/internal/meettest/room_test.go
+++ b/internal/meettest/room_test.go
@@ -280,3 +280,37 @@ func TestEndingTheMeetingEndsTheRoom(t *testing.T) {
t.Fatalf("room page after end: %d", rec.Code)
}
}
+
+// room.js is what makes the room a call, and it loads from layout's
+// head block for the same reason meet.css does (Important 15): the
+// page's CSP is default-src 'self', so nothing about the call may be
+// inline. The scriptless floor rests on #room being rendered hidden —
+// a browser with no script sees the noscript paragraph and the link
+// back to the holding screen, never an empty grid and three dead
+// buttons — so both facts are asserted together.
+func TestRoomPageLoadsRoomScriptFromTheHeadAndHidesTheRoom(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.get("/m/" + sqid).Body.String()
+
+ headEnd := strings.Index(body, "</head>")
+ // asset() fingerprints the filename (static/room.<hash>.js), so the
+ // literal "room.js" never appears — match the stable prefix.
+ script := strings.Index(body, "/static/room.")
+ if headEnd < 0 || script < 0 || script > headEnd {
+ t.Fatalf("room.js is not inside <head>: head ends at %d, room.js at %d\n%s", headEnd, script, body)
+ }
+ if !strings.Contains(body[:headEnd], "<script defer src=") {
+ t.Fatalf("room.js must stay deferred in the head block:\n%s", body[:headEnd])
+ }
+
+ open := strings.Index(body, "<div id=\"room\"")
+ if open < 0 {
+ t.Fatalf("no #room element:\n%s", body)
+ }
+ end := strings.Index(body[open:], ">")
+ if end < 0 || !strings.Contains(body[open:open+end], " hidden") {
+ t.Fatalf("#room must be rendered hidden for the scriptless floor: %q", body[open:open+end])
+ }
+}