1// TURN relay support.
2//
3// Direct and STUN-assisted pairing fails for some participants (symmetric NAT
4// on both ends, corporate firewalls that only permit 443), and those pairs need
5// a relay. Relays cost bandwidth, so this one is gated behind a token: whoever
6// has it types it in on the landing form, their browser exchanges it for a
7// short-lived ICE configuration at the Worker in `worker/`, and that
8// configuration is then shared with the rest of the room over the control
9// channels (see network.ts). Nobody else needs the token, and the token itself
10// never leaves the browser it was typed into.
11//
12// Everything here is optional: with no endpoint configured at build time, or no
13// token entered by anyone in the room, calls fall back to STUN plus the free
14// public relay and behave exactly as they did before.
16/** Where to exchange a room token for TURN credentials (build-time config). */
17export const TURN_ENDPOINT = (import.meta.env.VITE_TURN_ENDPOINT ?? '').trim()
19/** Whether this build has a credential endpoint at all. */
20export const TURN_CONFIGURED = TURN_ENDPOINT.length > 0
22/** An ICE configuration together with the moment its credentials stop working.
23 * This is the unit that gets minted, cached, shared and refreshed. */
24export interface IceConfig {
25 iceServers: RTCIceServer[]
26 /** Epoch ms; after this the TURN username/credential pair is dead. */
27 expiresAt: number
28}
30/** Where our relay configuration came from, for the status indicator. */
31export type RelayStatus =
32 /** No relay credentials: STUN (and the public fallback relay) only. */
33 | 'off'
34 /** Minted with a token entered in this browser. */
35 | 'self'
36 /** Received from another participant over the control channel. */
37 | 'shared'
39/** Plain STUN, always used. These only reveal a public address; they never
40 * carry media, so there is nothing to gate behind a token. */
41export const STUN_SERVERS: RTCIceServer[] = [
42 {urls: 'stun:stun.l.google.com:19302'},
43 {urls: 'stun:stun1.l.google.com:19302'},
44 {urls: 'stun:stun.cloudflare.com:3478'}
45]
47/** What a peer with no credentials uses: STUN plus the free openrelay relay.
48 * That relay is shared, rate-limited and frequently unavailable — it is a
49 * last resort, not a substitute for a token. */
50export const BASE_ICE_SERVERS: RTCIceServer[] = [
51 ...STUN_SERVERS,
52 {
53 urls: [
54 'turn:openrelay.metered.ca:80',
55 'turn:openrelay.metered.ca:443',
56 'turns:openrelay.metered.ca:443'
57 ],
58 username: 'openrelayproject',
59 credential: 'openrelayproject'
60 }
61]
63const FETCH_TIMEOUT_MS = 10000
65/** Exchange a room token for a short-lived ICE configuration.
66 *
67 * `roomTag` is an opaque per-room identifier (a prefix of the hashed room
68 * topic, never the room name) that the Worker forwards to Cloudflare as the
69 * credential's customIdentifier, so relay usage can be attributed per room.
70 *
71 * Throws with a message suitable for display. */
72export async function fetchIceConfig(
73 token: string,
74 roomTag: string
75): Promise<IceConfig> {
76 if (!TURN_CONFIGURED) throw new Error('No relay endpoint is configured')
77 const controller = new AbortController()
78 const timer = window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
79 let res: Response
80 try {
81 res = await fetch(TURN_ENDPOINT, {
82 method: 'POST',
83 headers: {
84 Authorization: `Bearer ${token}`,
85 'Content-Type': 'application/json'
86 },
87 body: JSON.stringify({room: roomTag}),
88 signal: controller.signal
89 })
90 } catch (err) {
91 throw new Error(
92 (err as {name?: string})?.name === 'AbortError'
93 ? 'the relay service did not respond'
94 : 'the relay service could not be reached'
95 )
96 } finally {
97 clearTimeout(timer)
98 }
100 if (!res.ok) {
101 const detail = await res
102 .json()
103 .then(b => (typeof (b as {error?: unknown})?.error === 'string'
104 ? (b as {error: string}).error
105 : ''))
106 .catch(() => '')
107 throw new Error(detail || `the relay service returned ${res.status}`)
108 }
110 const body = (await res.json().catch(() => null)) as {
111 iceServers?: unknown
112 expiresAt?: unknown
113 } | null
114 const iceServers = sanitizeIceServers(body?.iceServers)
115 if (!iceServers) throw new Error('the relay service sent an unusable response')
116 const expiresAt =
117 typeof body?.expiresAt === 'number' && Number.isFinite(body.expiresAt)
118 ? body.expiresAt
119 : Date.now() + 3600_000
120 return {iceServers, expiresAt}
121}
123const MAX_SERVERS = 8
124const MAX_URLS = 12
125const MAX_CREDENTIAL_LENGTH = 512
126// Only the four ICE schemes, and only characters that legitimately appear in
127// their URLs. Port 53 is excluded deliberately below.
128const URL_RE = /^(?:stuns?|turns?):[A-Za-z0-9._~-]+(?::\d{1,5})?(?:\?transport=(?:udp|tcp))?$/
130/** Validate an ICE server list that arrived over the network.
131 *
132 * This runs on the Worker's response AND on configurations shared by other
133 * participants, which is the case that matters: a room peer is not trusted, so
134 * anything it sends is treated as untrusted input and reduced to a
135 * well-formed, bounded list of ICE URLs before it can reach
136 * RTCPeerConnection. Returns null if the value is unusable. */
137export function sanitizeIceServers(value: unknown): RTCIceServer[] | null {
138 if (!Array.isArray(value) || value.length === 0) return null
139 const out: RTCIceServer[] = []
140 for (const raw of value.slice(0, MAX_SERVERS)) {
141 if (typeof raw !== 'object' || raw === null) continue
142 const entry = raw as {urls?: unknown; username?: unknown; credential?: unknown}
143 const candidates = Array.isArray(entry.urls)
144 ? entry.urls
145 : typeof entry.urls === 'string'
146 ? [entry.urls]
147 : []
148 const urls: string[] = []
149 for (const u of candidates.slice(0, MAX_URLS)) {
150 // Browsers block port 53, so those URLs can only ever time out.
151 if (typeof u === 'string' && URL_RE.test(u) && !/:53(?:\?|$)/.test(u)) {
152 urls.push(u)
153 }
154 }
155 if (urls.length === 0) continue
156 const server: RTCIceServer = {urls}
157 if (
158 typeof entry.username === 'string' &&
159 typeof entry.credential === 'string' &&
160 entry.username.length <= MAX_CREDENTIAL_LENGTH &&
161 entry.credential.length <= MAX_CREDENTIAL_LENGTH
162 ) {
163 server.username = entry.username
164 server.credential = entry.credential
165 } else if (urls.some(u => u.startsWith('turn'))) {
166 continue // a relay we have no credentials for can only fail to authenticate
167 }
168 out.push(server)
169 }
170 return out.length > 0 ? out : null
171}
173/** Validate an {iceServers, expiresAt} pair shared by another participant. */
174export function sanitizeIceConfig(
175 iceServers: unknown,
176 expiresAt: unknown
177): IceConfig | null {
178 const servers = sanitizeIceServers(iceServers)
179 if (!servers) return null
180 if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
181 if (expiresAt <= Date.now()) return null // already dead; nothing to adopt
182 return {iceServers: servers, expiresAt}
183}