| 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(); |
| })(); |