freehop 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1188 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A room is the set of peers sharing one secret. Gates only introduce peers; once two peers
3
+ // are linked, signalling flows over their own data channel. Media takes the cheapest
4
+ // working path: direct (host/IPv6/srflx/prflx) or an endpoint's own gateway, then a gateway
5
+ // run by another session member, then forwarding by another participant. No path ever uses
6
+ // infrastructure outside the session's own peers.
7
+ import { deriveRoom, seal, open, randomId } from './crypto.mjs';
8
+ import { Emitter, GateClient } from './gate-client.mjs';
9
+ import { TrackerClient } from './tracker-client.mjs';
10
+ import { validIceUrl, validStunUrls } from './ice-urls.mjs';
11
+ import { PeerLink, PHASE } from './peer.mjs';
12
+
13
+ const PEER_ID = /^[A-Za-z0-9_-]{22}$/;
14
+ const IP = /^[0-9a-fA-F.:]{2,45}$/;
15
+ const pairKey = (a, b) => a < b ? `${a}|${b}` : `${b}|${a}`;
16
+ // Gateway members (the session's host node) announce themselves with this id prefix. The
17
+ // prefix is only a hint; their gateway is learned from sealed caps like anything else.
18
+ const isGatewayMember = id => id.startsWith('gw_');
19
+ const KINDS = new Set(['caps', 'description', 'candidate', 'bye', 'restart-request', 'video-quality', 'bridge-request', 'bridge-offer', 'bridge-accept',
20
+ 'bridge-confirm', 'bridge-ready', 'bridge-active', 'bridge-release', 'bridge-fail', 'forward-map', 'forward-unmap', 'app']);
21
+ // Application messages (session.send): sealed like all signalling, at most 4096 characters of JSON; each
22
+ // receiver accepts at most 20 per second from one sender (burst 40).
23
+ export const APP_MESSAGE_BYTES = 4096;
24
+ const MIC = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
25
+ const CAM = { width: { ideal: 640 }, height: { ideal: 360 }, frameRate: { ideal: 24, max: 30 } };
26
+ const VIDEO_QUALITY = Object.freeze(['normal', 'reduced', 'minimal', 'paused']);
27
+ const VIDEO_QUALITY_INDEX = Object.freeze(Object.fromEntries(VIDEO_QUALITY.map((level, index) => [level, index])));
28
+ const VIDEO_QUALITY_SETTINGS = Object.freeze([
29
+ {bitrate: null, framerate: null, scale: 1, active: true},
30
+ {bitrate: 180000, framerate: 15, scale: 1.5, active: true},
31
+ {bitrate: 90000, framerate: 10, scale: 2, active: true},
32
+ {bitrate: null, framerate: null, scale: 2, active: false},
33
+ ]);
34
+ const deviceId = id => id === null || id === undefined || typeof id === 'string' && id.length > 0 && id.length <= 256;
35
+ const APP_RATE = 20, APP_BURST = 40;
36
+
37
+ export const DEFAULT_TIMING = Object.freeze({ capsWaitMs: 150, politeWaitMs: 1500, endpointMs: 5000, sessionMs: 7000,
38
+ bridgedRetryMs: 30000, maxRetryMs: 300000, restartFallbackMs: 2500, mediaWatchMs: 5000,
39
+ recoveryMs: 4000, bridgeWaitMs: 4000, departGraceMs: 8000, offerTimeoutMs: 8000, greetMs: 20000, outboxMs: 20000, sweepMs: 2000 });
40
+ export const DEFAULT_LIMITS = Object.freeze({ audioBitrate: 32000, videoBitrate: 300000, forwardVideoBitrate: 200000,
41
+ maxPeers: 8, maxGatewayMembers: 4, maxHints: 64, maxBridges: 4, meshForwardPerMinute: 600, outboxPerPeer: 32 });
42
+
43
+ export async function join(options) {
44
+ const room = new Room(options);
45
+ // A failed start (e.g. a refused microphone) must not leave captured devices running.
46
+ try { await room.start(); } catch (error) { await room.leave().catch(() => {}); throw error; }
47
+ return room;
48
+ }
49
+
50
+ const turnUrls = list => (Array.isArray(list) ? list : []).filter(u => validIceUrl(u, 'turn')).slice(0, 4);
51
+ const addresses = list => (Array.isArray(list) ? list : []).filter(a => typeof a === 'string' && IP.test(a)).slice(0, 4);
52
+
53
+ // A gateway as shared by another peer: TURN URLs plus credentials minted for us.
54
+ function cleanGateway(g) {
55
+ if (!g || typeof g.username !== 'string' || typeof g.credential !== 'string') return null;
56
+ const urls = turnUrls(g.urls);
57
+ if (!urls.length || g.username.length > 256 || g.credential.length > 256) return null;
58
+ return { urls, username: g.username, credential: g.credential, external: addresses(g.external),
59
+ internal: typeof g.internal === 'string' && IP.test(g.internal) ? g.internal : null };
60
+ }
61
+
62
+ // Public gateway metadata plus a credential broker. The signing key never enters the room.
63
+ function cleanOwnGateway(g) {
64
+ if (!g || typeof g.credentialsFor !== 'function') return null;
65
+ const info = typeof g.info === 'function' ? g.info() : g;
66
+ if (!info) return null;
67
+ const urls = turnUrls(info.urls);
68
+ if (!urls.length) return null;
69
+ return { urls, internalUrls: turnUrls(info.internalUrls), credentialsFor: g.credentialsFor.bind(g), ttlSeconds: info.ttlSeconds ?? 7200,
70
+ external: addresses(info.external), internal: typeof info.internal === 'string' && IP.test(info.internal) ? info.internal : null };
71
+ }
72
+
73
+ const sameGateway = (a, b) => !!a && !!b && a.urls.join() === b.urls.join() && a.external.join() === b.external.join();
74
+
75
+ // Some WebKit builds reject "?transport=" in TURN URLs (WebKit bug 320931). Detect once and
76
+ // degrade to the default (UDP) form instead of failing the whole peer connection.
77
+ let transportParamSupported;
78
+ function compatibleServers(servers, PeerConnection) {
79
+ if (transportParamSupported === undefined) {
80
+ try { new PeerConnection({ iceServers: [{ urls: 'turn:192.0.2.1:3478?transport=tcp', username: 'u', credential: 'c' }] }).close(); transportParamSupported = true; }
81
+ catch { transportParamSupported = false; }
82
+ }
83
+ if (transportParamSupported) return servers;
84
+ return servers.map(s => ({ ...s, urls: [...new Set((Array.isArray(s.urls) ? s.urls : [s.urls]).filter(u => !/\?transport=tcp/.test(u)).map(u => u.replace(/\?transport=udp$/, '')))] }))
85
+ .filter(s => s.urls.length);
86
+ }
87
+
88
+ export class Room extends Emitter {
89
+ constructor(options) {
90
+ super();
91
+ if (!Array.isArray(options?.gates) || !options.gates.length) throw new TypeError('At least one gate URL is required.');
92
+ if (options.stun !== undefined && !validStunUrls(options.stun)) throw new TypeError('Invalid application STUN URLs');
93
+ if (typeof options.auth === 'string' && options.gates.length !== 1) throw new TypeError('Use per-gate auth tokens for multiple gates');
94
+ this.options = options;
95
+ this.app = options.app ?? 'peerlane';
96
+ this.RTCPeerConnection = options.RTCPeerConnection ?? globalThis.RTCPeerConnection;
97
+ this.WebSocket = options.WebSocket ?? globalThis.WebSocket;
98
+ this.getUserMedia = options.getUserMedia ?? (c => navigator.mediaDevices.getUserMedia(c));
99
+ this.timing = { ...DEFAULT_TIMING, ...options.timing };
100
+ this.limits = { ...DEFAULT_LIMITS, ...options.limits };
101
+ if (options.adaptiveVideo !== undefined && typeof options.adaptiveVideo !== 'boolean') throw new TypeError('adaptiveVideo must be a boolean');
102
+ this.adaptiveVideoEnabled = options.adaptiveVideo === true;
103
+ this.log = options.log ?? (() => {});
104
+ do { this.id = randomId(16); } while (isGatewayMember(this.id));
105
+ this.links = new Map(); this.caps = new Map(); this.known = new Set(); this.departed = new Map(); this.gatewayMembers = new Set();
106
+ this.presence = new Map(); // peer -> Set(gate) — unauthenticated hints from gates
107
+ this.hintedAt = new Map(); // first unauthenticated hint; repeated rosters cannot refresh its lifetime
108
+ this.lastContact = new Map(); // peer -> time of the last presence, authenticated envelope or live link
109
+ this.greeted = new Map(); // peer -> time we last sent it our caps
110
+ this.outbox = new Map(); // peer -> [{ box, until }] envelopes waiting for a route
111
+ this.pendingLinks = new Map(); this.early = new Map(); this.sequences = new Map();
112
+ this.gates = []; this.stunUrls = new Set(options.stun ?? []);
113
+ this.ownGateway = cleanOwnGateway(options.gateway); this.gatewayCreds = new Map();
114
+ this.forward = options.forward !== false;
115
+ this.bridges = new Map(); // as an endpoint: pairKey -> { peer, via, state, requestedAt }
116
+ this.relaying = new Map(); // as a forwarder: pairKey -> { a, b }
117
+ this.bridgePending = new Map(); // forwarding awaits consent from the second endpoint
118
+ this.bridgeOffers = new Map();// as a forwarder: pairKey -> { a, b, at } offers we made
119
+ this.counters = { sent: 0, viaMesh: 0, viaGate: 0, viaIntroducer: 0, queued: 0, undeliverable: 0, received: 0, rejected: 0, duplicates: 0,
120
+ meshForwarded: 0, candidateErrors: 0, negotiationErrors: 0, linkErrors: 0, escalations: 0, bridgesUsed: 0, bridgesServed: 0,
121
+ offerTimeouts: 0, mediaStalls: 0, appSent: 0, appReceived: 0, appDropped: 0 };
122
+ this.appBudget = new Map(); // sender -> { tokens, at }
123
+ this.videoQuality = new Map(); // peer -> per-link send/receive controller state
124
+ // Preferred capture devices (options.devices, switchDevice); every later capture uses them.
125
+ if (!deviceId(options.devices?.audio) || !deviceId(options.devices?.video)) throw new TypeError('Invalid capture device id');
126
+ this.devices = { audio: options.devices?.audio ?? null, video: options.devices?.video ?? null };
127
+ this.sendCounter = 0; this.closed = false; this.localStream = null; this.ownsMedia = false;
128
+ this.sendProgress = new Map(); this.meshBudget = new Map();
129
+ }
130
+
131
+ get tag8() { return this.tag.slice(0, 8); }
132
+
133
+ async start() {
134
+ this.crypto = await deriveRoom(this.options.secret, this.app);
135
+ this.tag = this.crypto.tag;
136
+ if (this.ownGateway) await this.gatewayCredsFor('self');
137
+ await this.setMedia(this.options.media ?? null);
138
+ this.openGates();
139
+ this.mediaWatch = setInterval(() => this.checkSending(), this.timing.mediaWatchMs);
140
+ this.sweeper = setInterval(() => this.sweep(), this.timing.sweepMs);
141
+ }
142
+
143
+ openGates() {
144
+ const epoch = this.crypto;
145
+ for (const url of this.options.gates) {
146
+ // "bt+wss://tracker/..." uses a public WebTorrent tracker as a gate (magnet-style).
147
+ const gate = url.startsWith('bt+')
148
+ ? new TrackerClient(url.slice(3), { room: this.tag, peer: this.id, WebSocketImpl: this.WebSocket,
149
+ needsIntroduction: () => this.known.size === 0,
150
+ hello: () => seal(epoch, this.id, '*', { kind: 'hello', n: ++this.sendCounter }) })
151
+ // Auth is read at every (re)connect, so refreshed tokens (session.refresh) apply without a rotation.
152
+ : new GateClient(url, { room: this.tag, peer: this.id, auth: gateUrl => { const a = this.options.auth; return a && typeof a === 'object' ? a[gateUrl] : a; }, WebSocketImpl: this.WebSocket });
153
+ gate.on('hello', async ({ gate: g, from, box, route }) => {
154
+ if (this.closed || !PEER_ID.test(from) || from === this.id || this.blocked(from)) return;
155
+ const p = await open(epoch, from, '*', box);
156
+ if (this.closed || epoch !== this.crypto || this.blocked(from) || p?.kind !== 'hello' || !Number.isSafeInteger(p.n) || p.n < 1 || !this.acceptSequence(from, p.n)) return;
157
+ g.bind?.(from, route);
158
+ this.hint(from, g);
159
+ });
160
+ gate.on('joined', ({ peers }) => {
161
+ if (this.closed || epoch !== this.crypto) return;
162
+ // Gate messages cannot choose servers the browser will contact.
163
+ this.emit('gate', { url, state: 'joined' });
164
+ for (const p of peers) this.hint(p, gate);
165
+ });
166
+ gate.on('peer', ({ peer, on }) => on ? this.hint(peer, gate) : this.lostOnGate(peer, gate));
167
+ gate.on('left', () => { for (const p of [...this.presence.keys()]) this.lostOnGate(p, gate); this.emit('gate', { url, state: 'reconnecting' }); });
168
+ gate.on('recv', ({ gate: g, from, box, route }) => { if (PEER_ID.test(from)) this.receiveBox(from, box, g, route); });
169
+ gate.on('gate-error', ({ code }) => this.emit('gate', { url, state: 'error', code }));
170
+ this.gates.push(gate);
171
+ gate.connect();
172
+ }
173
+ }
174
+
175
+ // Rotation closes every old-key link, including unreported identities. Possession of the
176
+ // new secret is required to reconnect; a known peer id alone is never sufficient.
177
+ rekey(secret, options = {}) {
178
+ const task = (this.rekeyTask ?? Promise.resolve()).then(() => this.changeKey(secret, options));
179
+ this.rekeyTask = task.catch(() => {});
180
+ return task;
181
+ }
182
+
183
+ async changeKey(secret, { auth, gates, stun } = {}) {
184
+ if (this.closed) return;
185
+ if (gates !== undefined && (!Array.isArray(gates) || !gates.length)) throw new TypeError('At least one gate URL is required.');
186
+ if (stun !== undefined && !validStunUrls(stun)) throw new TypeError('Invalid application STUN URLs');
187
+ const nextGates = gates ?? this.options.gates;
188
+ if (typeof auth === 'string' && nextGates.length !== 1) throw new TypeError('Use per-gate auth tokens for multiple gates');
189
+ const next = await deriveRoom(secret, this.app);
190
+ if (this.closed) return;
191
+ // A rotation may also evict a gate or change STUN: the next ticket's lists replace the old ones.
192
+ this.options = { ...this.options, auth, gates: nextGates };
193
+ if (stun !== undefined) this.stunUrls = new Set(stun);
194
+ if (next.tag === this.tag) return;
195
+ for (const gate of this.gates) gate.close();
196
+ clearTimeout(this.capsTimer); this.capsTimer = null;
197
+ for (const peer of [...this.known]) this.dropPeer(peer, 'rekey');
198
+ for (const t of this.pendingLinks.values()) clearTimeout(t);
199
+ for (const id of this.gatewayMembers) this.emit('gateway', { id, available: false });
200
+ this.crypto = next; this.tag = next.tag;
201
+ this.gates = []; this.presence.clear(); this.hintedAt.clear(); this.caps.clear(); this.gatewayMembers.clear();
202
+ this.pendingLinks.clear(); this.early.clear(); this.sequences.clear(); this.greeted.clear(); this.lastContact.clear();
203
+ this.outbox.clear(); this.bridges.clear(); this.relaying.clear(); this.bridgeOffers.clear(); this.bridgePending.clear(); this.sendProgress.clear(); this.meshBudget.clear();
204
+ this.gatewayCreds.clear(); if (this.ownGateway) await this.gatewayCredsFor('self');
205
+ if (!this.closed) this.openGates();
206
+ this.scheduleCapsBroadcast();
207
+ }
208
+
209
+ // Disconnect a peer now (e.g. after a kick, together with rekey()).
210
+ drop(peer) { this.dropPeer(peer, 'dropped'); }
211
+
212
+ /**
213
+ * Send application data (any JSON value, at most 4096 characters) to one member (`{ to: peerId }`) or to every
214
+ * member. It travels sealed, like signalling: over the peers' own data channel once linked,
215
+ * otherwise through the gates. Receivers get a 'message' event { from, data }; treat it as
216
+ * untrusted input. Resolves to the number of members it was handed to.
217
+ */
218
+ async send(data, { to } = {}) {
219
+ if (this.closed) return 0;
220
+ let size; try { size = JSON.stringify(data ?? null).length; } catch { throw new TypeError('Message data must be JSON-serialisable.'); }
221
+ if (data === undefined || size > APP_MESSAGE_BYTES) throw new RangeError(`Message data must be at most ${APP_MESSAGE_BYTES} characters of JSON.`);
222
+ const targets = to === undefined ? [...this.known] : this.known.has(to) ? [to] : [];
223
+ for (const peer of targets) { this.signalTo(peer, { kind: 'app', data }).catch(() => {}); this.counters.appSent++; }
224
+ return targets.length;
225
+ }
226
+
227
+ takeAppToken(from) {
228
+ const now = Date.now(), b = this.appBudget.get(from) ?? { tokens: APP_BURST, at: now };
229
+ b.tokens = Math.min(APP_BURST, b.tokens + (now - b.at) * APP_RATE / 1000); b.at = now;
230
+ this.appBudget.set(from, b);
231
+ if (b.tokens < 1) return false;
232
+ b.tokens -= 1; return true;
233
+ }
234
+
235
+ count(name, error) { this.counters[name] = (this.counters[name] ?? 0) + 1; if (error) this.log(name, { message: error?.message ?? String(error) }); }
236
+
237
+ // ---------- discovery: hints from gates, admission only after authentication ----------
238
+ // A peer that left (bye) or was dropped (kick) stays out; one that merely vanished from
239
+ // the gates (network change) is welcome back once it proves itself again.
240
+ blocked(peer) {
241
+ const d = this.departed.get(peer);
242
+ if (!d) return false;
243
+ if (d.reason !== 'gone' && d.reason !== 'rekey') return true;
244
+ this.departed.delete(peer);
245
+ return false;
246
+ }
247
+
248
+ // Gate rosters are unauthenticated: they only make us greet the peer with sealed caps. A
249
+ // peer becomes a member (events, links, slots) once an envelope from it authenticates.
250
+ hint(peer, gate) {
251
+ if (this.closed || peer === this.id || !PEER_ID.test(peer) || this.blocked(peer)) return;
252
+ if (gate) {
253
+ let set = this.presence.get(peer);
254
+ if (!set) {
255
+ const budget = Math.max(1, Math.floor(this.limits.maxHints / this.options.gates.length));
256
+ const used = [...this.presence].filter(([id, sources]) => !this.known.has(id) && !this.gatewayMembers.has(id) && sources.has(gate)).length;
257
+ if (this.presence.size >= this.limits.maxHints || !this.known.has(peer) && !this.gatewayMembers.has(peer) && used >= budget) return;
258
+ set = new Set(); this.presence.set(peer, set);
259
+ }
260
+ set.add(gate);
261
+ if (!this.known.has(peer) && !this.gatewayMembers.has(peer) && !this.hintedAt.has(peer)) this.hintedAt.set(peer, Date.now());
262
+ this.lastContact.set(peer, Date.now());
263
+ this.flushOutbox(peer);
264
+ }
265
+ const last = this.greeted.get(peer) ?? 0;
266
+ if (!this.known.has(peer) && !this.gatewayMembers.has(peer) && Date.now() - last > this.timing.greetMs) {
267
+ this.greeted.set(peer, Date.now());
268
+ if (this.greeted.size > this.limits.maxHints * 2) this.greeted.delete(this.greeted.keys().next().value);
269
+ this.sendCaps(peer);
270
+ }
271
+ }
272
+
273
+ admit(peer) {
274
+ if (this.known.has(peer)) return true;
275
+ if (isGatewayMember(peer) || this.closed || this.blocked(peer) || this.known.size >= this.limits.maxPeers) return false;
276
+ this.hintedAt.delete(peer);
277
+ this.known.add(peer); this.emit('peer', { id: peer });
278
+ if (!this.greeted.has(peer) || Date.now() - this.greeted.get(peer) > 2000) { this.greeted.set(peer, Date.now()); this.sendCaps(peer); }
279
+ if (!this.links.has(peer) && !this.pendingLinks.has(peer)) {
280
+ // The impolite side (lower id) opens the link right away (the caps it just
281
+ // authenticated name any gateway); the polite side waits for that offer.
282
+ const delay = this.id < peer ? this.timing.capsWaitMs : this.timing.politeWaitMs;
283
+ this.pendingLinks.set(peer, setTimeout(() => this.ensureLink(peer), delay));
284
+ }
285
+ return true;
286
+ }
287
+
288
+ lostOnGate(peer, gate) {
289
+ const set = this.presence.get(peer); if (!set) return;
290
+ set.delete(gate);
291
+ if (!set.size) this.presence.delete(peer);
292
+ }
293
+
294
+ // Departures: a peer absent from every gate whose link is not connected is dropped after
295
+ // departGraceMs; a dead data channel can read "open" for a long time, so it does not count.
296
+ sweep() {
297
+ if (this.closed) return;
298
+ const now = Date.now();
299
+ for (const [peer, at] of this.hintedAt) {
300
+ if (this.known.has(peer) || this.gatewayMembers.has(peer)) { this.hintedAt.delete(peer); continue; }
301
+ if (now - at >= this.timing.greetMs) { this.presence.delete(peer); this.hintedAt.delete(peer); this.lastContact.delete(peer); this.outbox.delete(peer); }
302
+ }
303
+ for (const peer of [...this.known, ...this.gatewayMembers]) {
304
+ const link = this.links.get(peer);
305
+ if (this.presence.has(peer) || link?.connected) { this.lastContact.set(peer, now); continue; }
306
+ if (now - (this.lastContact.get(peer) ?? now) < this.timing.departGraceMs) continue;
307
+ if (this.gatewayMembers.has(peer)) { this.gatewayMembers.delete(peer); this.caps.delete(peer); this.lastContact.delete(peer); this.emit('gateway', { id: peer, available: false }); }
308
+ else this.dropPeer(peer, 'gone');
309
+ }
310
+ for (const [peer, list] of this.outbox) {
311
+ const live = list.filter(e => e.until > now);
312
+ if (live.length) this.outbox.set(peer, live); else this.outbox.delete(peer);
313
+ }
314
+ for (const [key, offer] of this.bridgeOffers) if (now - offer.at > 30000) this.bridgeOffers.delete(key);
315
+ // An accepted forwarder that never activated must not block other forwarders forever.
316
+ for (const [key, bridge] of [...this.bridges]) if (bridge.state === 'accepted' && now - bridge.requestedAt >= 30000 && !this.links.get(bridge.peer)?.connected) {
317
+ this.bridges.delete(key);
318
+ if (bridge.via && this.known.has(bridge.via)) this.signalTo(bridge.via, { kind: 'bridge-release', a: key.split('|')[0], b: key.split('|')[1] }).catch(() => {});
319
+ this.rescueLink(bridge.peer);
320
+ }
321
+ for (const [key, pending] of this.bridgePending) if (now - pending.at >= 30000) {
322
+ this.bridgePending.delete(key);
323
+ for (const to of [pending.a, pending.b]) this.signalTo(to, {kind: 'bridge-fail', a: pending.a, b: pending.b});
324
+ }
325
+ }
326
+
327
+ ensureLink(peer, forOffer = false) {
328
+ clearTimeout(this.pendingLinks.get(peer)); this.pendingLinks.delete(peer);
329
+ if (this.closed || this.blocked(peer) || !this.known.has(peer)) return null;
330
+ let link = this.links.get(peer);
331
+ if (!link) {
332
+ try { link = new PeerLink(this, peer, { forOffer }); }
333
+ catch (error) { this.log('peer-link-error', {peer, message: error.message}); this.emit('path', {peer, kind: 'unreachable'}); return null; }
334
+ this.links.set(peer, link);
335
+ for (const c of this.early.get(peer) ?? []) link.enqueue(() => link.onCandidate({ candidate: c }));
336
+ this.early.delete(peer);
337
+ }
338
+ return link;
339
+ }
340
+
341
+ dropPeer(peer, reason) {
342
+ if (!this.known.has(peer) && !this.links.has(peer)) return;
343
+ clearTimeout(this.pendingLinks.get(peer)); this.pendingLinks.delete(peer);
344
+ this.links.get(peer)?.close(); this.links.delete(peer);
345
+ this.known.delete(peer); this.hintedAt.delete(peer); this.caps.delete(peer); this.presence.delete(peer); this.early.delete(peer); this.outbox.delete(peer);
346
+ this.sendProgress.delete(peer); this.meshBudget.delete(peer); this.appBudget.delete(peer); this.videoQuality.delete(peer);
347
+ this.lastContact.delete(peer); this.gatewayCreds.delete(`${this.tag8}:${peer}`);
348
+ // The replay window survives a soft departure: a returning peer keeps its counter.
349
+ if (reason !== 'gone') this.sequences.delete(peer);
350
+ this.departed.set(peer, { at: Date.now(), reason });
351
+ if (this.departed.size > 256) this.departed.delete(this.departed.keys().next().value);
352
+ for (const [key, b] of [...this.bridgePending]) if (b.a === peer || b.b === peer) {
353
+ this.bridgePending.delete(key);
354
+ const other = b.a === peer ? b.b : b.a;
355
+ if (this.known.has(other)) this.signalTo(other, { kind: 'bridge-fail', a: b.a, b: b.b }).catch(() => {});
356
+ }
357
+ for (const [key, b] of [...this.relaying]) if (b.a === peer || b.b === peer) this.endRelayBridge(key, 'peer-left');
358
+ for (const [key, b] of [...this.bridges]) if (b.via === peer || b.peer === peer) { this.bridges.delete(key); if (b.via === peer) this.rescueLink(b.peer); }
359
+ for (const link of this.links.values()) for (const [stream, origin] of [...link.forwardMap]) if (origin === peer) link.forwardMap.delete(stream);
360
+ this.emit('peer-left', { id: peer, reason });
361
+ this.scheduleCapsBroadcast();
362
+ }
363
+
364
+ // ---------- signalling transport ----------
365
+ async signal(link, payload) { return this.signalTo(link.id, payload); }
366
+
367
+ async signalTo(peer, payload) {
368
+ if (this.closed && payload.kind !== 'bye') return false;
369
+ const crypto = this.crypto;
370
+ const box = await seal(crypto, this.id, peer, { ...payload, n: ++this.sendCounter });
371
+ if (crypto !== this.crypto || this.closed && payload.kind !== 'bye') return false;
372
+ this.counters.sent++;
373
+ if (this.route(peer, box)) return true;
374
+ // No route right now (gate reconnecting, peer not yet present): keep it briefly.
375
+ const list = this.outbox.get(peer) ?? [];
376
+ list.push({ box, until: Date.now() + this.timing.outboxMs });
377
+ if (list.length > this.limits.outboxPerPeer) list.shift();
378
+ this.outbox.set(peer, list); this.counters.queued++;
379
+ return false;
380
+ }
381
+
382
+ flushOutbox(peer) {
383
+ const list = this.outbox.get(peer);
384
+ if (!list?.length) return;
385
+ this.outbox.delete(peer);
386
+ const now = Date.now(), left = [];
387
+ for (const entry of list) if (entry.until > now && !this.route(peer, entry.box)) left.push(entry);
388
+ if (left.length) this.outbox.set(peer, left);
389
+ }
390
+
391
+ route(to, box) {
392
+ const link = this.links.get(to);
393
+ const frame = JSON.stringify({ t: 'env', from: this.id, to, box });
394
+ // A data channel can still read "open" for tens of seconds after its connection died, so
395
+ // it is trusted alone only while the link is connected; otherwise gates carry the
396
+ // envelope too (receivers drop duplicates).
397
+ if (link?.connected && link.control.readyState === 'open') {
398
+ try { link.control.send(frame); this.counters.viaMesh++; return true; } catch {}
399
+ }
400
+ let sent = false;
401
+ if (link?.control.readyState === 'open') { try { link.control.send(frame); sent = true; } catch {} }
402
+ for (const gate of this.presence.get(to) ?? []) sent = gate.send(to, box) || sent;
403
+ if (sent) { this.counters.viaGate++; return true; }
404
+ // Introduction through the mesh: a neighbour that reports a live link to `to` forwards once.
405
+ for (const l of this.links.values()) {
406
+ if (l.id !== to && l.connected && l.control.readyState === 'open' && this.caps.get(l.id)?.peers.includes(to)) {
407
+ l.control.send(JSON.stringify({ t: 'env', from: this.id, to, box, hop: 1 })); this.counters.viaIntroducer++; return true;
408
+ }
409
+ }
410
+ this.counters.undeliverable++;
411
+ return false;
412
+ }
413
+
414
+ onControlOpen(link) { this.sendCaps(link.id); this.flushOutbox(link.id); this.scheduleCapsBroadcast(); }
415
+
416
+ onControlMessage(link, data) {
417
+ if (typeof data !== 'string' || data.length > 70000) return;
418
+ let m; try { m = JSON.parse(data); } catch { return; }
419
+ if (m?.t !== 'env' || typeof m.box !== 'string' || !PEER_ID.test(m.from ?? '') || !PEER_ID.test(m.to ?? '')) return;
420
+ if (m.to === this.id) { if (m.from === link.id || m.hop === 2) this.receiveBox(m.from, m.box, 'mesh'); return; }
421
+ if (m.hop !== 1 || m.from !== link.id) return;
422
+ const minute = Math.floor(Date.now() / 60000), budget = this.meshBudget.get(link.id);
423
+ const used = budget?.minute === minute ? budget.used : 0;
424
+ if (used >= this.limits.meshForwardPerMinute) return;
425
+ this.meshBudget.set(link.id, { minute, used: used + 1 });
426
+ const target = this.links.get(m.to);
427
+ if (target?.connected && target.control.readyState === 'open') { target.control.send(JSON.stringify({ t: 'env', from: m.from, to: m.to, box: m.box, hop: 2 })); this.counters.meshForwarded++; }
428
+ }
429
+
430
+ acceptSequence(from, n) {
431
+ let s = this.sequences.get(from);
432
+ if (!s) {
433
+ if (this.sequences.size > this.limits.maxHints * 4) this.sequences.delete(this.sequences.keys().next().value);
434
+ s = { max: 0, seen: new Set() }; this.sequences.set(from, s);
435
+ }
436
+ if (n <= s.max - 1024 || s.seen.has(n)) return false;
437
+ s.seen.add(n); if (n > s.max) s.max = n;
438
+ if (s.seen.size > 2048) for (const v of s.seen) if (v <= s.max - 1024) s.seen.delete(v);
439
+ return true;
440
+ }
441
+
442
+ async receiveBox(from, box, via, route) {
443
+ if (this.closed || this.blocked(from)) return;
444
+ const crypto = this.crypto;
445
+ const p = await open(crypto, from, this.id, box);
446
+ if (crypto !== this.crypto || this.closed || this.blocked(from)) return;
447
+ if (!p || !Number.isSafeInteger(p.n) || p.n < 1 || !KINDS.has(p.kind)) { this.counters.rejected++; return; }
448
+ if (!this.acceptSequence(from, p.n)) { this.counters.duplicates++; return; }
449
+ this.counters.received++;
450
+ this.lastContact.set(from, Date.now());
451
+ // An authenticated envelope through a gate proves the sender is reachable there.
452
+ if (via && typeof via === 'object') { via.bind?.(from, route); this.hint(from, via); }
453
+ try { this.dispatch(from, p); } catch (error) { this.count('linkErrors', error); }
454
+ }
455
+
456
+ dispatch(from, p) {
457
+ if (isGatewayMember(from)) {
458
+ if (p.kind === 'bye') { this.gatewayMembers.delete(from); this.caps.delete(from); this.emit('gateway', { id: from, available: false }); return; }
459
+ if (p.kind === 'caps' && p.caps?.role === 'gateway' && (this.gatewayMembers.has(from) || this.gatewayMembers.size < this.limits.maxGatewayMembers)) {
460
+ const fresh = !this.gatewayMembers.has(from);
461
+ this.gatewayMembers.add(from); this.updateCaps(from, p.caps);
462
+ if (fresh) { this.emit('gateway', { id: from, available: !!this.caps.get(from)?.gateway }); this.sendCaps(from); }
463
+ }
464
+ return;
465
+ }
466
+ if (p.kind === 'bye') return this.dropPeer(from, 'bye');
467
+ if (!this.admit(from)) return;
468
+ switch (p.kind) {
469
+ case 'caps':
470
+ this.updateCaps(from, p.caps);
471
+ if (this.pendingLinks.has(from) && this.id < from) this.ensureLink(from);
472
+ return;
473
+ case 'description': {
474
+ if (!p.description || !['offer', 'answer'].includes(p.description.type) || typeof p.description.sdp !== 'string' || p.description.sdp.length > 131072) return;
475
+ const link = this.ensureLink(from, p.description.type === 'offer');
476
+ link?.enqueue(() => link.onDescription(p));
477
+ return;
478
+ }
479
+ case 'candidate': {
480
+ if (typeof p.candidate?.candidate !== 'string' || p.candidate.candidate.length > 1024) return;
481
+ const link = this.links.get(from);
482
+ if (link) link.enqueue(() => link.onCandidate(p));
483
+ else { const list = this.early.get(from) ?? []; list.push(p.candidate); if (list.length > 64) list.shift(); this.early.set(from, list); }
484
+ return;
485
+ }
486
+ case 'app': {
487
+ // Untrusted application data from a room member: bounded, rate-limited, never interpreted here.
488
+ let size; try { size = JSON.stringify(p.data ?? null).length; } catch { size = Infinity; }
489
+ if (p.data === undefined || size > APP_MESSAGE_BYTES || !this.takeAppToken(from)) { this.counters.appDropped++; return; }
490
+ this.counters.appReceived++;
491
+ this.emit('message', { from, data: p.data });
492
+ return;
493
+ }
494
+ case 'restart-request': {
495
+ // The polite side saw its media stall; only the impolite side restarts (no glare).
496
+ const link = this.links.get(from);
497
+ if (link && !link.polite) link.enqueue(() => link.restart());
498
+ return;
499
+ }
500
+ case 'video-quality':
501
+ this.receiveVideoQuality(from, p);
502
+ return;
503
+ default: return this.onBridgeMessage(from, p);
504
+ }
505
+ }
506
+
507
+ // ---------- capabilities ----------
508
+ async gatewayCredsFor(label) {
509
+ const g = this.ownGateway; if (!g) return null;
510
+ const name = `${this.tag8}:${label}`;
511
+ const cached = this.gatewayCreds.get(name);
512
+ if (cached && cached.expiry * 1000 - Date.now() > g.ttlSeconds * 500) return cached;
513
+ const epoch = this.crypto, tag = this.tag;
514
+ let minted;
515
+ try { minted = await g.credentialsFor(tag, label); }
516
+ catch (error) { this.log('gateway-credentials-unavailable', {message: error.message}); return null; }
517
+ if (this.closed || epoch !== this.crypto || !minted || typeof minted.username !== 'string' || typeof minted.credential !== 'string') return null;
518
+ const [rawExpiry, scope, owner, extra] = minted.username.split(':');
519
+ const expiry = Number(rawExpiry);
520
+ if (extra !== undefined || scope !== this.tag8 || owner !== label || !Number.isSafeInteger(expiry) || expiry * 1000 <= Date.now()) return null;
521
+ const creds = {...minted, expiry};
522
+ this.gatewayCreds.set(name, creds);
523
+ if (this.gatewayCreds.size > this.limits.maxHints * 2) this.gatewayCreds.delete(this.gatewayCreds.keys().next().value);
524
+ return creds;
525
+ }
526
+
527
+ async capsFor(peer) {
528
+ let gateway = null;
529
+ // Gateway credentials go only to admitted members, never to an unauthenticated hint.
530
+ if (this.ownGateway && (this.known.has(peer) || this.gatewayMembers.has(peer))) {
531
+ const c = await this.gatewayCredsFor(peer);
532
+ if (c) gateway = { urls: this.ownGateway.urls, username: c.username, credential: c.credential, external: this.ownGateway.external, internal: this.ownGateway.internal };
533
+ }
534
+ return { v: 1, forward: this.forward, peers: [...this.links.values()].filter(l => l.connected && l.control.readyState === 'open').map(l => l.id), gateway };
535
+ }
536
+
537
+ async sendCaps(peer) {
538
+ const epoch = this.crypto, caps = await this.capsFor(peer);
539
+ if (!this.closed && epoch === this.crypto) return this.signalTo(peer, { kind: 'caps', caps });
540
+ }
541
+
542
+ scheduleCapsBroadcast() {
543
+ if (this.capsTimer || this.closed) return;
544
+ this.capsTimer = setTimeout(() => { this.capsTimer = null; for (const peer of this.known) this.sendCaps(peer); }, 250);
545
+ }
546
+
547
+ updateCaps(from, caps) {
548
+ if (!caps || typeof caps !== 'object') return;
549
+ const before = this.caps.get(from);
550
+ const clean = { role: caps.role === 'gateway' && isGatewayMember(from) ? 'gateway' : 'peer', forward: caps.forward === true,
551
+ peers: (Array.isArray(caps.peers) ? caps.peers : []).filter(p => typeof p === 'string' && PEER_ID.test(p)).slice(0, 32),
552
+ gateway: cleanGateway(caps.gateway) };
553
+ this.caps.set(from, clean);
554
+ // Peers a member reports are hints too: greet them through the mesh.
555
+ for (const p of clean.peers) if (p !== this.id && !this.known.has(p)) this.hint(p, null);
556
+ if (clean.gateway && !sameGateway(before?.gateway, clean.gateway)) {
557
+ // An endpoint's own gateway joins that pair's servers directly; for pairs that already
558
+ // escalated, a new session gateway is tried right away instead of at the next retry.
559
+ for (const link of this.links.values()) {
560
+ if (link.connected) continue;
561
+ if (link.id === from) link.refreshServers();
562
+ else if (link.phase >= PHASE.SESSION) this.rescueLink(link.id);
563
+ }
564
+ }
565
+ }
566
+
567
+ async setGateway(gateway) {
568
+ this.ownGateway = cleanOwnGateway(gateway); this.gatewayCreds.clear();
569
+ if (this.ownGateway) await this.gatewayCredsFor('self');
570
+ this.scheduleCapsBroadcast();
571
+ for (const link of this.links.values()) if (!link.connected) link.refreshServers();
572
+ }
573
+
574
+ iceServersFor(link) {
575
+ const servers = [];
576
+ const stun = [...this.stunUrls].slice(0, 3);
577
+ if (stun.length) servers.push({ urls: stun });
578
+ const add = g => { if (g && servers.length < 6 && !servers.some(s => s.urls[0] === g.urls[0])) servers.push({ urls: g.urls, username: g.username, credential: g.credential }); };
579
+ const self = this.gatewayCreds.get(`${this.tag8}:self`);
580
+ if (this.ownGateway && self) add({ urls: this.ownGateway.internalUrls.length ? this.ownGateway.internalUrls : this.ownGateway.urls, username: self.username, credential: self.credential });
581
+ add(this.caps.get(link.id)?.gateway);
582
+ for (const id of link.extraGateways) if (id !== link.id) add(this.caps.get(id)?.gateway);
583
+ return compatibleServers(servers, this.RTCPeerConnection);
584
+ }
585
+
586
+ sessionGatewayIds(link) {
587
+ // Gateways of connected participants and of the session's gateway members (host node).
588
+ return [...this.caps.entries()].filter(([id, c]) => id !== link.id && c.gateway &&
589
+ (this.gatewayMembers.has(id) || this.links.get(id)?.connected)).map(([id]) => id);
590
+ }
591
+
592
+ // ---------- path ladder ----------
593
+ escalate(link) {
594
+ if (this.closed || link.closed || link.connected) return;
595
+ this.counters.escalations++;
596
+ // Gateways of other session members not yet tried for this pair come first — also for a
597
+ // bridged pair: a relayed path keeps media end-to-end encrypted and avoids re-encoding.
598
+ const fresh = this.sessionGatewayIds(link).filter(id => !link.extraGateways.has(id)).slice(0, 2);
599
+ if (fresh.length) {
600
+ for (const id of fresh) link.extraGateways.add(id);
601
+ if (link.phase < PHASE.SESSION) link.setPhase(PHASE.SESSION); else link.armWatchdog(true);
602
+ link.applyServers(true);
603
+ return;
604
+ }
605
+ if (link.phase < PHASE.BRIDGED && this.bridgeCandidates(link).length) {
606
+ link.setPhase(PHASE.BRIDGED);
607
+ this.requestBridge(link);
608
+ return;
609
+ }
610
+ if (link.phase === PHASE.BRIDGED && !this.bridges.get(pairKey(this.id, link.id))?.via) this.requestBridge(link);
611
+ // Nothing better exists right now: keep retrying slowly. New peers, gateways or a network
612
+ // change may open a path later; every retry is a cheap ICE restart, never a server relay.
613
+ if (link.phase === PHASE.ENDPOINT) link.phase = PHASE.SESSION;
614
+ link.path = this.bridges.get(pairKey(this.id, link.id))?.state === 'active' ? link.path : { kind: 'unreachable' };
615
+ this.emitPath(link);
616
+ link.grace = false;
617
+ link.requestRestart();
618
+ // Exponential backoff: a pair that stays unreachable costs the gate almost nothing.
619
+ const delay = Math.min(this.timing.bridgedRetryMs * 2 ** Math.min(link.retries++, 4), this.timing.maxRetryMs);
620
+ clearTimeout(link.watchdog);
621
+ link.watchdog = setTimeout(() => { link.watchdog = null; link.enqueue(() => link.checkProgress()); }, delay);
622
+ }
623
+
624
+ rescueLink(peer) {
625
+ const link = this.links.get(peer);
626
+ if (!link || link.connected || link.closed) return;
627
+ link.grace = false; link.enqueue(() => link.checkProgress());
628
+ }
629
+
630
+ emitPath(link) {
631
+ const bridge = this.bridges.get(pairKey(this.id, link.id));
632
+ if (!link.connected && bridge?.state === 'active') link.path = { kind: 'bridged', via: bridge.via };
633
+ else if (!link.connected && link.path.kind !== 'unreachable') link.path = { kind: 'connecting', phase: link.phase };
634
+ this.emit('path', { peer: link.id, ...link.path });
635
+ }
636
+
637
+ // The selected pair can be missing from stats right at "connected"; retry briefly. The media
638
+ // watch also refreshes every connected link, which catches later route upgrades.
639
+ async updatePath(link, report, attempt = 0) {
640
+ const info = await link.classify(report).catch(() => null);
641
+ if (link.closed || !link.connected) return;
642
+ if (!info) { if (attempt < 10) setTimeout(() => this.updatePath(link, undefined, attempt + 1), 300); return; }
643
+ let kind = 'direct', via = null;
644
+ // A local peer-reflexive candidate learned through a TURN allocation still rides the relay.
645
+ if (info.local === 'relay' || info.relayProtocol || info.remote === 'relay') {
646
+ via = this.gatewayOwner(info);
647
+ kind = via === this.id || via === link.id ? 'gateway' : 'relay';
648
+ }
649
+ const changed = link.path.kind !== kind || link.path.via !== via;
650
+ link.path = { kind, via, ...info };
651
+ if (changed) this.emit('path', { peer: link.id, ...link.path });
652
+ }
653
+
654
+ gatewayOwner(info) {
655
+ const owns = (g, address, url) => g && (address && g.external.includes(address) || url && (g.urls.includes(url) || g.internalUrls?.includes(url)) ||
656
+ address && g.internal === address);
657
+ const candidates = [[this.id, this.ownGateway], ...[...this.caps].map(([id, c]) => [id, c.gateway])];
658
+ for (const [id, g] of candidates) {
659
+ if ((info.local === 'relay' || info.relayProtocol) && owns(g, info.localAddress, info.relayUrl)) return id;
660
+ if (info.remote === 'relay' && owns(g, info.remoteAddress, null)) return id;
661
+ }
662
+ return 'unknown';
663
+ }
664
+
665
+ onLinkConnected(link) {
666
+ this.lastContact.set(link.id, Date.now());
667
+ this.scheduleCapsBroadcast();
668
+ this.applyEncodingLimits(link);
669
+ if (this.adaptiveVideoEnabled) {
670
+ const quality = this.videoQualityState(link.id);
671
+ quality.previous = null; quality.sendBad = quality.sendGood = quality.receiveBad = quality.receiveGood = 0;
672
+ this.emit('video-quality', {peer: link.id, direction: 'send', level: this.effectiveVideoLevel(quality), reason: 'monitoring'});
673
+ this.emit('video-quality', {peer: link.id, direction: 'receive', level: quality.receiveLevel, reason: 'monitoring'});
674
+ if (quality.receiveLevel !== 'normal') this.signalTo(link.id, {kind: 'video-quality', level: quality.receiveLevel}).catch(() => {});
675
+ }
676
+ this.flushOutbox(link.id);
677
+ const key = pairKey(this.id, link.id), bridge = this.bridges.get(key);
678
+ if (bridge) { this.bridges.delete(key); if (bridge.via) this.signalTo(bridge.via, { kind: 'bridge-release', a: this.id, b: link.id }); }
679
+ if (link.phase === PHASE.BRIDGED) link.phase = PHASE.SESSION;
680
+ link.retries = 0;
681
+ this.emit('link', { peer: link.id, connected: true });
682
+ }
683
+
684
+ onLinkDown(link) {
685
+ this.scheduleCapsBroadcast();
686
+ for (const [key, b] of [...this.relaying]) if (b.a === link.id || b.b === link.id) this.endRelayBridge(key, 'link-down');
687
+ for (const [key, b] of [...this.bridges]) if (b.via === link.id) { this.bridges.delete(key); this.rescueLink(b.peer); }
688
+ this.emit('link', { peer: link.id, connected: false });
689
+ }
690
+
691
+ // ---------- participant bridging (last resort, still inside the session) ----------
692
+ bridgeCandidates(link) {
693
+ return [...this.links.values()].filter(l => l !== link && l.connected && this.caps.get(l.id)?.forward && this.caps.get(l.id)?.peers.includes(link.id));
694
+ }
695
+
696
+ requestBridge(link) {
697
+ const key = pairKey(this.id, link.id);
698
+ const existing = this.bridges.get(key);
699
+ if (existing?.via) return;
700
+ // Only the lower id coordinates, so both endpoints never pick different forwarders; the
701
+ // other endpoint takes over if nothing happened within bridgeWaitMs.
702
+ if (this.id > link.id && (!existing || Date.now() - existing.requestedAt < this.timing.bridgeWaitMs)) {
703
+ if (!existing) this.bridges.set(key, { peer: link.id, via: null, state: 'waiting', requestedAt: Date.now() });
704
+ setTimeout(() => { if (!this.bridges.get(key)?.via && !link.connected && !link.closed) this.requestBridge(link); }, this.timing.bridgeWaitMs + 50);
705
+ return;
706
+ }
707
+ this.bridges.set(key, { peer: link.id, via: null, state: 'requested', requestedAt: Date.now() });
708
+ for (const candidate of this.bridgeCandidates(link)) this.signalTo(candidate.id, { kind: 'bridge-request', a: this.id, b: link.id });
709
+ }
710
+
711
+ canForward(a, b, key) {
712
+ const la = this.links.get(a), lb = this.links.get(b);
713
+ return this.forward && !!la?.connected && !!lb?.connected && (this.relaying.has(key) || this.bridgePending.has(key) || this.relaying.size + this.bridgePending.size < this.limits.maxBridges);
714
+ }
715
+
716
+ async onBridgeMessage(from, p) {
717
+ const a = p.a, b = p.b;
718
+ if (!PEER_ID.test(a ?? '') || !PEER_ID.test(b ?? '') || a === b) return;
719
+ const key = pairKey(a, b);
720
+ switch (p.kind) {
721
+ case 'bridge-request': {
722
+ if (from !== a || !this.canForward(a, b, key)) return;
723
+ this.bridgeOffers.set(key, { a, b, at: Date.now() });
724
+ return this.signalTo(a, { kind: 'bridge-offer', a, b });
725
+ }
726
+ case 'bridge-offer': {
727
+ const bridge = this.bridges.get(key);
728
+ const link = this.links.get(b);
729
+ if (a !== this.id || !bridge || bridge.via || !link || link.connected || !this.bridgeCandidates(link).some(l => l.id === from)) return;
730
+ bridge.via = from; bridge.state = 'accepted';
731
+ return this.signalTo(from, { kind: 'bridge-accept', a, b });
732
+ }
733
+ case 'bridge-accept': {
734
+ // Only an accept for an offer we made, from the endpoint we made it to.
735
+ const offer = this.bridgeOffers.get(key);
736
+ if (from !== a || !offer || offer.a !== a || Date.now() - offer.at >= 30000 || !this.canForward(a, b, key)) return;
737
+ this.bridgeOffers.delete(key);
738
+ return this.startRelayBridge(key, a, b);
739
+ }
740
+ case 'bridge-confirm': {
741
+ if (b !== this.id) return;
742
+ const link = this.links.get(a), existing = this.bridges.get(key);
743
+ if (!link || link.connected || existing?.via && existing.via !== from || !this.bridgeCandidates(link).some(l => l.id === from)) {
744
+ return this.signalTo(from, {kind: 'bridge-release', a, b});
745
+ }
746
+ this.bridges.set(key, {peer: a, via: from, state: 'accepted', requestedAt: Date.now()});
747
+ return this.signalTo(from, {kind: 'bridge-ready', a, b});
748
+ }
749
+ case 'bridge-ready': {
750
+ const pending = this.bridgePending.get(key);
751
+ if (!pending || pending.a !== a || pending.b !== b || from !== b || Date.now() - pending.at >= 30000 || !this.canForward(a, b, key)) return;
752
+ this.bridgePending.delete(key);
753
+ return this.activateRelayBridge(key, a, b);
754
+ }
755
+ case 'bridge-active': {
756
+ if (a !== this.id && b !== this.id) return;
757
+ const peer = a === this.id ? b : a;
758
+ const link = this.links.get(peer), bridge = this.bridges.get(key);
759
+ // A second forwarder (both endpoints asked) or a stale one is released at once.
760
+ if (!link || link.connected || !bridge || bridge.via !== from || !['accepted', 'active'].includes(bridge.state) || !this.bridgeCandidates(link).some(l => l.id === from)) { this.signalTo(from, { kind: 'bridge-release', a, b }); return; }
761
+ this.bridges.set(key, { peer, via: from, state: 'active', requestedAt: bridge?.requestedAt ?? Date.now() });
762
+ this.counters.bridgesUsed++;
763
+ if (link.phase < PHASE.BRIDGED) link.phase = PHASE.BRIDGED;
764
+ link.path = { kind: 'bridged', via: from };
765
+ this.emit('path', { peer, kind: 'bridged', via: from });
766
+ return;
767
+ }
768
+ case 'bridge-release': {
769
+ const pending = this.bridgePending.get(key);
770
+ if (pending && (from === pending.a || from === pending.b)) {
771
+ this.bridgePending.delete(key);
772
+ this.signalTo(from === pending.a ? pending.b : pending.a, {kind: 'bridge-fail', a, b});
773
+ }
774
+ const bridge = this.relaying.get(key);
775
+ if (bridge && (from === bridge.a || from === bridge.b)) this.endRelayBridge(key, 'released');
776
+ return;
777
+ }
778
+ case 'bridge-fail': {
779
+ const bridge = this.bridges.get(key);
780
+ if (bridge?.via === from) { this.bridges.delete(key); this.rescueLink(bridge.peer); }
781
+ return;
782
+ }
783
+ case 'forward-map': {
784
+ // Accept a forwarded stream only for an origin we cannot reach ourselves, and only from
785
+ // the forwarder of that pair once one is established.
786
+ const link = this.links.get(from), origin = p.origin;
787
+ if (!link || !PEER_ID.test(origin ?? '') || origin === from || origin === this.id || this.links.get(origin)?.connected) return;
788
+ const bridge = this.bridges.get(pairKey(this.id, origin));
789
+ if (bridge?.state !== 'active' || bridge.via !== from) return;
790
+ if (typeof p.stream !== 'string' || p.stream.length > 128 || link.forwardMap.size >= 16) return;
791
+ link.forwardMap.set(p.stream, origin);
792
+ return;
793
+ }
794
+ case 'forward-unmap': {
795
+ const link = this.links.get(from);
796
+ if (!link) return;
797
+ for (const [stream, origin] of [...link.forwardMap]) if (origin === p.origin) {
798
+ link.forwardMap.delete(stream);
799
+ this.emit('track-ended', { peer: origin, via: from, stream });
800
+ }
801
+ return;
802
+ }
803
+ }
804
+ }
805
+
806
+ async startRelayBridge(key, a, b) {
807
+ if (!this.canForward(a, b, key)) return;
808
+ this.bridgePending.set(key, {a, b, at: Date.now()});
809
+ await this.signalTo(b, {kind: 'bridge-confirm', a, b});
810
+ }
811
+
812
+ async activateRelayBridge(key, a, b) {
813
+ const la = this.links.get(a), lb = this.links.get(b);
814
+ if (!la?.connected || !lb?.connected) return this.signalTo(a, { kind: 'bridge-fail', a, b });
815
+ if (!this.relaying.has(key)) { this.relaying.set(key, { a, b }); this.counters.bridgesServed++; }
816
+ const bridge = this.relaying.get(key);
817
+ const current = () => !this.closed && this.relaying.get(key) === bridge && la.connected && lb.connected;
818
+ // Establish the authenticated forwarding relationship before sending attribution maps.
819
+ await this.signalTo(a, { kind: 'bridge-active', a, b });
820
+ if (!current()) return;
821
+ await this.signalTo(b, { kind: 'bridge-active', a, b });
822
+ if (!current()) return;
823
+ await this.forwardTracks(la, lb);
824
+ if (!current()) return;
825
+ await this.forwardTracks(lb, la);
826
+ }
827
+
828
+ async forwardTracks(fromLink, toLink) {
829
+ const key = pairKey(fromLink.id, toLink.id), bridge = this.relaying.get(key);
830
+ if (!bridge) return;
831
+ const tracks = fromLink.ownTracks();
832
+ const entry = toLink.forwardSenders.get(fromLink.id);
833
+ const stream = entry?.stream ?? new MediaStream();
834
+ if (!entry) toLink.forwardSenders.set(fromLink.id, { stream, senders: new Map() });
835
+ // The receiver must learn the stream's origin before the renegotiation that carries it.
836
+ await this.signalTo(toLink.id, { kind: 'forward-map', a: fromLink.id, b: toLink.id, origin: fromLink.id, stream: stream.id });
837
+ if (!this.closed && this.relaying.get(key) === bridge && fromLink.connected && toLink.connected && tracks.length) toLink.addForward(fromLink.id, tracks);
838
+ }
839
+
840
+ endRelayBridge(key, reason) {
841
+ const bridge = this.relaying.get(key);
842
+ if (!bridge) return;
843
+ this.relaying.delete(key);
844
+ for (const [from, to] of [[bridge.a, bridge.b], [bridge.b, bridge.a]]) {
845
+ const link = this.links.get(to);
846
+ if (!link) continue;
847
+ link.removeForward(from);
848
+ this.signalTo(to, { kind: 'forward-unmap', a: bridge.a, b: bridge.b, origin: from });
849
+ if (reason !== 'released') this.signalTo(to, { kind: 'bridge-fail', a: bridge.a, b: bridge.b });
850
+ }
851
+ }
852
+
853
+ // ---------- media ----------
854
+ async setMedia(media) {
855
+ if (media && typeof media.getTracks === 'function') { this.localStream = media; this.ownsMedia = false; return; }
856
+ this.localStream = new MediaStream(); this.ownsMedia = true;
857
+ if (media?.audio) await this.setMicrophone(true);
858
+ if (media?.video) await this.setCamera(true);
859
+ }
860
+
861
+ attachLocalMedia(link) {
862
+ for (const track of this.localStream?.getTracks() ?? []) {
863
+ if (!link.senders.has(track.kind)) link.senders.set(track.kind, link.pc.addTrack(track, this.localStream));
864
+ }
865
+ }
866
+
867
+ async #enableTrack(kind, enabled, constraints) {
868
+ if (this.closed) return;
869
+ let track = this.localStream.getTracks().find(t => t.kind === kind);
870
+ if (!enabled) {
871
+ if (!track) return;
872
+ if (kind === 'audio') { track.enabled = false; return; } // muted mic keeps its slot, no renegotiation
873
+ track.stop(); this.localStream.removeTrack(track);
874
+ for (const link of this.links.values()) link.senders.get(kind)?.replaceTrack(null);
875
+ // Camera-off is an explicit user choice. Do not leave an automatic pause latched
876
+ // when the user turns the camera back on later.
877
+ if (kind === 'video' && this.adaptiveVideoEnabled) {
878
+ for (const [peer, state] of this.videoQuality) {
879
+ state.sendLevel = 'normal'; state.sendBad = state.sendGood = 0; state.sendProbe = false;
880
+ this.setVideoQuality(peer, 'send', this.effectiveVideoLevel(state), 'camera-off');
881
+ }
882
+ }
883
+ return;
884
+ }
885
+ if (track) { track.enabled = true; return; }
886
+ const captured = await this.getUserMedia({ [kind]: this.constraintsFor(kind, constraints) });
887
+ if (this.closed) { for (const t of captured.getTracks()) t.stop(); return; }
888
+ track = captured.getTracks().find(t => t.kind === kind);
889
+ if (!track) return;
890
+ this.localStream.addTrack(track);
891
+ for (const link of this.links.values()) {
892
+ const sender = link.senders.get(kind);
893
+ if (sender) await sender.replaceTrack(track);
894
+ else link.senders.set(kind, link.pc.addTrack(track, this.localStream));
895
+ await this.applyEncodingLimits(link);
896
+ }
897
+ }
898
+
899
+ setMicrophone(enabled) { return this.#enableTrack('audio', enabled, MIC); }
900
+ setCamera(enabled) { return this.#enableTrack('video', enabled, CAM); }
901
+
902
+ constraintsFor(kind, base) { const id = this.devices[kind]; return id ? { ...base, deviceId: { exact: id } } : base; }
903
+
904
+ /**
905
+ * Use another microphone ('audio') or camera ('video'): a deviceId from enumerateDevices(), or
906
+ * null for the default. A running track is replaced on every link without renegotiation and
907
+ * keeps its mute state; otherwise the choice applies to the next setMicrophone/setCamera(true).
908
+ */
909
+ async switchDevice(kind, id) {
910
+ if (kind !== 'audio' && kind !== 'video') throw new TypeError("kind must be 'audio' or 'video'");
911
+ if (!deviceId(id)) throw new TypeError('Invalid capture device id');
912
+ this.devices = { ...this.devices, [kind]: id ?? null };
913
+ const old = this.localStream?.getTracks().find(t => t.kind === kind);
914
+ if (this.closed || !old) return !this.closed;
915
+ const captured = await this.getUserMedia({ [kind]: this.constraintsFor(kind, kind === 'audio' ? MIC : CAM) });
916
+ const track = captured.getTracks().find(t => t.kind === kind);
917
+ // Never keep a device that was captured after leave(), or for a track that was switched off meanwhile.
918
+ if (this.closed || !track || !this.localStream.getTracks().includes(old)) { for (const t of captured.getTracks()) t.stop(); return false; }
919
+ track.enabled = old.enabled;
920
+ this.localStream.addTrack(track); this.localStream.removeTrack(old);
921
+ for (const link of this.links.values()) await link.senders.get(kind)?.replaceTrack(track);
922
+ for (const link of this.links.values()) await this.applyEncodingLimits(link);
923
+ old.stop();
924
+ return true;
925
+ }
926
+
927
+ async applyEncodingLimits(link) {
928
+ const forwarded = new Set();
929
+ for (const entry of link.forwardSenders?.values?.() ?? []) for (const s of entry.senders.values()) forwarded.add(s);
930
+ const senders = link.pc.getSenders?.() ?? [...link.senders.values()];
931
+ for (const sender of senders) {
932
+ if (!sender.track) continue;
933
+ const params = sender.getParameters();
934
+ if (!params.encodings?.length) continue;
935
+ const max = sender.track.kind === 'audio' ? this.limits.audioBitrate : forwarded.has(sender) ? this.limits.forwardVideoBitrate : this.limits.videoBitrate;
936
+ const state = this.videoQuality.get(link.id);
937
+ const localLevel = this.adaptiveVideoEnabled ? state?.sendLevel ?? 'normal' : 'normal';
938
+ const remoteLevel = this.adaptiveVideoEnabled ? state?.remoteLevel ?? 'normal' : 'normal';
939
+ const quality = VIDEO_QUALITY_SETTINGS[Math.max(VIDEO_QUALITY_INDEX[localLevel], VIDEO_QUALITY_INDEX[remoteLevel])];
940
+ let changed = false;
941
+ for (const e of params.encodings) {
942
+ const bitrate = sender.track.kind === 'audio' ? max : quality.bitrate === null ? max : Math.min(max, quality.bitrate);
943
+ if (e.maxBitrate !== bitrate) { e.maxBitrate = bitrate; changed = true; }
944
+ if (sender.track.kind === 'video') {
945
+ if (e.active !== quality.active) { e.active = quality.active; changed = true; }
946
+ if (quality.framerate !== null && e.maxFramerate !== quality.framerate) { e.maxFramerate = quality.framerate; changed = true; }
947
+ if (quality.framerate === null && e.maxFramerate !== undefined) { delete e.maxFramerate; changed = true; }
948
+ if (e.scaleResolutionDownBy !== quality.scale) { e.scaleResolutionDownBy = quality.scale; changed = true; }
949
+ }
950
+ }
951
+ if (changed) { try { await sender.setParameters(params); } catch (error) { this.count('encodingErrors', error); } }
952
+ }
953
+ }
954
+
955
+ videoQualityState(peer) {
956
+ let state = this.videoQuality.get(peer);
957
+ if (!state) {
958
+ state = {sendLevel: 'normal', reportedSendLevel: 'normal', receiveLevel: 'normal', remoteLevel: 'normal', sendBad: 0, sendGood: 0,
959
+ receiveBad: 0, receiveGood: 0, previous: null, receiveProbe: false, sendProbe: false, lastControlAt: 0, lastRequestN: 0};
960
+ this.videoQuality.set(peer, state);
961
+ }
962
+ return state;
963
+ }
964
+
965
+ setVideoQuality(peer, direction, level, reason) {
966
+ const state = this.videoQualityState(peer);
967
+ const property = direction === 'send' ? 'reportedSendLevel' : 'receiveLevel';
968
+ if (state[property] === level) return false;
969
+ state[property] = level;
970
+ this.emit('video-quality', {peer, direction, level, reason});
971
+ return true;
972
+ }
973
+
974
+ async setAdaptiveVideo(enabled) {
975
+ if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean');
976
+ if (enabled === this.adaptiveVideoEnabled) return enabled;
977
+ if (!enabled) {
978
+ for (const [peer, state] of this.videoQuality) {
979
+ if (state.receiveLevel !== 'normal') this.signalTo(peer, {kind: 'video-quality', level: 'normal'}).catch(() => {});
980
+ if (state.reportedSendLevel !== 'normal') this.emit('video-quality', {peer, direction: 'send', level: 'normal', reason: 'disabled'});
981
+ if (state.receiveLevel !== 'normal') this.emit('video-quality', {peer, direction: 'receive', level: 'normal', reason: 'disabled'});
982
+ state.sendBad = state.sendGood = state.receiveBad = state.receiveGood = 0;
983
+ state.sendProbe = state.receiveProbe = false;
984
+ state.sendLevel = state.reportedSendLevel = state.receiveLevel = state.remoteLevel = 'normal';
985
+ }
986
+ this.adaptiveVideoEnabled = false;
987
+ } else {
988
+ this.adaptiveVideoEnabled = true;
989
+ for (const [peer, link] of this.links) {
990
+ const state = this.videoQualityState(peer);
991
+ state.previous = null; state.sendBad = state.sendGood = state.receiveBad = state.receiveGood = 0;
992
+ this.emit('video-quality', {peer, direction: 'send', level: this.effectiveVideoLevel(state), reason: 'monitoring'});
993
+ this.emit('video-quality', {peer, direction: 'receive', level: state.receiveLevel, reason: 'monitoring'});
994
+ if (link.connected && state.receiveLevel !== 'normal') this.signalTo(peer, {kind: 'video-quality', level: state.receiveLevel}).catch(() => {});
995
+ }
996
+ }
997
+ for (const link of this.links.values()) await this.applyEncodingLimits(link);
998
+ return enabled;
999
+ }
1000
+
1001
+ receiveVideoQuality(from, message) {
1002
+ const link = this.links.get(from);
1003
+ if (!this.adaptiveVideoEnabled || !link?.connected || !VIDEO_QUALITY.includes(message.level) ||
1004
+ !Number.isSafeInteger(message.n) || message.n <= 0) return;
1005
+ const state = this.videoQualityState(from), now = Date.now();
1006
+ if (message.n <= state.lastRequestN || now - state.lastControlAt < 1000) return;
1007
+ state.lastRequestN = message.n; state.lastControlAt = now;
1008
+ if (state.remoteLevel === message.level) return;
1009
+ state.remoteLevel = message.level;
1010
+ this.setVideoQuality(from, 'send', this.effectiveVideoLevel(state), 'peer-request');
1011
+ this.applyEncodingLimits(link);
1012
+ }
1013
+
1014
+ effectiveVideoLevel(state) {
1015
+ return VIDEO_QUALITY[Math.max(VIDEO_QUALITY_INDEX[state.sendLevel], VIDEO_QUALITY_INDEX[state.remoteLevel])];
1016
+ }
1017
+
1018
+ reportReceiveVideoQuality(link, level, reason) {
1019
+ const state = this.videoQualityState(link.id);
1020
+ const changed = state.receiveLevel !== level;
1021
+ if (!changed) return;
1022
+ state.receiveLevel = level;
1023
+ this.emit('video-quality', {peer: link.id, direction: 'receive', level, reason});
1024
+ this.signalTo(link.id, {kind: 'video-quality', level}).catch(error => this.count('videoQualityErrors', error));
1025
+ }
1026
+
1027
+ async checkAdaptiveVideo(link, stats) {
1028
+ if (!this.adaptiveVideoEnabled || !link.connected || link.closed) return;
1029
+ const state = this.videoQualityState(link.id), values = [...stats.values()];
1030
+ const outbound = values.filter(s => s.type === 'outbound-rtp' && s.kind === 'video');
1031
+ const inbound = values.filter(s => s.type === 'inbound-rtp' && s.kind === 'video');
1032
+ const transport = values.find(s => s.type === 'transport' && s.selectedCandidatePairId);
1033
+ const pair = (transport && stats.get(transport.selectedCandidatePairId)) ??
1034
+ values.find(s => s.type === 'candidate-pair' && (s.selected || s.nominated)) ??
1035
+ values.find(s => s.type === 'candidate-pair' && s.state === 'succeeded');
1036
+ const sum = (items, key) => items.reduce((total, item) => total + (item[key] ?? 0), 0);
1037
+ const current = {
1038
+ outBytes: sum(outbound, 'bytesSent'), outPackets: sum(outbound, 'packetsSent'),
1039
+ inBytes: sum(inbound, 'bytesReceived'), inPackets: sum(inbound, 'packetsReceived'), inLost: sum(inbound, 'packetsLost'),
1040
+ inFrames: sum(inbound, 'framesDecoded'), inDropped: sum(inbound, 'framesDropped'), inDecode: sum(inbound, 'totalDecodeTime'),
1041
+ limitation: outbound.some(s => s.qualityLimitationReason === 'cpu') ? 'cpu' : outbound.some(s => s.qualityLimitationReason === 'bandwidth') ? 'bandwidth' : 'none',
1042
+ available: pair?.availableOutgoingBitrate ?? null,
1043
+ };
1044
+ const previous = state.previous;
1045
+ state.previous = current;
1046
+ if (!previous) return;
1047
+ const hasLocalVideo = this.localStream?.getTracks().some(track => track.kind === 'video' && track.readyState === 'live');
1048
+ const sentBytes = current.outBytes - previous.outBytes;
1049
+ const recvPackets = current.inPackets - previous.inPackets;
1050
+ const lostPackets = current.inLost - previous.inLost;
1051
+ const decoded = current.inFrames - previous.inFrames;
1052
+ const dropped = current.inDropped - previous.inDropped;
1053
+ const decodeTime = current.inDecode - previous.inDecode;
1054
+ const lossRate = Math.max(0, recvPackets) + Math.max(0, lostPackets) > 0 ? Math.max(0, lostPackets) / (Math.max(0, recvPackets) + Math.max(0, lostPackets)) : 0;
1055
+ const dropRate = Math.max(0, decoded) + Math.max(0, dropped) > 0 ? Math.max(0, dropped) / (Math.max(0, decoded) + Math.max(0, dropped)) : 0;
1056
+ const decodeSlow = decoded >= 5 && decodeTime / decoded > 0.05;
1057
+ const sendLimit = VIDEO_QUALITY_SETTINGS[VIDEO_QUALITY_INDEX[this.effectiveVideoLevel(state)]].bitrate ?? this.limits.videoBitrate;
1058
+ const lowEstimate = current.available !== null && current.available < Math.min(180000, sendLimit * 0.7);
1059
+ const outgoingBad = current.limitation === 'cpu' || current.limitation === 'bandwidth' && lowEstimate || lowEstimate && sentBytes > 0;
1060
+ const incomingBad = recvPackets + lostPackets >= 10 && lossRate >= 0.08 || decoded + dropped >= 10 && dropRate >= 0.12 || decodeSlow;
1061
+ const severeReceive = lossRate >= 0.25 || dropRate >= 0.3 || decoded >= 5 && decodeTime / decoded > 0.15;
1062
+ const now = Date.now();
1063
+
1064
+ state.sendBad = hasLocalVideo && outgoingBad ? state.sendBad + 1 : 0;
1065
+ state.sendGood = hasLocalVideo && !outgoingBad ? state.sendGood + 1 : 0;
1066
+ if (hasLocalVideo && state.sendBad >= 2) {
1067
+ const severeSend = current.available !== null && current.available < 60000 || state.sendLevel === 'minimal' && current.limitation === 'cpu';
1068
+ const next = severeSend ? 'paused' : VIDEO_QUALITY[Math.min(2, VIDEO_QUALITY_INDEX[state.sendLevel] + 1)];
1069
+ state.sendBad = 0;
1070
+ if (next !== state.sendLevel) {
1071
+ state.sendProbe = false;
1072
+ state.sendGood = state.sendBad = 0;
1073
+ state.sendChangedAt = now;
1074
+ state.sendLevel = next;
1075
+ this.setVideoQuality(link.id, 'send', this.effectiveVideoLevel(state), current.limitation === 'cpu' ? 'cpu' : 'bandwidth');
1076
+ await this.applyEncodingLimits(link);
1077
+ }
1078
+ } else if (hasLocalVideo && state.sendLevel === 'paused' && !state.sendProbe && now - (state.sendChangedAt ?? now) >= 25000) {
1079
+ state.sendProbe = true; state.sendLevel = 'minimal'; state.sendGood = 0;
1080
+ this.setVideoQuality(link.id, 'send', this.effectiveVideoLevel(state), 'recovery-probe');
1081
+ await this.applyEncodingLimits(link);
1082
+ } else if (hasLocalVideo && state.sendGood >= 5 && state.sendLevel !== 'normal') {
1083
+ state.sendLevel = VIDEO_QUALITY[Math.max(0, VIDEO_QUALITY_INDEX[state.sendLevel] - 1)];
1084
+ state.sendProbe = false; state.sendGood = state.sendBad = 0; state.sendChangedAt = now;
1085
+ this.setVideoQuality(link.id, 'send', this.effectiveVideoLevel(state), 'recovery');
1086
+ await this.applyEncodingLimits(link);
1087
+ }
1088
+
1089
+ state.receiveBad = incomingBad ? state.receiveBad + 1 : 0;
1090
+ state.receiveGood = incomingBad ? 0 : state.receiveGood + 1;
1091
+ if (state.receiveBad >= 2) {
1092
+ const next = severeReceive ? 'paused' : VIDEO_QUALITY[Math.min(3, VIDEO_QUALITY_INDEX[state.receiveLevel] + 1)];
1093
+ state.receiveBad = 0;
1094
+ if (next !== state.receiveLevel) {
1095
+ state.receiveProbe = false; state.receiveGood = state.receiveBad = 0; state.receiveChangedAt = now;
1096
+ this.reportReceiveVideoQuality(link, next, decodeSlow ? 'cpu' : 'bandwidth');
1097
+ }
1098
+ } else if (state.receiveLevel === 'paused' && !state.receiveProbe && now - (state.receiveChangedAt ?? now) >= 25000) {
1099
+ state.receiveProbe = true; state.receiveGood = 0;
1100
+ this.reportReceiveVideoQuality(link, 'minimal', 'recovery-probe');
1101
+ } else if (state.receiveGood >= 5 && state.receiveLevel !== 'normal') {
1102
+ const next = VIDEO_QUALITY[Math.max(0, VIDEO_QUALITY_INDEX[state.receiveLevel] - 1)];
1103
+ state.receiveProbe = false; state.receiveGood = state.receiveBad = 0; state.receiveChangedAt = now;
1104
+ this.reportReceiveVideoQuality(link, next, 'recovery');
1105
+ }
1106
+ }
1107
+
1108
+ // Safety net: a connected link whose live local track sends no packets for two checks gets
1109
+ // a fresh ICE restart from its impolite side (the polite side asks for one).
1110
+ async checkSending() {
1111
+ const epoch = this.crypto;
1112
+ for (const link of [...this.links.values()]) {
1113
+ if (!link.connected || link.closed) continue;
1114
+ let stats; try { stats = await link.pc.getStats(); } catch { continue; }
1115
+ if (this.closed || this.crypto !== epoch || link.closed) return;
1116
+ this.updatePath(link, stats);
1117
+ await this.checkAdaptiveVideo(link, stats);
1118
+ const sent = {};
1119
+ for (const s of stats.values()) if (s.type === 'outbound-rtp' && s.kind) sent[s.kind] = (sent[s.kind] ?? 0) + (s.packetsSent ?? 0);
1120
+ const progress = this.sendProgress.get(link.id) ?? {};
1121
+ for (const track of this.localStream?.getTracks() ?? []) {
1122
+ const sender = link.senders.get(track.kind);
1123
+ if (track.kind === 'video' && this.effectiveVideoLevel(this.videoQualityState(link.id)) === 'paused') { delete progress.video; continue; }
1124
+ if (track.readyState !== 'live' || !sender?.track) { delete progress[track.kind]; continue; }
1125
+ const now = sent[track.kind] ?? 0, before = progress[track.kind];
1126
+ const stalls = before && now <= before.packets ? before.stalls + 1 : 0;
1127
+ progress[track.kind] = { packets: now, stalls };
1128
+ if (stalls === 2) {
1129
+ this.counters.mediaStalls++;
1130
+ if (link.polite) this.signalTo(link.id, { kind: 'restart-request' }); else link.restart();
1131
+ }
1132
+ }
1133
+ this.sendProgress.set(link.id, progress);
1134
+ }
1135
+ }
1136
+
1137
+ onRemoteTrack(link, origin, track, stream) {
1138
+ this.emit('track', { peer: origin, via: origin === link.id ? null : link.id, track, stream });
1139
+ if (origin !== link.id) return;
1140
+ // A forwarder keeps forwarding tracks the origin adds later (e.g. camera switched on).
1141
+ for (const bridge of this.relaying.values()) {
1142
+ const other = bridge.a === origin ? bridge.b : bridge.b === origin ? bridge.a : null;
1143
+ const target = other && this.links.get(other);
1144
+ if (target?.connected) target.addForward(origin, [track]);
1145
+ }
1146
+ }
1147
+
1148
+ // ---------- inspection & teardown ----------
1149
+ async stats() {
1150
+ const links = [];
1151
+ for (const link of this.links.values()) {
1152
+ const media = { inbound: {}, outbound: {} };
1153
+ try {
1154
+ for (const s of (await link.pc.getStats()).values()) {
1155
+ if (s.type === 'inbound-rtp' && s.kind) {
1156
+ const m = media.inbound[s.kind] ??= { packets: 0, bytes: 0, frames: 0, samples: 0, lost: 0, jitter: 0, streams: 0 };
1157
+ m.streams++; m.packets += s.packetsReceived ?? 0; m.bytes += s.bytesReceived ?? 0; m.frames += s.framesDecoded ?? 0;
1158
+ m.samples += s.totalSamplesReceived ?? 0; m.lost += s.packetsLost ?? 0; m.jitter = Math.max(m.jitter, s.jitter ?? 0);
1159
+ if (s.frameWidth) { m.width = s.frameWidth; m.height = s.frameHeight; }
1160
+ } else if (s.type === 'outbound-rtp' && s.kind) {
1161
+ const m = media.outbound[s.kind] ??= { packets: 0, bytes: 0, frames: 0, streams: 0 };
1162
+ m.streams++; m.packets += s.packetsSent ?? 0; m.bytes += s.bytesSent ?? 0; m.frames += s.framesEncoded ?? 0;
1163
+ }
1164
+ }
1165
+ } catch {}
1166
+ links.push({ peer: link.id, connected: link.connected, phase: link.phase, path: { ...link.path }, restarts: link.restarts,
1167
+ connectMs: link.connectedAt ? link.connectedAt - link.createdAt : null, forwardedOrigins: [...link.forwardMap.values()], forwarding: [...link.forwardSenders.keys()], media });
1168
+ }
1169
+ return { id: this.id, tag: this.tag, counters: { ...this.counters },
1170
+ gates: this.gates.map(g => ({ url: g.url, state: g.state, ...g.counters })),
1171
+ bridges: [...this.bridges.values()].map(b => ({ ...b })), relaying: [...this.relaying.values()].map(b => ({ ...b })), links };
1172
+ }
1173
+
1174
+ async leave() {
1175
+ if (this.closed) return;
1176
+ const goodbyes = [...this.known, ...this.gatewayMembers].map(peer => this.signalTo(peer, { kind: 'bye' }).catch(() => {}));
1177
+ await Promise.race([Promise.all(goodbyes), new Promise(r => setTimeout(r, 300))]);
1178
+ this.closed = true;
1179
+ clearTimeout(this.capsTimer); clearInterval(this.mediaWatch); clearInterval(this.sweeper);
1180
+ for (const t of this.pendingLinks.values()) clearTimeout(t);
1181
+ for (const link of this.links.values()) link.close();
1182
+ this.links.clear(); this.outbox.clear();
1183
+ this.videoQuality.clear();
1184
+ for (const gate of this.gates) gate.close();
1185
+ if (this.ownsMedia) for (const track of this.localStream?.getTracks() ?? []) track.stop();
1186
+ this.emit('closed', {});
1187
+ }
1188
+ }