Optional TURN relay, gated by a token and shared across the room
Some pairs of browsers cannot reach each other directly (symmetric NAT at
both ends, or a firewall permitting only outbound 443) and need a relay.
Relaying is metered, so the credentials are bought with a token rather
than handed to everyone who loads the page.
Only one participant needs that token. Whoever has it exchanges it at the
new Cloudflare Worker in worker/ for a short-lived ICE configuration,
which is then shared with the rest of the room over the same control data
channels that carry chat and settings; everyone else adopts it and gains
relay candidates of their own. What travels between peers is the
credential, never the token, so the token stays in the browser it was
typed into and what does travel expires on its own. Credentials arriving
from a peer are treated as untrusted input and validated down to a
bounded list of well-formed ICE URLs before use.
Note that a credential cannot help the connection that carried it: a peer
learns it from the first peer it manages to reach and uses it for every
connection after that, and a pair that stalls in the meantime is rebuilt
by the existing connection retry, which re-reads the configuration.
All of this is optional. With no VITE_TURN_ENDPOINT set at build time the
token field is hidden and calls fall back to STUN plus the free public
relay, as before.
19 changed files+2480−29
.env.exampleadded+8−0View file
@@ -0,0 +1,8 @@
1+# Optional: URL of the TURN credential Worker deployed from worker/.
2+#
3+# Without it the relay-token field is hidden and calls use STUN plus the free
4+# public fallback relay, exactly as before. Copy this file to `.env.local` for
5+# local development; for the GitHub Pages build, set it as the repository
6+# variable VITE_TURN_ENDPOINT (Settings -> Secrets and variables -> Actions ->
7+# Variables). It is not a secret — the Worker is protected by the room token.
8+VITE_TURN_ENDPOINT=https://commonroom-turn.<your-subdomain>.workers.dev
.github/workflows/deploy.ymlmodified+4−0View file
@@ -26,6 +26,10 @@ jobs:
2626 cache: npm
2727 - run: npm ci
2828 - run: npm run build
29+ env:
30+ # Optional; unset simply disables the relay-token field. Not a
31+ # secret — the Worker it points at is protected by the room token.
32+ VITE_TURN_ENDPOINT: ${{ vars.VITE_TURN_ENDPOINT }}
2933 - uses: actions/upload-pages-artifact@v3
3034 with:
3135 path: dist
.gitignoremodified+2−0View file
@@ -1,3 +1,5 @@
11 node_modules
22 dist
33 *.log
4+.env.local
5+.env*.local
CLAUDE.mdmodified+32−1View file
@@ -13,11 +13,14 @@ src/p2p/
1313 nostr.ts minimal relay client + topic scheme — ported (roomTopic takes a room ID)
1414 peer.ts WebRTC wrapper: media + control channel — ported (replaceTrack generalized to audio|video)
1515 settings.ts shared ROOM settings, quality presets — default quality is 'medium', not 'auto'
16- network.ts the heart: rooms, presence, mesh, media, settings sync
16+ turn.ts optional TURN: build-time endpoint, credential fetch, sanitizers
17+ network.ts the heart: rooms, presence, mesh, media, settings sync, relay sharing
1718 src/App.tsx landing form (light) + in-room view (dark), video grid with
1819 click-to-spotlight (gallery ↔ one big tile + filmstrip; Esc or
1920 click again to return), control bar, chat panel (side panel on
2021 wide screens, overlay ≤700px, unread badge)
22+worker/ Cloudflare Worker that mints TURN credentials — deployed
23+ separately (wrangler), NOT part of `npm run build`
2124 ```
2225
2326 ## Key design decisions
@@ -74,6 +77,24 @@ src/App.tsx landing form (light) + in-room view (dark), video grid with
7477 ensures a blip-reconnect logs "joined" to match its "left". Links: only
7578 http(s) URLs matched by `withLinks` become anchors (target=_blank,
7679 rel=noopener noreferrer); never linkify other schemes.
80+- **TURN is optional, token-gated, and shared room-wide.** `VITE_TURN_ENDPOINT`
81+ (build time) points at `worker/`; unset ⇒ the whole feature is hidden and
82+ `BASE_ICE_SERVERS` (STUN + openrelay) is used, as before. One participant
83+ enters a token on the landing form, `mintIce` exchanges it for an
84+ `{iceServers, expiresAt}` and it is broadcast as `{t:'ice'}` — on `connect`
85+ to each peer, and again on refresh (`scheduleIceRefresh`, 5 min before
86+ expiry). Peers `adoptIce` it; our own credentials always beat a shared one
87+ (only we can refresh them), otherwise the later `expiresAt` wins. Share the
88+ CREDENTIAL, never the token — the token never leaves the browser it was
89+ typed into. **Peer-supplied ICE is untrusted:** `sanitizeIceConfig` bounds
90+ and scheme-checks it before it reaches `RTCPeerConnection`.
91+- **Credentials can't help the connection that carried them.** `Peer` takes
92+ `iceServers` at construction and never renegotiates them, so a peer learns
93+ credentials from the first peer it reaches and uses them for the NEXT
94+ connection. Stalled pairs recover via the existing `CONNECT_RETRY_MS` path,
95+ which calls `iceServers()` afresh — deliberately no proactive teardown on
96+ adoption, since rebuilding a half-open pair out of step with the other side
97+ is exactly what that retry already handles.
7798 - **Cleanup is join-generation-guarded.** `joinSeq` is bumped on every
7899 join/leave; async work (getUserMedia, topic hashing, display capture)
79100 re-checks it after each await. `leave()` unsubscribes topics, stops all
@@ -86,3 +107,13 @@ per-browser-profile via localStorage, so two tabs in one profile are the SAME
86107 peer — use a private window or second browser). `npm run build` type-checks
87108 (`tsc -b`) and bundles. Let the user test multi-party media in real browsers;
88109 don't try to automate camera/mic flows.
110+
111+The Worker CAN be tested without a browser: `cd worker && cp .dev.vars.example
112+.dev.vars && npm run dev`, then curl it. With the example values, token checks
113+work (`goodtoken` passes, anything else 401s) and the upstream call 404s, which
114+surfaces as a 502 — enough to cover auth, CORS and method handling. `cd worker
115+&& npx tsc --noEmit` type-checks it; the root `tsc -b` does not (it only
116+includes `src`). The sanitizers in `turn.ts` are pure and testable under node
117+via `npx esbuild src/p2p/turn.ts --format=esm --define:import.meta.env='{}'`.
118+Whether a relay is actually USED can only be seen in a real browser
119+(chrome://webrtc-internals, candidate pair type `relay`).
README.mdmodified+44−3View file
@@ -36,9 +36,9 @@ quality presets, screen share):
3636 - **Mesh** — being in the room is the consent: every participant automatically
3737 brings up a WebRTC connection with every other participant (deterministic
3838 initiator = smaller peer ID; offer/answer/ICE ride per-peer nostr topics).
39- Audio/video flows directly between browsers, with public STUN servers and a
40- free TURN relay as fallback. Rooms are softly capped at 8 — peers already at
41- capacity turn newcomers away.
39+ Audio/video flows directly between browsers, with public STUN servers and
40+ an optional TURN relay as fallback (see below). Rooms are softly capped at
41+ 8 — peers already at capacity turn newcomers away.
4242 - **Muted by default** — camera/mic are requested on entry so unmuting is
4343 instant, but tracks start disabled. If you deny access you still join,
4444 sending silent/black placeholder tracks; unmuting retries the device and
@@ -49,6 +49,42 @@ quality presets, screen share):
4949 map to `RTCRtpSender.setParameters` caps that each participant applies to
5050 its own outgoing senders.
5151
52+## The relay, and how one token covers a room
53+
54+Most pairs of browsers can reach each other directly once STUN has told them
55+their public addresses. Some cannot: symmetric NAT at both ends, or a firewall
56+that only permits outbound 443. Those pairs need a TURN relay, which costs
57+bandwidth and therefore cannot simply be handed to everyone who loads the page.
58+
59+The arrangement here is that relay credentials are bought with a token, but
60+only one person in the room needs to have one. Whoever has it types it into the
61+landing form; their browser exchanges it at the credential Worker in
62+[`worker/`](worker/) for a short-lived ICE configuration, and that
63+configuration is then shared with the rest of the room over the same control
64+data channels that carry chat and settings. Everyone else adopts it and gains
65+relay candidates of their own, so the relay is available to every pair in the
66+room rather than only to the token holder.
67+
68+What travels between peers is the credential, never the token. The token stays
69+in the browser it was typed into; the credential expires on its own and can be
70+revoked at the Worker. Credentials arriving from a peer are treated as
71+untrusted input and validated down to a bounded list of well-formed ICE URLs
72+before they go anywhere near an `RTCPeerConnection`.
73+
74+Note that a credential cannot help the connection that carried it: a peer
75+learns it from the first peer it manages to reach, and uses it for every
76+connection after that. In practice the token holder's own relay candidates are
77+what make that first connection work for a peer that has none. A pair that
78+stalls in the meantime is rebuilt by the ordinary connection retry, which picks
79+up whatever credentials have arrived since, so recovery takes up to about
80+twenty seconds.
81+
82+The control bar shows where the room's relay came from — your token, another
83+participant's, or none at all. All of this is optional: with no
84+`VITE_TURN_ENDPOINT` configured at build time the token field is hidden
85+entirely, and calls fall back to STUN plus a free public relay exactly as
86+before. See [`worker/README.md`](worker/README.md) for deployment and costs.
87+
5288 ## Development
5389
5490 ```sh
@@ -62,3 +98,8 @@ second browser or a private window.
6298
6399 `npm run build` type-checks and bundles to `dist/`. Pushes to `main` deploy to
64100 GitHub Pages via `.github/workflows/deploy.yml`.
101+
102+To work on the relay as well, copy `.env.example` to `.env.local` and point
103+`VITE_TURN_ENDPOINT` at a Worker (`cd worker && npm install && npm run dev`
104+serves one locally on port 8787). The Worker is deployed separately from the
105+page, with `npx wrangler deploy`.
src/App.tsxmodified+81−3View file
@@ -5,6 +5,7 @@ import {
55 type ParticipantInfo
66 } from './p2p/network'
77 import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
8+import {TURN_CONFIGURED, type RelayStatus} from './p2p/turn'
89 import {useNetwork} from './useNetwork'
910
1011 // Screen capture is desktop-only in practice; hide the button where the API
@@ -357,20 +358,23 @@ function Tile({
357358 function Landing({
358359 notice,
359360 initialName,
361+ initialTurnToken,
360362 onDismissNotice,
361363 onEnter
362364 }: {
363365 notice: string | null
364366 initialName: string
367+ initialTurnToken: string
365368 onDismissNotice: () => void
366- onEnter: (name: string, room: string) => void
369+ onEnter: (name: string, room: string, turnToken: string) => void
367370 }) {
368371 const [name, setName] = useState(initialName)
369372 const [room, setRoom] = useState(initialRoomFromHash)
373+ const [turnToken, setTurnToken] = useState(initialTurnToken)
370374 const canEnter = name.trim().length > 0 && room.length > 0
371375 const submit = (e: React.FormEvent) => {
372376 e.preventDefault()
373- if (canEnter) onEnter(name, room)
377+ if (canEnter) onEnter(name, room, turnToken)
374378 }
375379 return (
376380 <div
@@ -431,6 +435,30 @@ function Landing({
431435 style={inputStyle}
432436 />
433437 </label>
438+ {TURN_CONFIGURED && (
439+ <details
440+ open={initialTurnToken.length > 0}
441+ style={{marginBottom: '1.25rem'}}
442+ >
443+ <summary style={{cursor: 'pointer', color: '#555'}}>
444+ Relay token (optional)
445+ </summary>
446+ <input
447+ type="password"
448+ value={turnToken}
449+ onChange={e => setTurnToken(e.target.value)}
450+ placeholder="leave blank if you don't have one"
451+ maxLength={200}
452+ style={{...inputStyle, marginTop: '0.5rem'}}
453+ />
454+ <p style={{color: '#888', fontSize: '0.85rem', marginBottom: 0}}>
455+ Some networks won't let two browsers talk directly, and those
456+ need a relay. Only one person in the room needs to enter the
457+ token — everyone else picks the relay up from them
458+ automatically. The token stays in this browser.
459+ </p>
460+ </details>
461+ )}
434462 <button
435463 type="submit"
436464 style={canEnter ? primaryBtn : {...primaryBtn, ...disabledStyle}}
@@ -473,6 +501,53 @@ function CopyLinkButton({compact}: {compact: boolean}) {
473501 )
474502 }
475503
504+// A quiet indicator of whether the room has a relay available, and where it
505+// came from. Only shown on builds that have a credential endpoint at all.
506+function RelayChip({status}: {status: RelayStatus}) {
507+ const [color, label, title] =
508+ status === 'self'
509+ ? [
510+ '#3fb950',
511+ 'Relay on',
512+ 'A relay is available from the token you entered, and has been shared with everyone else in the room.'
513+ ]
514+ : status === 'shared'
515+ ? [
516+ '#3fb950',
517+ 'Relay shared',
518+ 'Another participant shared a relay with the room, so difficult connections can fall back to it.'
519+ ]
520+ : [
521+ '#777',
522+ 'No relay',
523+ "Nobody in the room has entered a relay token. Most connections are fine without one; if someone can't connect, rejoin with a token."
524+ ]
525+ return (
526+ <span
527+ title={title}
528+ style={{
529+ display: 'inline-flex',
530+ alignItems: 'center',
531+ gap: '0.4rem',
532+ fontSize: '0.85rem',
533+ color: '#999',
534+ cursor: 'default'
535+ }}
536+ >
537+ <span
538+ style={{
539+ width: 8,
540+ height: 8,
541+ borderRadius: '50%',
542+ background: color,
543+ flex: '0 0 auto'
544+ }}
545+ />
546+ {label}
547+ </span>
548+ )
549+}
550+
476551 // ---- chat -----------------------------------------------------------------
477552
478553 const useMediaQuery = (query: string): boolean => {
@@ -695,6 +770,7 @@ export default function App() {
695770 screenStream,
696771 settings,
697772 chat,
773+ relay,
698774 notice
699775 } = snapshot
700776
@@ -743,8 +819,9 @@ export default function App() {
743819 <Landing
744820 notice={notice}
745821 initialName={network.savedName}
822+ initialTurnToken={network.savedTurnToken}
746823 onDismissNotice={() => network.dismissNotice()}
747- onEnter={(n, r) => void network.enterRoom(n, r)}
824+ onEnter={(n, r, t) => void network.enterRoom(n, r, t)}
748825 />
749826 )
750827 }
@@ -1124,6 +1201,7 @@ export default function App() {
11241201 ))}
11251202 </select>
11261203 </label>
1204+ {TURN_CONFIGURED && <RelayChip status={relay} />}
11271205 </footer>
11281206 </div>
11291207 )
src/p2p/network.tsmodified+158−2View file
@@ -8,6 +8,15 @@ import {
88 type RoomSettings,
99 type VideoQuality
1010 } from './settings'
11+import {
12+ BASE_ICE_SERVERS,
13+ STUN_SERVERS,
14+ TURN_CONFIGURED,
15+ fetchIceConfig,
16+ sanitizeIceConfig,
17+ type IceConfig,
18+ type RelayStatus
19+} from './turn'
1120
1221 // ---------------------------------------------------------------------------
1322 // CommonRoom network layer: a full-mesh group video call.
@@ -63,6 +72,8 @@ type ControlMsg =
6372 | ({t: 'set'} & SettingEntry)
6473 | {t: 'mute'; audio: boolean; video: boolean}
6574 | {t: 'chat'; text: string}
75+ /** TURN credentials, so one person's token covers the whole room. */
76+ | {t: 'ice'; iceServers: RTCIceServer[]; expiresAt: number}
6677 | {t: 'bye'}
6778
6879 const ANNOUNCE_INTERVAL_MS = 5000
@@ -74,6 +85,11 @@ const PRESENCE_TTL_MS = 15000
7485 const CONNECT_RETRY_MS = 15000
7586
7687 const NAME_KEY = 'commonroom:name'
88+const TURN_TOKEN_KEY = 'commonroom:turnToken'
89+
90+/** Re-mint our relay credentials this long before they lapse, so a long call
91+ * never runs out mid-session. */
92+const ICE_REFRESH_MARGIN_MS = 5 * 60 * 1000
7793
7894 const CHAT_MAX_LENGTH = 2000
7995 const CHAT_LOG_CAP = 500
@@ -130,6 +146,8 @@ export interface Snapshot {
130146 screenStream: MediaStream | null
131147 settings: RoomSettings
132148 chat: ChatItem[]
149+ /** Where our TURN credentials came from (ours, a peer's, or none). */
150+ relay: RelayStatus
133151 notice: string | null
134152 }
135153
@@ -155,6 +173,14 @@ export class Network {
155173 private videoMuted = true
156174 private audioCtx: AudioContext | null = null
157175
176+ // Relay (TURN) credentials for this room, if anyone in it has a token. See
177+ // turn.ts for the scheme; the sharing itself is below under "relay".
178+ private ice: IceConfig | null = null
179+ /** True when `ice` was minted with OUR token rather than shared with us. */
180+ private iceFromSelf = false
181+ private iceTimer: number | null = null
182+ private turnToken = ''
183+
158184 private settings: RoomSettings = {...DEFAULT_SETTINGS}
159185 /** Per-key revision + setter for the last-writer-wins settings sync. */
160186 private settingsMeta: Partial<
@@ -182,6 +208,11 @@ export class Network {
182208 /** Last name used on this browser, for prefilling the join form. */
183209 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
184210
211+ /** Last relay token used on this browser, likewise. It stays on this device:
212+ * what gets shared with the room is the credential it buys, never the
213+ * token itself. */
214+ readonly savedTurnToken: string = localStorage.getItem(TURN_TOKEN_KEY) ?? ''
215+
185216 constructor() {
186217 this.rebuildSnapshot()
187218 window.addEventListener('online', () => void this.announce())
@@ -194,14 +225,16 @@ export class Network {
194225
195226 // ---- joining and leaving ----------------------------------------------
196227
197- async enterRoom(name: string, room: string) {
228+ async enterRoom(name: string, room: string, turnToken = '') {
198229 if (this.phase !== 'landing') return
199230 const nm = name.trim().slice(0, 40)
200231 const rm = room.replace(/\s+/g, '').slice(0, 100)
201232 if (!nm || !rm) return
202233 this.name = nm
203234 this.roomId = rm
235+ this.turnToken = turnToken.trim()
204236 localStorage.setItem(NAME_KEY, nm)
237+ localStorage.setItem(TURN_TOKEN_KEY, this.turnToken)
205238 // Put the room in the URL so the address bar is the invite link.
206239 try {
207240 location.hash = encodeURIComponent(rm)
@@ -237,6 +270,15 @@ export class Network {
237270
238271 this.root = await roomTopic(rm)
239272 if (this.joinSeq !== seq) return
273+
274+ // Mint relay credentials BEFORE the mesh starts, so our very first
275+ // connections already offer relay candidates. Peers without a token pick
276+ // these up over the control channel once they are connected to someone.
277+ if (TURN_CONFIGURED && this.turnToken) {
278+ await this.mintIce(seq)
279+ if (this.joinSeq !== seq) return
280+ }
281+
240282 const selfTopic = await peerTopic(this.root, selfId)
241283 if (this.joinSeq !== seq) return
242284
@@ -305,8 +347,14 @@ export class Network {
305347 for (const u of this.unsubs.splice(0)) u()
306348 if (this.announceTimer !== null) clearInterval(this.announceTimer)
307349 if (this.sweepTimer !== null) clearInterval(this.sweepTimer)
350+ if (this.iceTimer !== null) clearTimeout(this.iceTimer)
308351 this.announceTimer = null
309352 this.sweepTimer = null
353+ this.iceTimer = null
354+ // Credentials are per-room (they carry a per-room analytics tag) and, when
355+ // shared, belong to whoever was in that room — don't carry them onward.
356+ this.ice = null
357+ this.iceFromSelf = false
310358 if (this.screenStream) {
311359 for (const t of this.screenStream.getTracks()) t.stop()
312360 this.screenStream = null
@@ -450,7 +498,7 @@ export class Network {
450498 }
451499
452500 private createPeer(peerId: string, initiator: boolean): Conn {
453- const peer = new Peer(initiator, this.outgoingStream())
501+ const peer = new Peer(initiator, this.outgoingStream(), this.iceServers())
454502 const conn: Conn = {
455503 peer,
456504 createdAt: Date.now(),
@@ -473,6 +521,7 @@ export class Network {
473521 connect: () => {
474522 conn.connected = true
475523 this.sendHello(conn)
524+ this.sendIce(conn)
476525 this.applyVideoParamsTo(conn)
477526 this.rebuildSnapshot()
478527 },
@@ -524,6 +573,104 @@ export class Network {
524573 }
525574 }
526575
576+ // ---- relay (TURN) ---------------------------------------------------------
577+ //
578+ // Relaying costs bandwidth, so the credentials are bought with a token that
579+ // only some participants have. Rather than require the token from everyone,
580+ // whoever has one mints a short-lived ICE configuration and shares it over
581+ // the control channels; everyone else adopts it and gains relay candidates
582+ // of their own. One person's token therefore covers the whole room.
583+ //
584+ // Sharing the credential rather than the token is what makes this safe to do
585+ // over the mesh: the token never leaves the browser it was typed into, and
586+ // what does travel expires on its own and can be revoked at the Worker.
587+ //
588+ // Note the bootstrapping order. Credentials arrive over a connection, so they
589+ // cannot help the connection that carried them — a peer learns them from the
590+ // first peer it manages to reach (usually the token holder, whose relay
591+ // candidates make that first connection work even for the peer that has
592+ // none) and uses them for every connection after that. A pair that stalls in
593+ // the meantime is rebuilt by the CONNECT_RETRY_MS retry in maybeConnect,
594+ // which reads iceServers() afresh, so it picks up whatever has arrived since.
595+
596+ /** The ICE configuration for a NEW connection. */
597+ private iceServers(): RTCIceServer[] {
598+ const ice = this.ice
599+ if (!ice || ice.expiresAt <= Date.now()) return BASE_ICE_SERVERS
600+ // Keep the plain STUN servers alongside the relay: reflexive candidates
601+ // are what let most pairs avoid the relay altogether.
602+ return [...STUN_SERVERS, ...ice.iceServers]
603+ }
604+
605+ private relayStatus(): RelayStatus {
606+ if (!this.ice || this.ice.expiresAt <= Date.now()) return 'off'
607+ return this.iceFromSelf ? 'self' : 'shared'
608+ }
609+
610+ /** Buy credentials with our token and share them with the room. */
611+ private async mintIce(seq: number, refresh = false) {
612+ let cfg: IceConfig
613+ try {
614+ // The Worker tags the credential with this for per-room usage analytics.
615+ // It is a prefix of the hashed room topic, so the room name itself is
616+ // never sent anywhere.
617+ cfg = await fetchIceConfig(this.turnToken, this.root.slice(0, 16))
618+ } catch (err) {
619+ if (this.joinSeq !== seq) return
620+ if (refresh) {
621+ // Mid-call, and the credential we already have is still good for a few
622+ // more minutes: keep it, say nothing, and try again shortly.
623+ this.scheduleIceRefresh(seq)
624+ return
625+ }
626+ const why = err instanceof Error ? err.message : 'the request failed'
627+ const msg = `Relay unavailable — ${why}. Calls will use direct connections only, which may not work for everyone.`
628+ this.notice = this.notice ? `${this.notice} ${msg}` : msg
629+ this.rebuildSnapshot()
630+ return
631+ }
632+ if (this.joinSeq !== seq) return
633+ this.ice = cfg
634+ this.iceFromSelf = true
635+ this.scheduleIceRefresh(seq)
636+ // No-op at join time (no peers yet); this is what carries a REFRESHED
637+ // credential out to a room that is already assembled.
638+ this.broadcastControl({t: 'ice', ...cfg})
639+ this.rebuildSnapshot()
640+ }
641+
642+ private scheduleIceRefresh(seq: number) {
643+ if (this.iceTimer !== null) clearTimeout(this.iceTimer)
644+ const due = (this.ice?.expiresAt ?? 0) - Date.now() - ICE_REFRESH_MARGIN_MS
645+ this.iceTimer = window.setTimeout(
646+ () => {
647+ this.iceTimer = null
648+ if (this.joinSeq === seq && this.phase === 'room') {
649+ void this.mintIce(seq, true)
650+ }
651+ },
652+ // The floor also paces retries after a failed refresh, which reschedules
653+ // itself with an expiry already in the past.
654+ Math.max(due, 60_000)
655+ )
656+ }
657+
658+ private sendIce(conn: Conn) {
659+ const ice = this.ice
660+ if (!ice || ice.expiresAt <= Date.now()) return
661+ conn.peer.send(JSON.stringify({t: 'ice', ...ice} satisfies ControlMsg))
662+ }
663+
664+ /** Take on a configuration another participant shared with us. Our own
665+ * credentials always win: they are the ones we can refresh. */
666+ private adoptIce(cfg: IceConfig) {
667+ if (this.iceFromSelf && this.ice && this.ice.expiresAt > Date.now()) return
668+ if (this.ice && this.ice.expiresAt >= cfg.expiresAt) return // no better
669+ this.ice = cfg
670+ this.iceFromSelf = false
671+ this.rebuildSnapshot()
672+ }
673+
527674 // ---- control channel ----------------------------------------------------
528675
529676 private broadcastControl(msg: ControlMsg) {
@@ -614,6 +761,14 @@ export class Network {
614761 this.rebuildSnapshot()
615762 return
616763 }
764+ case 'ice': {
765+ // Untrusted input: a peer could send anything here, so the list is
766+ // validated down to well-formed ICE URLs before it goes near a
767+ // RTCPeerConnection.
768+ const cfg = sanitizeIceConfig(msg.iceServers, msg.expiresAt)
769+ if (cfg) this.adoptIce(cfg)
770+ return
771+ }
617772 case 'bye': {
618773 this.presence.delete(peerId)
619774 conn.peer.destroy() // its close handler removes it and rebuilds
@@ -946,6 +1101,7 @@ export class Network {
9461101 screenStream: this.screenStream,
9471102 settings: this.settings,
9481103 chat: this.chat,
1104+ relay: this.relayStatus(),
9491105 notice: this.notice
9501106 }
9511107 for (const l of this.listeners) l()
src/p2p/peer.tsmodified+9−19View file
@@ -28,23 +28,6 @@ export interface PeerHandlers {
2828 close: () => void
2929 }
3030
31-export const ICE_SERVERS: RTCIceServer[] = [
32- {urls: 'stun:stun.l.google.com:19302'},
33- {urls: 'stun:stun1.l.google.com:19302'},
34- {urls: 'stun:stun.cloudflare.com:3478'},
35- // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
36- // fails (symmetric NAT, hairpinning, host-candidate blocking).
37- {
38- urls: [
39- 'turn:openrelay.metered.ca:80',
40- 'turn:openrelay.metered.ca:443',
41- 'turns:openrelay.metered.ca:443'
42- ],
43- username: 'openrelayproject',
44- credential: 'openrelayproject'
45- }
46-]
47-
4831 // A media connection can survive a brief network blip: 'disconnected' often
4932 // recovers on its own, so only tear down if it persists this long.
5033 const DISCONNECT_GRACE_MS = 5000
@@ -59,8 +42,15 @@ export class Peer {
5942 private disconnectTimer: number | null = null
6043 private closed = false
6144
62- constructor(private initiator: boolean, localStream: MediaStream) {
63- this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
45+ /** `iceServers` is fixed for the life of the connection: relay credentials
46+ * that arrive later apply to the NEXT connection to this peer, not this one
47+ * (see the retry in network.ts). */
48+ constructor(
49+ private initiator: boolean,
50+ localStream: MediaStream,
51+ iceServers: RTCIceServer[]
52+ ) {
53+ this.pc = new RTCPeerConnection({iceServers})
6454
6555 // Both sides add their tracks up front: the initiator's single offer then
6656 // covers all media, and the answerer's tracks ride back in the answer.
src/p2p/turn.tsadded+183−0View file
@@ -0,0 +1,183 @@
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.
15+
16+/** Where to exchange a room token for TURN credentials (build-time config). */
17+export const TURN_ENDPOINT = (import.meta.env.VITE_TURN_ENDPOINT ?? '').trim()
18+
19+/** Whether this build has a credential endpoint at all. */
20+export const TURN_CONFIGURED = TURN_ENDPOINT.length > 0
21+
22+/** An ICE configuration together with the moment its credentials stop working.
23+ * This is the unit that gets minted, cached, shared and refreshed. */
24+export interface IceConfig {
25+ iceServers: RTCIceServer[]
26+ /** Epoch ms; after this the TURN username/credential pair is dead. */
27+ expiresAt: number
28+}
29+
30+/** Where our relay configuration came from, for the status indicator. */
31+export 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'
38+
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. */
41+export 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+]
46+
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. */
50+export 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+]
62+
63+const FETCH_TIMEOUT_MS = 10000
64+
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. */
72+export 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+ }
99+
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+ }
109+
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+}
122+
123+const MAX_SERVERS = 8
124+const MAX_URLS = 12
125+const 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.
128+const URL_RE = /^(?:stuns?|turns?):[A-Za-z0-9._~-]+(?::\d{1,5})?(?:\?transport=(?:udp|tcp))?$/
129+
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. */
137+export 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+}
172+
173+/** Validate an {iceServers, expiresAt} pair shared by another participant. */
174+export 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+}
src/vite-env.d.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+/// <reference types="vite/client" />
2+
3+interface ImportMetaEnv {
4+ /** URL of the TURN credential Worker (see worker/). Optional: without it the
5+ * relay-token field is hidden and calls use STUN plus the public fallback. */
6+ readonly VITE_TURN_ENDPOINT?: string
7+}
8+
9+interface ImportMeta {
10+ readonly env: ImportMetaEnv
11+}
tsconfig.tsbuildinfomodified+1−1View file
@@ -1 +1 @@
1-{"root":["./src/App.tsx","./src/main.tsx","./src/useNetwork.ts","./src/p2p/identity.ts","./src/p2p/network.ts","./src/p2p/nostr.ts","./src/p2p/peer.ts","./src/p2p/settings.ts"],"version":"5.9.3"}
1+{"root":["./src/App.tsx","./src/main.tsx","./src/useNetwork.ts","./src/vite-env.d.ts","./src/p2p/identity.ts","./src/p2p/network.ts","./src/p2p/nostr.ts","./src/p2p/peer.ts","./src/p2p/settings.ts","./src/p2p/turn.ts"],"version":"5.9.3"}
worker/.dev.vars.exampleadded+3−0View file
@@ -0,0 +1,3 @@
1+TURN_KEY_ID=test-key-id
2+TURN_KEY_API_TOKEN=test-api-token
3+ROOM_TOKENS=goodtoken, secondtoken
worker/.gitignoreadded+3−0View file
@@ -0,0 +1,3 @@
1+node_modules
2+.wrangler
3+.dev.vars
worker/README.mdadded+98−0View file
@@ -0,0 +1,98 @@
1+# commonroom-turn
2+
3+A Cloudflare Worker that mints short-lived TURN credentials for CommonRoom.
4+
5+Most WebRTC connections pair up directly, but some cannot: symmetric NAT at
6+both ends, or a firewall that only permits outbound 443. Those pairs need a
7+relay, and CommonRoom uses [Cloudflare Realtime
8+TURN](https://developers.cloudflare.com/realtime/turn/) for it. Relaying is
9+metered, so the credentials are gated behind a token rather than handed to
10+anyone who loads the page.
11+
12+The Worker exists because the browser cannot call Cloudflare's TURN API
13+itself: doing so would put the long-lived API token in a static bundle, where
14+anyone could mint unlimited credentials against the account. The Worker holds
15+that token, checks a room token, and returns only a short-lived ICE
16+configuration.
17+
18+```
19+POST / Authorization: Bearer <room token>
20+{"room": "<opaque tag, optional>"}
21+-> 200 {"iceServers": [...], "expiresAt": <epoch ms>}
22+```
23+
24+## Setup
25+
26+1. **Create a TURN key.** In the Cloudflare dashboard, go to Realtime → TURN
27+ and create a key. You get a TURN key ID and an API token.
28+
29+2. **Choose room tokens.** These are the strings people type into CommonRoom's
30+ landing form. Any hard-to-guess strings will do:
31+
32+ ```sh
33+ openssl rand -base64 24
34+ ```
35+
36+ Several can be configured at once (comma-separated), so different groups can
37+ have different tokens and one can be withdrawn without disturbing the others.
38+
39+3. **Set the secrets and deploy.**
40+
41+ ```sh
42+ cd worker
43+ npm install
44+ npx wrangler secret put TURN_KEY_ID
45+ npx wrangler secret put TURN_KEY_API_TOKEN
46+ npx wrangler secret put ROOM_TOKENS
47+ npx wrangler deploy
48+ ```
49+
50+4. **Point the app at it.** Edit `ALLOWED_ORIGINS` in `wrangler.jsonc` to the
51+ origin serving CommonRoom, then set `VITE_TURN_ENDPOINT` to the deployed
52+ Worker URL — in `.env.local` for local development, and as the repository
53+ variable `VITE_TURN_ENDPOINT` for the GitHub Pages build. That URL is not a
54+ secret; the room token is what protects the endpoint.
55+
56+## Local development
57+
58+```sh
59+cp .dev.vars.example .dev.vars # then edit in your real key ID and API token
60+npm run dev
61+```
62+
63+`.dev.vars` is gitignored. With the example values left as they are, token
64+checks work (`goodtoken` is accepted, anything else gets a 401) but the
65+upstream call to Cloudflare returns 404, which the Worker reports as a 502 —
66+enough to exercise everything except the credential itself.
67+
68+## Cost
69+
70+Cloudflare Realtime TURN is $0.05/GB after a free tier of 1,000 GB per month,
71+billed on data sent from the Cloudflare edge to the TURN client. A relayed
72+participant in a four-person room at the default `medium` quality preset
73+receives roughly 1 GB per hour, so the free tier covers on the order of 800
74+participant-hours per month. Only the connections that actually need a relay
75+use one; everyone else pairs directly and costs nothing.
76+
77+Usage is visible per room in the Realtime analytics: the client sends a prefix
78+of the hashed room topic as the credential's `customIdentifier`, which
79+distinguishes rooms from each other without revealing any room name.
80+
81+## Notes on the token
82+
83+A room token buys a credential, so treat it as a spending key. It is checked in
84+constant time, and `CREDENTIAL_TTL` bounds how long an issued credential stays
85+useful (Cloudflare's maximum is 48 hours). Credentials can also be revoked
86+before they expire:
87+
88+```sh
89+curl --request POST \
90+ https://rtc.live.cloudflare.com/v1/turn/keys/$TURN_KEY_ID/credentials/$USERNAME/revoke \
91+ --header "Authorization: Bearer $TURN_KEY_API_TOKEN"
92+```
93+
94+Note that a credential minted by one participant is shared with everyone else
95+in that room (this is the point — only one person needs a token), so anyone in
96+the room could in principle use it elsewhere until it expires. Keeping
97+`CREDENTIAL_TTL` modest is the main defense; per-room analytics is how abuse
98+would be noticed.
worker/package-lock.jsonadded+1610−0View file
@@ -0,0 +1,1610 @@
1+{
2+ "name": "commonroom-turn",
3+ "version": "0.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "commonroom-turn",
9+ "version": "0.0.0",
10+ "devDependencies": {
11+ "@cloudflare/workers-types": "^5.20260811.1",
12+ "typescript": "^5.6.3",
13+ "wrangler": "^4.122.0"
14+ }
15+ },
16+ "node_modules/@cloudflare/kv-asset-handler": {
17+ "version": "0.5.0",
18+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
19+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
20+ "dev": true,
21+ "license": "MIT OR Apache-2.0",
22+ "engines": {
23+ "node": ">=22.0.0"
24+ }
25+ },
26+ "node_modules/@cloudflare/unenv-preset": {
27+ "version": "2.16.1",
28+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
29+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
30+ "dev": true,
31+ "license": "MIT OR Apache-2.0",
32+ "peerDependencies": {
33+ "unenv": "2.0.0-rc.24",
34+ "workerd": ">1.20260305.0 <2.0.0-0"
35+ },
36+ "peerDependenciesMeta": {
37+ "workerd": {
38+ "optional": true
39+ }
40+ }
41+ },
42+ "node_modules/@cloudflare/workerd-darwin-64": {
43+ "version": "1.20260811.1",
44+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260811.1.tgz",
45+ "integrity": "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==",
46+ "cpu": [
47+ "x64"
48+ ],
49+ "dev": true,
50+ "license": "Apache-2.0",
51+ "optional": true,
52+ "os": [
53+ "darwin"
54+ ],
55+ "engines": {
56+ "node": ">=16"
57+ }
58+ },
59+ "node_modules/@cloudflare/workerd-darwin-arm64": {
60+ "version": "1.20260811.1",
61+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260811.1.tgz",
62+ "integrity": "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==",
63+ "cpu": [
64+ "arm64"
65+ ],
66+ "dev": true,
67+ "license": "Apache-2.0",
68+ "optional": true,
69+ "os": [
70+ "darwin"
71+ ],
72+ "engines": {
73+ "node": ">=16"
74+ }
75+ },
76+ "node_modules/@cloudflare/workerd-linux-64": {
77+ "version": "1.20260811.1",
78+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260811.1.tgz",
79+ "integrity": "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==",
80+ "cpu": [
81+ "x64"
82+ ],
83+ "dev": true,
84+ "license": "Apache-2.0",
85+ "optional": true,
86+ "os": [
87+ "linux"
88+ ],
89+ "engines": {
90+ "node": ">=16"
91+ }
92+ },
93+ "node_modules/@cloudflare/workerd-linux-arm64": {
94+ "version": "1.20260811.1",
95+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260811.1.tgz",
96+ "integrity": "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==",
97+ "cpu": [
98+ "arm64"
99+ ],
100+ "dev": true,
101+ "license": "Apache-2.0",
102+ "optional": true,
103+ "os": [
104+ "linux"
105+ ],
106+ "engines": {
107+ "node": ">=16"
108+ }
109+ },
110+ "node_modules/@cloudflare/workerd-windows-64": {
111+ "version": "1.20260811.1",
112+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260811.1.tgz",
113+ "integrity": "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==",
114+ "cpu": [
115+ "x64"
116+ ],
117+ "dev": true,
118+ "license": "Apache-2.0",
119+ "optional": true,
120+ "os": [
121+ "win32"
122+ ],
123+ "engines": {
124+ "node": ">=16"
125+ }
126+ },
127+ "node_modules/@cloudflare/workers-types": {
128+ "version": "5.20260812.1",
129+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260812.1.tgz",
130+ "integrity": "sha512-eG2aQdUWH8RYEubpBjUE98L2G57JYzGEcI8PgROTjt9YjunbEnPBHP4371Usj8v8pTWcXtbOmI+LRCXF4KhJgQ==",
131+ "dev": true,
132+ "license": "MIT OR Apache-2.0"
133+ },
134+ "node_modules/@cspotcode/source-map-support": {
135+ "version": "0.8.1",
136+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
137+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
138+ "dev": true,
139+ "license": "MIT",
140+ "dependencies": {
141+ "@jridgewell/trace-mapping": "0.3.9"
142+ },
143+ "engines": {
144+ "node": ">=12"
145+ }
146+ },
147+ "node_modules/@emnapi/runtime": {
148+ "version": "1.11.3",
149+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
150+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
151+ "dev": true,
152+ "license": "MIT",
153+ "optional": true,
154+ "dependencies": {
155+ "tslib": "^2.4.0"
156+ }
157+ },
158+ "node_modules/@esbuild/aix-ppc64": {
159+ "version": "0.28.1",
160+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
161+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
162+ "cpu": [
163+ "ppc64"
164+ ],
165+ "dev": true,
166+ "license": "MIT",
167+ "optional": true,
168+ "os": [
169+ "aix"
170+ ],
171+ "engines": {
172+ "node": ">=18"
173+ }
174+ },
175+ "node_modules/@esbuild/android-arm": {
176+ "version": "0.28.1",
177+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
178+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
179+ "cpu": [
180+ "arm"
181+ ],
182+ "dev": true,
183+ "license": "MIT",
184+ "optional": true,
185+ "os": [
186+ "android"
187+ ],
188+ "engines": {
189+ "node": ">=18"
190+ }
191+ },
192+ "node_modules/@esbuild/android-arm64": {
193+ "version": "0.28.1",
194+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
195+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
196+ "cpu": [
197+ "arm64"
198+ ],
199+ "dev": true,
200+ "license": "MIT",
201+ "optional": true,
202+ "os": [
203+ "android"
204+ ],
205+ "engines": {
206+ "node": ">=18"
207+ }
208+ },
209+ "node_modules/@esbuild/android-x64": {
210+ "version": "0.28.1",
211+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
212+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
213+ "cpu": [
214+ "x64"
215+ ],
216+ "dev": true,
217+ "license": "MIT",
218+ "optional": true,
219+ "os": [
220+ "android"
221+ ],
222+ "engines": {
223+ "node": ">=18"
224+ }
225+ },
226+ "node_modules/@esbuild/darwin-arm64": {
227+ "version": "0.28.1",
228+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
229+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
230+ "cpu": [
231+ "arm64"
232+ ],
233+ "dev": true,
234+ "license": "MIT",
235+ "optional": true,
236+ "os": [
237+ "darwin"
238+ ],
239+ "engines": {
240+ "node": ">=18"
241+ }
242+ },
243+ "node_modules/@esbuild/darwin-x64": {
244+ "version": "0.28.1",
245+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
246+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
247+ "cpu": [
248+ "x64"
249+ ],
250+ "dev": true,
251+ "license": "MIT",
252+ "optional": true,
253+ "os": [
254+ "darwin"
255+ ],
256+ "engines": {
257+ "node": ">=18"
258+ }
259+ },
260+ "node_modules/@esbuild/freebsd-arm64": {
261+ "version": "0.28.1",
262+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
263+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
264+ "cpu": [
265+ "arm64"
266+ ],
267+ "dev": true,
268+ "license": "MIT",
269+ "optional": true,
270+ "os": [
271+ "freebsd"
272+ ],
273+ "engines": {
274+ "node": ">=18"
275+ }
276+ },
277+ "node_modules/@esbuild/freebsd-x64": {
278+ "version": "0.28.1",
279+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
280+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
281+ "cpu": [
282+ "x64"
283+ ],
284+ "dev": true,
285+ "license": "MIT",
286+ "optional": true,
287+ "os": [
288+ "freebsd"
289+ ],
290+ "engines": {
291+ "node": ">=18"
292+ }
293+ },
294+ "node_modules/@esbuild/linux-arm": {
295+ "version": "0.28.1",
296+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
297+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
298+ "cpu": [
299+ "arm"
300+ ],
301+ "dev": true,
302+ "license": "MIT",
303+ "optional": true,
304+ "os": [
305+ "linux"
306+ ],
307+ "engines": {
308+ "node": ">=18"
309+ }
310+ },
311+ "node_modules/@esbuild/linux-arm64": {
312+ "version": "0.28.1",
313+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
314+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
315+ "cpu": [
316+ "arm64"
317+ ],
318+ "dev": true,
319+ "license": "MIT",
320+ "optional": true,
321+ "os": [
322+ "linux"
323+ ],
324+ "engines": {
325+ "node": ">=18"
326+ }
327+ },
328+ "node_modules/@esbuild/linux-ia32": {
329+ "version": "0.28.1",
330+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
331+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
332+ "cpu": [
333+ "ia32"
334+ ],
335+ "dev": true,
336+ "license": "MIT",
337+ "optional": true,
338+ "os": [
339+ "linux"
340+ ],
341+ "engines": {
342+ "node": ">=18"
343+ }
344+ },
345+ "node_modules/@esbuild/linux-loong64": {
346+ "version": "0.28.1",
347+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
348+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
349+ "cpu": [
350+ "loong64"
351+ ],
352+ "dev": true,
353+ "license": "MIT",
354+ "optional": true,
355+ "os": [
356+ "linux"
357+ ],
358+ "engines": {
359+ "node": ">=18"
360+ }
361+ },
362+ "node_modules/@esbuild/linux-mips64el": {
363+ "version": "0.28.1",
364+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
365+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
366+ "cpu": [
367+ "mips64el"
368+ ],
369+ "dev": true,
370+ "license": "MIT",
371+ "optional": true,
372+ "os": [
373+ "linux"
374+ ],
375+ "engines": {
376+ "node": ">=18"
377+ }
378+ },
379+ "node_modules/@esbuild/linux-ppc64": {
380+ "version": "0.28.1",
381+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
382+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
383+ "cpu": [
384+ "ppc64"
385+ ],
386+ "dev": true,
387+ "license": "MIT",
388+ "optional": true,
389+ "os": [
390+ "linux"
391+ ],
392+ "engines": {
393+ "node": ">=18"
394+ }
395+ },
396+ "node_modules/@esbuild/linux-riscv64": {
397+ "version": "0.28.1",
398+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
399+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
400+ "cpu": [
401+ "riscv64"
402+ ],
403+ "dev": true,
404+ "license": "MIT",
405+ "optional": true,
406+ "os": [
407+ "linux"
408+ ],
409+ "engines": {
410+ "node": ">=18"
411+ }
412+ },
413+ "node_modules/@esbuild/linux-s390x": {
414+ "version": "0.28.1",
415+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
416+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
417+ "cpu": [
418+ "s390x"
419+ ],
420+ "dev": true,
421+ "license": "MIT",
422+ "optional": true,
423+ "os": [
424+ "linux"
425+ ],
426+ "engines": {
427+ "node": ">=18"
428+ }
429+ },
430+ "node_modules/@esbuild/linux-x64": {
431+ "version": "0.28.1",
432+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
433+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
434+ "cpu": [
435+ "x64"
436+ ],
437+ "dev": true,
438+ "license": "MIT",
439+ "optional": true,
440+ "os": [
441+ "linux"
442+ ],
443+ "engines": {
444+ "node": ">=18"
445+ }
446+ },
447+ "node_modules/@esbuild/netbsd-arm64": {
448+ "version": "0.28.1",
449+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
450+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
451+ "cpu": [
452+ "arm64"
453+ ],
454+ "dev": true,
455+ "license": "MIT",
456+ "optional": true,
457+ "os": [
458+ "netbsd"
459+ ],
460+ "engines": {
461+ "node": ">=18"
462+ }
463+ },
464+ "node_modules/@esbuild/netbsd-x64": {
465+ "version": "0.28.1",
466+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
467+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
468+ "cpu": [
469+ "x64"
470+ ],
471+ "dev": true,
472+ "license": "MIT",
473+ "optional": true,
474+ "os": [
475+ "netbsd"
476+ ],
477+ "engines": {
478+ "node": ">=18"
479+ }
480+ },
481+ "node_modules/@esbuild/openbsd-arm64": {
482+ "version": "0.28.1",
483+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
484+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
485+ "cpu": [
486+ "arm64"
487+ ],
488+ "dev": true,
489+ "license": "MIT",
490+ "optional": true,
491+ "os": [
492+ "openbsd"
493+ ],
494+ "engines": {
495+ "node": ">=18"
496+ }
497+ },
498+ "node_modules/@esbuild/openbsd-x64": {
499+ "version": "0.28.1",
500+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
501+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
502+ "cpu": [
503+ "x64"
504+ ],
505+ "dev": true,
506+ "license": "MIT",
507+ "optional": true,
508+ "os": [
509+ "openbsd"
510+ ],
511+ "engines": {
512+ "node": ">=18"
513+ }
514+ },
515+ "node_modules/@esbuild/openharmony-arm64": {
516+ "version": "0.28.1",
517+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
518+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
519+ "cpu": [
520+ "arm64"
521+ ],
522+ "dev": true,
523+ "license": "MIT",
524+ "optional": true,
525+ "os": [
526+ "openharmony"
527+ ],
528+ "engines": {
529+ "node": ">=18"
530+ }
531+ },
532+ "node_modules/@esbuild/sunos-x64": {
533+ "version": "0.28.1",
534+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
535+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
536+ "cpu": [
537+ "x64"
538+ ],
539+ "dev": true,
540+ "license": "MIT",
541+ "optional": true,
542+ "os": [
543+ "sunos"
544+ ],
545+ "engines": {
546+ "node": ">=18"
547+ }
548+ },
549+ "node_modules/@esbuild/win32-arm64": {
550+ "version": "0.28.1",
551+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
552+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
553+ "cpu": [
554+ "arm64"
555+ ],
556+ "dev": true,
557+ "license": "MIT",
558+ "optional": true,
559+ "os": [
560+ "win32"
561+ ],
562+ "engines": {
563+ "node": ">=18"
564+ }
565+ },
566+ "node_modules/@esbuild/win32-ia32": {
567+ "version": "0.28.1",
568+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
569+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
570+ "cpu": [
571+ "ia32"
572+ ],
573+ "dev": true,
574+ "license": "MIT",
575+ "optional": true,
576+ "os": [
577+ "win32"
578+ ],
579+ "engines": {
580+ "node": ">=18"
581+ }
582+ },
583+ "node_modules/@esbuild/win32-x64": {
584+ "version": "0.28.1",
585+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
586+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
587+ "cpu": [
588+ "x64"
589+ ],
590+ "dev": true,
591+ "license": "MIT",
592+ "optional": true,
593+ "os": [
594+ "win32"
595+ ],
596+ "engines": {
597+ "node": ">=18"
598+ }
599+ },
600+ "node_modules/@img/colour": {
601+ "version": "1.1.0",
602+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
603+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
604+ "dev": true,
605+ "license": "MIT",
606+ "engines": {
607+ "node": ">=18"
608+ }
609+ },
610+ "node_modules/@img/sharp-darwin-arm64": {
611+ "version": "0.35.2",
612+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
613+ "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
614+ "cpu": [
615+ "arm64"
616+ ],
617+ "dev": true,
618+ "license": "Apache-2.0",
619+ "optional": true,
620+ "os": [
621+ "darwin"
622+ ],
623+ "engines": {
624+ "node": ">=20.9.0"
625+ },
626+ "funding": {
627+ "url": "https://opencollective.com/libvips"
628+ },
629+ "optionalDependencies": {
630+ "@img/sharp-libvips-darwin-arm64": "1.3.1"
631+ }
632+ },
633+ "node_modules/@img/sharp-darwin-x64": {
634+ "version": "0.35.2",
635+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
636+ "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
637+ "cpu": [
638+ "x64"
639+ ],
640+ "dev": true,
641+ "license": "Apache-2.0",
642+ "optional": true,
643+ "os": [
644+ "darwin"
645+ ],
646+ "engines": {
647+ "node": ">=20.9.0"
648+ },
649+ "funding": {
650+ "url": "https://opencollective.com/libvips"
651+ },
652+ "optionalDependencies": {
653+ "@img/sharp-libvips-darwin-x64": "1.3.1"
654+ }
655+ },
656+ "node_modules/@img/sharp-freebsd-wasm32": {
657+ "version": "0.35.2",
658+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
659+ "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
660+ "dev": true,
661+ "license": "Apache-2.0",
662+ "optional": true,
663+ "os": [
664+ "freebsd"
665+ ],
666+ "dependencies": {
667+ "@img/sharp-wasm32": "0.35.2"
668+ },
669+ "engines": {
670+ "node": ">=20.9.0"
671+ },
672+ "funding": {
673+ "url": "https://opencollective.com/libvips"
674+ }
675+ },
676+ "node_modules/@img/sharp-libvips-darwin-arm64": {
677+ "version": "1.3.1",
678+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
679+ "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
680+ "cpu": [
681+ "arm64"
682+ ],
683+ "dev": true,
684+ "license": "LGPL-3.0-or-later",
685+ "optional": true,
686+ "os": [
687+ "darwin"
688+ ],
689+ "funding": {
690+ "url": "https://opencollective.com/libvips"
691+ }
692+ },
693+ "node_modules/@img/sharp-libvips-darwin-x64": {
694+ "version": "1.3.1",
695+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
696+ "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
697+ "cpu": [
698+ "x64"
699+ ],
700+ "dev": true,
701+ "license": "LGPL-3.0-or-later",
702+ "optional": true,
703+ "os": [
704+ "darwin"
705+ ],
706+ "funding": {
707+ "url": "https://opencollective.com/libvips"
708+ }
709+ },
710+ "node_modules/@img/sharp-libvips-linux-arm": {
711+ "version": "1.3.1",
712+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
713+ "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
714+ "cpu": [
715+ "arm"
716+ ],
717+ "dev": true,
718+ "libc": [
719+ "glibc"
720+ ],
721+ "license": "LGPL-3.0-or-later",
722+ "optional": true,
723+ "os": [
724+ "linux"
725+ ],
726+ "funding": {
727+ "url": "https://opencollective.com/libvips"
728+ }
729+ },
730+ "node_modules/@img/sharp-libvips-linux-arm64": {
731+ "version": "1.3.1",
732+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
733+ "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
734+ "cpu": [
735+ "arm64"
736+ ],
737+ "dev": true,
738+ "libc": [
739+ "glibc"
740+ ],
741+ "license": "LGPL-3.0-or-later",
742+ "optional": true,
743+ "os": [
744+ "linux"
745+ ],
746+ "funding": {
747+ "url": "https://opencollective.com/libvips"
748+ }
749+ },
750+ "node_modules/@img/sharp-libvips-linux-ppc64": {
751+ "version": "1.3.1",
752+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
753+ "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
754+ "cpu": [
755+ "ppc64"
756+ ],
757+ "dev": true,
758+ "libc": [
759+ "glibc"
760+ ],
761+ "license": "LGPL-3.0-or-later",
762+ "optional": true,
763+ "os": [
764+ "linux"
765+ ],
766+ "funding": {
767+ "url": "https://opencollective.com/libvips"
768+ }
769+ },
770+ "node_modules/@img/sharp-libvips-linux-riscv64": {
771+ "version": "1.3.1",
772+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
773+ "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
774+ "cpu": [
775+ "riscv64"
776+ ],
777+ "dev": true,
778+ "libc": [
779+ "glibc"
780+ ],
781+ "license": "LGPL-3.0-or-later",
782+ "optional": true,
783+ "os": [
784+ "linux"
785+ ],
786+ "funding": {
787+ "url": "https://opencollective.com/libvips"
788+ }
789+ },
790+ "node_modules/@img/sharp-libvips-linux-s390x": {
791+ "version": "1.3.1",
792+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
793+ "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
794+ "cpu": [
795+ "s390x"
796+ ],
797+ "dev": true,
798+ "libc": [
799+ "glibc"
800+ ],
801+ "license": "LGPL-3.0-or-later",
802+ "optional": true,
803+ "os": [
804+ "linux"
805+ ],
806+ "funding": {
807+ "url": "https://opencollective.com/libvips"
808+ }
809+ },
810+ "node_modules/@img/sharp-libvips-linux-x64": {
811+ "version": "1.3.1",
812+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
813+ "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
814+ "cpu": [
815+ "x64"
816+ ],
817+ "dev": true,
818+ "libc": [
819+ "glibc"
820+ ],
821+ "license": "LGPL-3.0-or-later",
822+ "optional": true,
823+ "os": [
824+ "linux"
825+ ],
826+ "funding": {
827+ "url": "https://opencollective.com/libvips"
828+ }
829+ },
830+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
831+ "version": "1.3.1",
832+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
833+ "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
834+ "cpu": [
835+ "arm64"
836+ ],
837+ "dev": true,
838+ "libc": [
839+ "musl"
840+ ],
841+ "license": "LGPL-3.0-or-later",
842+ "optional": true,
843+ "os": [
844+ "linux"
845+ ],
846+ "funding": {
847+ "url": "https://opencollective.com/libvips"
848+ }
849+ },
850+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
851+ "version": "1.3.1",
852+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
853+ "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
854+ "cpu": [
855+ "x64"
856+ ],
857+ "dev": true,
858+ "libc": [
859+ "musl"
860+ ],
861+ "license": "LGPL-3.0-or-later",
862+ "optional": true,
863+ "os": [
864+ "linux"
865+ ],
866+ "funding": {
867+ "url": "https://opencollective.com/libvips"
868+ }
869+ },
870+ "node_modules/@img/sharp-linux-arm": {
871+ "version": "0.35.2",
872+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
873+ "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
874+ "cpu": [
875+ "arm"
876+ ],
877+ "dev": true,
878+ "libc": [
879+ "glibc"
880+ ],
881+ "license": "Apache-2.0",
882+ "optional": true,
883+ "os": [
884+ "linux"
885+ ],
886+ "engines": {
887+ "node": ">=20.9.0"
888+ },
889+ "funding": {
890+ "url": "https://opencollective.com/libvips"
891+ },
892+ "optionalDependencies": {
893+ "@img/sharp-libvips-linux-arm": "1.3.1"
894+ }
895+ },
896+ "node_modules/@img/sharp-linux-arm64": {
897+ "version": "0.35.2",
898+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
899+ "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
900+ "cpu": [
901+ "arm64"
902+ ],
903+ "dev": true,
904+ "libc": [
905+ "glibc"
906+ ],
907+ "license": "Apache-2.0",
908+ "optional": true,
909+ "os": [
910+ "linux"
911+ ],
912+ "engines": {
913+ "node": ">=20.9.0"
914+ },
915+ "funding": {
916+ "url": "https://opencollective.com/libvips"
917+ },
918+ "optionalDependencies": {
919+ "@img/sharp-libvips-linux-arm64": "1.3.1"
920+ }
921+ },
922+ "node_modules/@img/sharp-linux-ppc64": {
923+ "version": "0.35.2",
924+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
925+ "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
926+ "cpu": [
927+ "ppc64"
928+ ],
929+ "dev": true,
930+ "libc": [
931+ "glibc"
932+ ],
933+ "license": "Apache-2.0",
934+ "optional": true,
935+ "os": [
936+ "linux"
937+ ],
938+ "engines": {
939+ "node": ">=20.9.0"
940+ },
941+ "funding": {
942+ "url": "https://opencollective.com/libvips"
943+ },
944+ "optionalDependencies": {
945+ "@img/sharp-libvips-linux-ppc64": "1.3.1"
946+ }
947+ },
948+ "node_modules/@img/sharp-linux-riscv64": {
949+ "version": "0.35.2",
950+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
951+ "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
952+ "cpu": [
953+ "riscv64"
954+ ],
955+ "dev": true,
956+ "libc": [
957+ "glibc"
958+ ],
959+ "license": "Apache-2.0",
960+ "optional": true,
961+ "os": [
962+ "linux"
963+ ],
964+ "engines": {
965+ "node": ">=20.9.0"
966+ },
967+ "funding": {
968+ "url": "https://opencollective.com/libvips"
969+ },
970+ "optionalDependencies": {
971+ "@img/sharp-libvips-linux-riscv64": "1.3.1"
972+ }
973+ },
974+ "node_modules/@img/sharp-linux-s390x": {
975+ "version": "0.35.2",
976+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
977+ "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
978+ "cpu": [
979+ "s390x"
980+ ],
981+ "dev": true,
982+ "libc": [
983+ "glibc"
984+ ],
985+ "license": "Apache-2.0",
986+ "optional": true,
987+ "os": [
988+ "linux"
989+ ],
990+ "engines": {
991+ "node": ">=20.9.0"
992+ },
993+ "funding": {
994+ "url": "https://opencollective.com/libvips"
995+ },
996+ "optionalDependencies": {
997+ "@img/sharp-libvips-linux-s390x": "1.3.1"
998+ }
999+ },
1000+ "node_modules/@img/sharp-linux-x64": {
1001+ "version": "0.35.2",
1002+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
1003+ "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
1004+ "cpu": [
1005+ "x64"
1006+ ],
1007+ "dev": true,
1008+ "libc": [
1009+ "glibc"
1010+ ],
1011+ "license": "Apache-2.0",
1012+ "optional": true,
1013+ "os": [
1014+ "linux"
1015+ ],
1016+ "engines": {
1017+ "node": ">=20.9.0"
1018+ },
1019+ "funding": {
1020+ "url": "https://opencollective.com/libvips"
1021+ },
1022+ "optionalDependencies": {
1023+ "@img/sharp-libvips-linux-x64": "1.3.1"
1024+ }
1025+ },
1026+ "node_modules/@img/sharp-linuxmusl-arm64": {
1027+ "version": "0.35.2",
1028+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
1029+ "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
1030+ "cpu": [
1031+ "arm64"
1032+ ],
1033+ "dev": true,
1034+ "libc": [
1035+ "musl"
1036+ ],
1037+ "license": "Apache-2.0",
1038+ "optional": true,
1039+ "os": [
1040+ "linux"
1041+ ],
1042+ "engines": {
1043+ "node": ">=20.9.0"
1044+ },
1045+ "funding": {
1046+ "url": "https://opencollective.com/libvips"
1047+ },
1048+ "optionalDependencies": {
1049+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
1050+ }
1051+ },
1052+ "node_modules/@img/sharp-linuxmusl-x64": {
1053+ "version": "0.35.2",
1054+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
1055+ "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
1056+ "cpu": [
1057+ "x64"
1058+ ],
1059+ "dev": true,
1060+ "libc": [
1061+ "musl"
1062+ ],
1063+ "license": "Apache-2.0",
1064+ "optional": true,
1065+ "os": [
1066+ "linux"
1067+ ],
1068+ "engines": {
1069+ "node": ">=20.9.0"
1070+ },
1071+ "funding": {
1072+ "url": "https://opencollective.com/libvips"
1073+ },
1074+ "optionalDependencies": {
1075+ "@img/sharp-libvips-linuxmusl-x64": "1.3.1"
1076+ }
1077+ },
1078+ "node_modules/@img/sharp-wasm32": {
1079+ "version": "0.35.2",
1080+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
1081+ "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
1082+ "dev": true,
1083+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
1084+ "optional": true,
1085+ "dependencies": {
1086+ "@emnapi/runtime": "^1.11.1"
1087+ },
1088+ "engines": {
1089+ "node": ">=20.9.0"
1090+ },
1091+ "funding": {
1092+ "url": "https://opencollective.com/libvips"
1093+ }
1094+ },
1095+ "node_modules/@img/sharp-webcontainers-wasm32": {
1096+ "version": "0.35.2",
1097+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
1098+ "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
1099+ "cpu": [
1100+ "wasm32"
1101+ ],
1102+ "dev": true,
1103+ "license": "Apache-2.0",
1104+ "optional": true,
1105+ "dependencies": {
1106+ "@img/sharp-wasm32": "0.35.2"
1107+ },
1108+ "engines": {
1109+ "node": ">=20.9.0"
1110+ },
1111+ "funding": {
1112+ "url": "https://opencollective.com/libvips"
1113+ }
1114+ },
1115+ "node_modules/@img/sharp-win32-arm64": {
1116+ "version": "0.35.2",
1117+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
1118+ "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
1119+ "cpu": [
1120+ "arm64"
1121+ ],
1122+ "dev": true,
1123+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1124+ "optional": true,
1125+ "os": [
1126+ "win32"
1127+ ],
1128+ "engines": {
1129+ "node": ">=20.9.0"
1130+ },
1131+ "funding": {
1132+ "url": "https://opencollective.com/libvips"
1133+ }
1134+ },
1135+ "node_modules/@img/sharp-win32-ia32": {
1136+ "version": "0.35.2",
1137+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
1138+ "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
1139+ "cpu": [
1140+ "ia32"
1141+ ],
1142+ "dev": true,
1143+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1144+ "optional": true,
1145+ "os": [
1146+ "win32"
1147+ ],
1148+ "engines": {
1149+ "node": "^20.9.0"
1150+ },
1151+ "funding": {
1152+ "url": "https://opencollective.com/libvips"
1153+ }
1154+ },
1155+ "node_modules/@img/sharp-win32-x64": {
1156+ "version": "0.35.2",
1157+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
1158+ "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
1159+ "cpu": [
1160+ "x64"
1161+ ],
1162+ "dev": true,
1163+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1164+ "optional": true,
1165+ "os": [
1166+ "win32"
1167+ ],
1168+ "engines": {
1169+ "node": ">=20.9.0"
1170+ },
1171+ "funding": {
1172+ "url": "https://opencollective.com/libvips"
1173+ }
1174+ },
1175+ "node_modules/@jridgewell/resolve-uri": {
1176+ "version": "3.1.2",
1177+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
1178+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
1179+ "dev": true,
1180+ "license": "MIT",
1181+ "engines": {
1182+ "node": ">=6.0.0"
1183+ }
1184+ },
1185+ "node_modules/@jridgewell/sourcemap-codec": {
1186+ "version": "1.5.5",
1187+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
1188+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
1189+ "dev": true,
1190+ "license": "MIT"
1191+ },
1192+ "node_modules/@jridgewell/trace-mapping": {
1193+ "version": "0.3.9",
1194+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
1195+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
1196+ "dev": true,
1197+ "license": "MIT",
1198+ "dependencies": {
1199+ "@jridgewell/resolve-uri": "^3.0.3",
1200+ "@jridgewell/sourcemap-codec": "^1.4.10"
1201+ }
1202+ },
1203+ "node_modules/@poppinss/colors": {
1204+ "version": "4.1.6",
1205+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
1206+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
1207+ "dev": true,
1208+ "license": "MIT",
1209+ "dependencies": {
1210+ "kleur": "^4.1.5"
1211+ }
1212+ },
1213+ "node_modules/@poppinss/dumper": {
1214+ "version": "0.6.5",
1215+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
1216+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
1217+ "dev": true,
1218+ "license": "MIT",
1219+ "dependencies": {
1220+ "@poppinss/colors": "^4.1.5",
1221+ "@sindresorhus/is": "^7.0.2",
1222+ "supports-color": "^10.0.0"
1223+ }
1224+ },
1225+ "node_modules/@poppinss/exception": {
1226+ "version": "1.2.3",
1227+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
1228+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
1229+ "dev": true,
1230+ "license": "MIT"
1231+ },
1232+ "node_modules/@sindresorhus/is": {
1233+ "version": "7.2.0",
1234+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
1235+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
1236+ "dev": true,
1237+ "license": "MIT",
1238+ "engines": {
1239+ "node": ">=18"
1240+ },
1241+ "funding": {
1242+ "url": "https://github.com/sindresorhus/is?sponsor=1"
1243+ }
1244+ },
1245+ "node_modules/@speed-highlight/core": {
1246+ "version": "1.2.24",
1247+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
1248+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
1249+ "dev": true,
1250+ "license": "CC0-1.0"
1251+ },
1252+ "node_modules/blake3-wasm": {
1253+ "version": "2.1.5",
1254+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
1255+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
1256+ "dev": true,
1257+ "license": "MIT"
1258+ },
1259+ "node_modules/cookie": {
1260+ "version": "1.1.1",
1261+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
1262+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
1263+ "dev": true,
1264+ "license": "MIT",
1265+ "engines": {
1266+ "node": ">=18"
1267+ },
1268+ "funding": {
1269+ "type": "opencollective",
1270+ "url": "https://opencollective.com/express"
1271+ }
1272+ },
1273+ "node_modules/detect-libc": {
1274+ "version": "2.1.2",
1275+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1276+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1277+ "dev": true,
1278+ "license": "Apache-2.0",
1279+ "engines": {
1280+ "node": ">=8"
1281+ }
1282+ },
1283+ "node_modules/error-stack-parser-es": {
1284+ "version": "1.0.5",
1285+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
1286+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
1287+ "dev": true,
1288+ "license": "MIT",
1289+ "funding": {
1290+ "url": "https://github.com/sponsors/antfu"
1291+ }
1292+ },
1293+ "node_modules/esbuild": {
1294+ "version": "0.28.1",
1295+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
1296+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
1297+ "dev": true,
1298+ "hasInstallScript": true,
1299+ "license": "MIT",
1300+ "bin": {
1301+ "esbuild": "bin/esbuild"
1302+ },
1303+ "engines": {
1304+ "node": ">=18"
1305+ },
1306+ "optionalDependencies": {
1307+ "@esbuild/aix-ppc64": "0.28.1",
1308+ "@esbuild/android-arm": "0.28.1",
1309+ "@esbuild/android-arm64": "0.28.1",
1310+ "@esbuild/android-x64": "0.28.1",
1311+ "@esbuild/darwin-arm64": "0.28.1",
1312+ "@esbuild/darwin-x64": "0.28.1",
1313+ "@esbuild/freebsd-arm64": "0.28.1",
1314+ "@esbuild/freebsd-x64": "0.28.1",
1315+ "@esbuild/linux-arm": "0.28.1",
1316+ "@esbuild/linux-arm64": "0.28.1",
1317+ "@esbuild/linux-ia32": "0.28.1",
1318+ "@esbuild/linux-loong64": "0.28.1",
1319+ "@esbuild/linux-mips64el": "0.28.1",
1320+ "@esbuild/linux-ppc64": "0.28.1",
1321+ "@esbuild/linux-riscv64": "0.28.1",
1322+ "@esbuild/linux-s390x": "0.28.1",
1323+ "@esbuild/linux-x64": "0.28.1",
1324+ "@esbuild/netbsd-arm64": "0.28.1",
1325+ "@esbuild/netbsd-x64": "0.28.1",
1326+ "@esbuild/openbsd-arm64": "0.28.1",
1327+ "@esbuild/openbsd-x64": "0.28.1",
1328+ "@esbuild/openharmony-arm64": "0.28.1",
1329+ "@esbuild/sunos-x64": "0.28.1",
1330+ "@esbuild/win32-arm64": "0.28.1",
1331+ "@esbuild/win32-ia32": "0.28.1",
1332+ "@esbuild/win32-x64": "0.28.1"
1333+ }
1334+ },
1335+ "node_modules/fsevents": {
1336+ "version": "2.3.3",
1337+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1338+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1339+ "dev": true,
1340+ "hasInstallScript": true,
1341+ "license": "MIT",
1342+ "optional": true,
1343+ "os": [
1344+ "darwin"
1345+ ],
1346+ "engines": {
1347+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1348+ }
1349+ },
1350+ "node_modules/kleur": {
1351+ "version": "4.1.5",
1352+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
1353+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
1354+ "dev": true,
1355+ "license": "MIT",
1356+ "engines": {
1357+ "node": ">=6"
1358+ }
1359+ },
1360+ "node_modules/miniflare": {
1361+ "version": "5.20260811.0-alpha",
1362+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260811.0-alpha.tgz",
1363+ "integrity": "sha512-sypXsD5fjY88fZNedPqnwrwR1dwfnfbfW7MfvMyIfPJdtRiCCOpUnjWGeFVYYZ+0fQVICye6Juu+vZgzTEx8XA==",
1364+ "dev": true,
1365+ "license": "MIT",
1366+ "dependencies": {
1367+ "@cspotcode/source-map-support": "0.8.1",
1368+ "sharp": "0.35.2",
1369+ "undici": "7.29.0",
1370+ "workerd": "1.20260811.1",
1371+ "ws": "8.21.0",
1372+ "youch": "4.1.0-beta.10"
1373+ },
1374+ "engines": {
1375+ "node": ">=22.0.0"
1376+ }
1377+ },
1378+ "node_modules/path-to-regexp": {
1379+ "version": "6.3.0",
1380+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
1381+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
1382+ "dev": true,
1383+ "license": "MIT"
1384+ },
1385+ "node_modules/pathe": {
1386+ "version": "2.0.3",
1387+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1388+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1389+ "dev": true,
1390+ "license": "MIT"
1391+ },
1392+ "node_modules/semver": {
1393+ "version": "7.8.5",
1394+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
1395+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
1396+ "dev": true,
1397+ "license": "ISC",
1398+ "bin": {
1399+ "semver": "bin/semver.js"
1400+ },
1401+ "engines": {
1402+ "node": ">=10"
1403+ }
1404+ },
1405+ "node_modules/sharp": {
1406+ "version": "0.35.2",
1407+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
1408+ "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
1409+ "dev": true,
1410+ "license": "Apache-2.0",
1411+ "dependencies": {
1412+ "@img/colour": "^1.1.0",
1413+ "detect-libc": "^2.1.2",
1414+ "semver": "^7.8.4"
1415+ },
1416+ "engines": {
1417+ "node": ">=20.9.0"
1418+ },
1419+ "funding": {
1420+ "url": "https://opencollective.com/libvips"
1421+ },
1422+ "optionalDependencies": {
1423+ "@img/sharp-darwin-arm64": "0.35.2",
1424+ "@img/sharp-darwin-x64": "0.35.2",
1425+ "@img/sharp-freebsd-wasm32": "0.35.2",
1426+ "@img/sharp-libvips-darwin-arm64": "1.3.1",
1427+ "@img/sharp-libvips-darwin-x64": "1.3.1",
1428+ "@img/sharp-libvips-linux-arm": "1.3.1",
1429+ "@img/sharp-libvips-linux-arm64": "1.3.1",
1430+ "@img/sharp-libvips-linux-ppc64": "1.3.1",
1431+ "@img/sharp-libvips-linux-riscv64": "1.3.1",
1432+ "@img/sharp-libvips-linux-s390x": "1.3.1",
1433+ "@img/sharp-libvips-linux-x64": "1.3.1",
1434+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
1435+ "@img/sharp-libvips-linuxmusl-x64": "1.3.1",
1436+ "@img/sharp-linux-arm": "0.35.2",
1437+ "@img/sharp-linux-arm64": "0.35.2",
1438+ "@img/sharp-linux-ppc64": "0.35.2",
1439+ "@img/sharp-linux-riscv64": "0.35.2",
1440+ "@img/sharp-linux-s390x": "0.35.2",
1441+ "@img/sharp-linux-x64": "0.35.2",
1442+ "@img/sharp-linuxmusl-arm64": "0.35.2",
1443+ "@img/sharp-linuxmusl-x64": "0.35.2",
1444+ "@img/sharp-webcontainers-wasm32": "0.35.2",
1445+ "@img/sharp-win32-arm64": "0.35.2",
1446+ "@img/sharp-win32-ia32": "0.35.2",
1447+ "@img/sharp-win32-x64": "0.35.2"
1448+ }
1449+ },
1450+ "node_modules/supports-color": {
1451+ "version": "10.2.2",
1452+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
1453+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
1454+ "dev": true,
1455+ "license": "MIT",
1456+ "engines": {
1457+ "node": ">=18"
1458+ },
1459+ "funding": {
1460+ "url": "https://github.com/chalk/supports-color?sponsor=1"
1461+ }
1462+ },
1463+ "node_modules/tslib": {
1464+ "version": "2.8.1",
1465+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1466+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1467+ "dev": true,
1468+ "license": "0BSD",
1469+ "optional": true
1470+ },
1471+ "node_modules/typescript": {
1472+ "version": "5.9.3",
1473+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1474+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1475+ "dev": true,
1476+ "license": "Apache-2.0",
1477+ "bin": {
1478+ "tsc": "bin/tsc",
1479+ "tsserver": "bin/tsserver"
1480+ },
1481+ "engines": {
1482+ "node": ">=14.17"
1483+ }
1484+ },
1485+ "node_modules/undici": {
1486+ "version": "7.29.0",
1487+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
1488+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
1489+ "dev": true,
1490+ "license": "MIT",
1491+ "engines": {
1492+ "node": ">=20.18.1"
1493+ }
1494+ },
1495+ "node_modules/unenv": {
1496+ "version": "2.0.0-rc.24",
1497+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
1498+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
1499+ "dev": true,
1500+ "license": "MIT",
1501+ "dependencies": {
1502+ "pathe": "^2.0.3"
1503+ }
1504+ },
1505+ "node_modules/workerd": {
1506+ "version": "1.20260811.1",
1507+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260811.1.tgz",
1508+ "integrity": "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw==",
1509+ "dev": true,
1510+ "hasInstallScript": true,
1511+ "license": "Apache-2.0",
1512+ "bin": {
1513+ "workerd": "bin/workerd"
1514+ },
1515+ "engines": {
1516+ "node": ">=16"
1517+ },
1518+ "optionalDependencies": {
1519+ "@cloudflare/workerd-darwin-64": "1.20260811.1",
1520+ "@cloudflare/workerd-darwin-arm64": "1.20260811.1",
1521+ "@cloudflare/workerd-linux-64": "1.20260811.1",
1522+ "@cloudflare/workerd-linux-arm64": "1.20260811.1",
1523+ "@cloudflare/workerd-windows-64": "1.20260811.1"
1524+ }
1525+ },
1526+ "node_modules/wrangler": {
1527+ "version": "4.122.0",
1528+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.122.0.tgz",
1529+ "integrity": "sha512-qkskzgQ76Y1qvVe5JARgvc3RISq6BC2rPoxQhFoKH1dKIwQc3GDFttQ/7m2OfeQ+tmQRzynv2dy/DXnxFCj2Lw==",
1530+ "dev": true,
1531+ "license": "MIT OR Apache-2.0",
1532+ "dependencies": {
1533+ "@cloudflare/kv-asset-handler": "0.5.0",
1534+ "@cloudflare/unenv-preset": "2.16.1",
1535+ "blake3-wasm": "2.1.5",
1536+ "esbuild": "0.28.1",
1537+ "miniflare": "5.20260811.0-alpha",
1538+ "path-to-regexp": "6.3.0",
1539+ "unenv": "2.0.0-rc.24",
1540+ "workerd": "1.20260811.1"
1541+ },
1542+ "bin": {
1543+ "cf-wrangler": "bin/cf-wrangler.js",
1544+ "wrangler": "bin/wrangler.js",
1545+ "wrangler2": "bin/wrangler.js"
1546+ },
1547+ "engines": {
1548+ "node": ">=22.0.0"
1549+ },
1550+ "optionalDependencies": {
1551+ "fsevents": "2.3.3"
1552+ },
1553+ "peerDependencies": {
1554+ "@cloudflare/workers-types": "^5.20260811.1"
1555+ },
1556+ "peerDependenciesMeta": {
1557+ "@cloudflare/workers-types": {
1558+ "optional": true
1559+ }
1560+ }
1561+ },
1562+ "node_modules/ws": {
1563+ "version": "8.21.0",
1564+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
1565+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
1566+ "dev": true,
1567+ "license": "MIT",
1568+ "engines": {
1569+ "node": ">=10.0.0"
1570+ },
1571+ "peerDependencies": {
1572+ "bufferutil": "^4.0.1",
1573+ "utf-8-validate": ">=5.0.2"
1574+ },
1575+ "peerDependenciesMeta": {
1576+ "bufferutil": {
1577+ "optional": true
1578+ },
1579+ "utf-8-validate": {
1580+ "optional": true
1581+ }
1582+ }
1583+ },
1584+ "node_modules/youch": {
1585+ "version": "4.1.0-beta.10",
1586+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
1587+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
1588+ "dev": true,
1589+ "license": "MIT",
1590+ "dependencies": {
1591+ "@poppinss/colors": "^4.1.5",
1592+ "@poppinss/dumper": "^0.6.4",
1593+ "@speed-highlight/core": "^1.2.7",
1594+ "cookie": "^1.0.2",
1595+ "youch-core": "^0.3.3"
1596+ }
1597+ },
1598+ "node_modules/youch-core": {
1599+ "version": "0.3.3",
1600+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
1601+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
1602+ "dev": true,
1603+ "license": "MIT",
1604+ "dependencies": {
1605+ "@poppinss/exception": "^1.2.2",
1606+ "error-stack-parser-es": "^1.0.5"
1607+ }
1608+ }
1609+ }
1610+}
worker/package.jsonadded+16−0View file
@@ -0,0 +1,16 @@
1+{
2+ "name": "commonroom-turn",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "wrangler dev",
8+ "deploy": "wrangler deploy",
9+ "typecheck": "tsc --noEmit"
10+ },
11+ "devDependencies": {
12+ "@cloudflare/workers-types": "^5.20260811.1",
13+ "typescript": "^5.6.3",
14+ "wrangler": "^4.122.0"
15+ }
16+}
worker/src/index.tsadded+179−0View file
@@ -0,0 +1,179 @@
1+// Credential minter for Cloudflare Realtime TURN.
2+//
3+// The browser cannot call Cloudflare's TURN API directly: doing so would mean
4+// shipping the long-lived TURN API token in a static bundle, and anyone could
5+// then mint unlimited credentials against the account. This Worker holds that
6+// token and hands out short-lived ICE configurations to callers that present
7+// one of the room tokens configured in ROOM_TOKENS.
8+//
9+// POST / Authorization: Bearer <room token>
10+// {"room": "<opaque tag, optional>"}
11+// -> 200 {"iceServers": [...], "expiresAt": <epoch ms>}
12+//
13+// The `room` tag is passed to Cloudflare as the credential's customIdentifier
14+// so usage can be attributed per room in the Realtime analytics. The client
15+// sends a prefix of the hashed room topic, never the room name itself.
16+
17+export interface Env {
18+ /** TURN key ID from the Cloudflare dashboard (Realtime -> TURN). */
19+ TURN_KEY_ID: string
20+ /** API token paired with that TURN key. Never leaves this Worker. */
21+ TURN_KEY_API_TOKEN: string
22+ /** Accepted room tokens, separated by commas or whitespace. */
23+ ROOM_TOKENS: string
24+ /** Comma-separated origins allowed to call this Worker, or "*". */
25+ ALLOWED_ORIGINS?: string
26+ /** Lifetime of an issued credential, in seconds (Cloudflare's max is 48 h). */
27+ CREDENTIAL_TTL?: string
28+}
29+
30+const DEFAULT_TTL_SECONDS = 6 * 60 * 60
31+const MAX_TTL_SECONDS = 48 * 60 * 60
32+
33+export default {
34+ async fetch(request: Request, env: Env): Promise<Response> {
35+ const origin = request.headers.get('Origin')
36+ const allowedOrigin = resolveOrigin(origin, env.ALLOWED_ORIGINS)
37+ const cors: Record<string, string> = {
38+ vary: 'Origin',
39+ 'access-control-allow-methods': 'POST, OPTIONS',
40+ 'access-control-allow-headers': 'Authorization, Content-Type',
41+ 'access-control-max-age': '86400'
42+ }
43+ if (allowedOrigin) cors['access-control-allow-origin'] = allowedOrigin
44+
45+ if (request.method === 'OPTIONS') {
46+ return new Response(null, {status: 204, headers: cors})
47+ }
48+ if (request.method !== 'POST') {
49+ return json({error: 'Use POST.'}, 405, cors)
50+ }
51+ // A browser would block the response anyway; answering plainly makes a
52+ // misconfigured ALLOWED_ORIGINS obvious in the network tab.
53+ if (origin && !allowedOrigin) {
54+ return json({error: 'Origin not allowed.'}, 403, cors)
55+ }
56+ if (!env.TURN_KEY_ID || !env.TURN_KEY_API_TOKEN || !env.ROOM_TOKENS) {
57+ return json({error: 'Relay is not configured.'}, 500, cors)
58+ }
59+
60+ const header = request.headers.get('Authorization') ?? ''
61+ const token = header.startsWith('Bearer ') ? header.slice(7).trim() : ''
62+ if (!token || !(await tokenAccepted(token, env.ROOM_TOKENS))) {
63+ return json({error: 'Invalid relay token.'}, 401, cors)
64+ }
65+
66+ let customIdentifier: string | undefined
67+ try {
68+ const body = (await request.json()) as {room?: unknown}
69+ if (typeof body?.room === 'string' && body.room) {
70+ // Keep it short and inert: this string ends up in Cloudflare's
71+ // analytics, and only ever needs to distinguish one room from another.
72+ customIdentifier = body.room.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64)
73+ }
74+ } catch {
75+ /* no body, or not JSON — the tag is optional */
76+ }
77+
78+ const ttl = clampTtl(env.CREDENTIAL_TTL)
79+ let upstream: Response
80+ try {
81+ upstream = await fetch(
82+ `https://rtc.live.cloudflare.com/v1/turn/keys/${env.TURN_KEY_ID}/credentials/generate-ice-servers`,
83+ {
84+ method: 'POST',
85+ headers: {
86+ Authorization: `Bearer ${env.TURN_KEY_API_TOKEN}`,
87+ 'Content-Type': 'application/json'
88+ },
89+ body: JSON.stringify(
90+ customIdentifier ? {ttl, customIdentifier} : {ttl}
91+ )
92+ }
93+ )
94+ } catch {
95+ return json({error: 'Could not reach the relay service.'}, 502, cors)
96+ }
97+
98+ if (!upstream.ok) {
99+ // Deliberately vague: the upstream body can echo account details.
100+ console.error('generate-ice-servers failed', upstream.status)
101+ return json(
102+ {error: `Relay service returned ${upstream.status}.`},
103+ 502,
104+ cors
105+ )
106+ }
107+
108+ const payload = (await upstream.json()) as {iceServers?: unknown}
109+ if (!payload || typeof payload !== 'object' || !payload.iceServers) {
110+ return json({error: 'Malformed response from the relay service.'}, 502, cors)
111+ }
112+
113+ return json(
114+ {
115+ iceServers: payload.iceServers,
116+ // The client shares this with the rest of the room and re-mints before
117+ // it lapses. A small safety margin absorbs clock skew between peers.
118+ expiresAt: Date.now() + (ttl - 60) * 1000
119+ },
120+ 200,
121+ {...cors, 'cache-control': 'no-store'}
122+ )
123+ }
124+} satisfies ExportedHandler<Env>
125+
126+const json = (
127+ body: unknown,
128+ status: number,
129+ headers: Record<string, string>
130+): Response =>
131+ new Response(JSON.stringify(body), {
132+ status,
133+ headers: {...headers, 'content-type': 'application/json; charset=utf-8'}
134+ })
135+
136+const resolveOrigin = (
137+ origin: string | null,
138+ allowed: string | undefined
139+): string | null => {
140+ const list = (allowed ?? '*')
141+ .split(',')
142+ .map(s => s.trim())
143+ .filter(Boolean)
144+ if (list.includes('*')) return origin ?? '*'
145+ if (origin && list.includes(origin)) return origin
146+ return null
147+}
148+
149+const clampTtl = (raw: string | undefined): number => {
150+ const n = Number(raw)
151+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_TTL_SECONDS
152+ return Math.min(Math.floor(n), MAX_TTL_SECONDS)
153+}
154+
155+/** Compare against each configured token in constant time. Hashing first makes
156+ * the comparison independent of token length as well as content. */
157+const tokenAccepted = async (
158+ token: string,
159+ configured: string
160+): Promise<boolean> => {
161+ const candidates = configured.split(/[\s,]+/).filter(Boolean)
162+ if (candidates.length === 0) return false
163+ const offered = await sha256(token)
164+ let ok = false
165+ for (const c of candidates) {
166+ if (equalBytes(offered, await sha256(c))) ok = true
167+ }
168+ return ok
169+}
170+
171+const sha256 = async (s: string): Promise<Uint8Array> =>
172+ new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))
173+
174+const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => {
175+ if (a.length !== b.length) return false
176+ let diff = 0
177+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
178+ return diff === 0
179+}
worker/tsconfig.jsonadded+16−0View file
@@ -0,0 +1,16 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": ["ES2022"],
5+ "module": "ESNext",
6+ "moduleResolution": "bundler",
7+ "types": ["@cloudflare/workers-types"],
8+ "strict": true,
9+ "noUnusedLocals": true,
10+ "noUnusedParameters": true,
11+ "skipLibCheck": true,
12+ "isolatedModules": true,
13+ "noEmit": true
14+ },
15+ "include": ["src"]
16+}
worker/wrangler.jsoncadded+22−0View file
@@ -0,0 +1,22 @@
1+{
2+ "$schema": "node_modules/wrangler/config-schema.json",
3+ "name": "commonroom-turn",
4+ "main": "src/index.ts",
5+ "compatibility_date": "2026-08-01",
6+ "observability": {
7+ "enabled": true
8+ },
9+ "vars": {
10+ // Origins allowed to call this Worker. Replace with your deployed page —
11+ // e.g. "https://concept-collection.github.io" — keeping localhost for
12+ // `npm run dev`. "*" allows any origin.
13+ "ALLOWED_ORIGINS": "http://localhost:5173,https://concept-collection.github.io",
14+ // Lifetime of an issued credential, in seconds. Credentials are shared
15+ // with the whole room, so this is also how long a leaked one stays useful.
16+ "CREDENTIAL_TTL": "21600"
17+ }
18+ // Secrets (set with `npx wrangler secret put <NAME>`, never committed):
19+ // TURN_KEY_ID — from the Cloudflare dashboard, Realtime -> TURN
20+ // TURN_KEY_API_TOKEN — the API token paired with that TURN key
21+ // ROOM_TOKENS — tokens you hand to people, comma-separated
22+}