From f3adc08ee5c4c7c44d439ae28386c6caea128e78 Mon Sep 17 00:00:00 2001 From: Veton Shabani <68129513+vetonshabani0@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:28:37 +0200 Subject: [PATCH] Get TURN from the server instead of URL params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TURN credentials used to reach the phone as query params, put there by an untracked dev helper the launcher's QR pointed at. The QR now goes straight to this app and carries no credentials — and it couldn't, since this repo is public — so rtcConfig() takes its ICE servers from the server's /api/ice. The fetch runs once at load into a module-level cache. connect() awaits it before building the RTCPeerConnection, whose ICE servers are fixed at construction; by the time anyone taps Join it has long since settled. Callers don't use the return value, so connect() stays synchronous for them. A Leave or a second Join while the fetch is still in flight bumps a generation counter in _closeConnections(), so a superseded attempt can't open a peer behind it. Query params still override for a one-off device, and a missing /api/ice falls back to STUN, which still covers same-network play. --- assets/js/session.js | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/assets/js/session.js b/assets/js/session.js index 7202fb3..5445382 100644 --- a/assets/js/session.js +++ b/assets/js/session.js @@ -6,14 +6,33 @@ // The signaling socket goes idle once the channel is open. The UI is untouched: // the same `ready` / `sent` / `closed` events (plus `error`) drive it. -// RTCPeerConnection config. Defaults to free public STUN; an optional TURN relay -// can be added per device WITHOUT code changes via query params (mirrors ?signal=): +// RTCPeerConnection config. Public STUN is enough while the phone and the TV sit +// on one network; a TURN relay is what makes everything else work (phone on mobile +// data, TV on Wi-Fi, iOS's mDNS-masked candidates). TURN credentials come from the +// server at `/api/ice` — they can't be committed, since this repo is public. +// +// Fetched once at load into a module-level cache, so `connect()` only has to await +// a promise that has almost certainly already settled by the time a player taps Join. +// +// Query params still win, for pointing a single device at a TURN the server doesn't +// know about (mirrors ?signal=): // ?turn=turn::3478&turnuser=&turncred=

add a TURN server // ?relay=1 force relay-only (to verify TURN) -// The same URL points at a local coturn now or a hosted/college TURN later. +const DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }]; +let vendedIce = null; + +// Best-effort: if there's no /api/ice (e.g. the app is on a static host), we simply +// fall back to STUN, which still covers same-network play. +const iceReady = fetch("/api/ice") + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d && Array.isArray(d.iceServers) && d.iceServers.length) vendedIce = d.iceServers; + }) + .catch(() => { /* no server-vended ICE — STUN only */ }); + function rtcConfig() { const q = new URLSearchParams(location.search); - const iceServers = [{ urls: "stun:stun.l.google.com:19302" }]; + const iceServers = (vendedIce || DEFAULT_ICE).slice(); const turn = q.get("turn"); if (turn) { // Comma-separated list → one server with multiple URLs (ICE tries each). @@ -35,6 +54,7 @@ export class ControllerSession extends EventTarget { this._dc = null; this._pendingIce = []; this._intentional = false; // true only for a user-initiated Leave + this._attempt = 0; // connect() generation, so a stale attempt can't open } /** Join a room by code: signal in, then open a peer DataChannel to the TV. */ @@ -43,6 +63,19 @@ export class ControllerSession extends EventTarget { this.roomCode = (roomCode || "").toUpperCase(); this.name = (name || "").trim() || null; // shown on the TV roster / leaderboards + // An RTCPeerConnection's ICE servers are fixed at construction, so we have to + // know the TURN config before building it. The fetch starts at page load, so by + // the time someone taps Join this is already settled. Stays synchronous for + // callers (app.js just calls connect() and listens for events). + const attempt = this._attempt; // bumped by _closeConnections on leave/rejoin + iceReady.then(() => { + if (this._attempt !== attempt) return; // superseded by a Leave or a re-Join + this._open(); + }); + return this; + } + + _open() { const pc = new RTCPeerConnection(rtcConfig()); this._pc = pc; const dc = pc.createDataChannel("intents", { ordered: true }); @@ -137,6 +170,9 @@ export class ControllerSession extends EventTarget { // Close the live sockets/peer without announcing it — used both by teardown and // when reconnecting (so a stale connection can't leak or double-fire events). _closeConnections() { + // Invalidate any connect() still waiting on the ICE fetch, so a Leave (or a + // second Join) can't be followed by a stale peer opening behind it. + this._attempt = (this._attempt || 0) + 1; this.connected = false; if (this._dc) { try { this._dc.close(); } catch { /* already closing */ }