/** * PeerConnectionManager * * Manages RTCPeerConnection lifecycle including: * - Connection creation and teardown * - ICE candidate gathering and exchange * - Connection state monitoring * - Automatic reconnection with exponential backoff */ import { ensureWebRTCGlobals } from './webrtc-globals'; import { TwinTransport, PeerConnectionConfig, SignalingMessageType, SignalingSink } from './types'; import { DEFAULT_WEBRTC_OPTIONS } from './webrtc-manager'; export interface PeerConnectionManagerOptions { connectionTimeout: number; initialRetryDelay: number; maxRetryDelay: number; } /** * Extract the ICE ufrag from an SDP blob. The ufrag changes whenever a peer * starts a new session (e.g. a browser reload rebuilds its RTCPeerConnection), * so it lets the responder tell a genuinely-new session apart from a duplicate * or in-session re-offer. Returns null if absent/unparseable. */ function extractIceUfrag(sdp: string | undefined | null): string | null { if (!sdp) return null; const m = /a=ice-ufrag:(\S+)/.exec(sdp); return m ? m[1] : null; } /** * Mint a random per-session peer id used to demultiplex peers that share one * logical channel (see responder-fanout.ts). Prefers crypto.randomUUID (browser * + Node 16+ / @roamhq runtime) and falls back to a timestamp+random string. */ function generatePeerId(): string { const webcrypto = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; if (webcrypto && typeof webcrypto.randomUUID === 'function') { return webcrypto.randomUUID(); } return `peer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } /** Whether a single ICE URL is a TURN (relay) URL rather than STUN. */ function isTurnUrl(url: string): boolean { return url.startsWith('turn:') || url.startsWith('turns:'); } /** * Strip STUN from an ICE server list, keeping only TURN (relay) endpoints. Used * on the no-STUN retry: dropping STUN forces a stuck srflx path to fall back to * relay. STUN URLs are removed even from entries that bundle STUN+TURN together, * and STUN-only entries are dropped entirely. */ function keepTurnServers(servers: RTCIceServer[]): RTCIceServer[] { const result: RTCIceServer[] = []; for (const server of servers) { const urls = (Array.isArray(server.urls) ? server.urls : [server.urls]).filter(isTurnUrl); if (urls.length === 0) continue; result.push({ urls, username: server.username, credential: server.credential }); } return result; } // Refresh a provider-supplied ICE list this long before its credentials expire, // so a reconnect near the TTL boundary doesn't race the relay's clock. const ICE_SERVERS_REFRESH_MARGIN_MS = 30_000; // Backstop on how long we wait for iceServersProvider() before falling back to // the static config. The connection-timeout is only armed AFTER ICE servers // resolve, so a provider that hangs (e.g. a fetch with no timeout of its own) // would otherwise wedge the attempt indefinitely. Sized ABOVE the SDK provider's // own request timeout (PhyHubClient.getIceServers ~10s) so this backstop never // pre-empts a slow-but-live response — it only catches a provider that has no // timeout at all. const ICE_SERVERS_FETCH_TIMEOUT_MS = 12_000; // Cap on how long the non-trickle one-shot answer path waits for ICE gathering to // complete before emitting the answer SDP anyway. A viewer signaled over HTTP has // no return channel for trickled candidates, so the answer must carry them // embedded; but a relay-only gather can stall, so we cap the wait and emit the // best-available SDP rather than hang the HTTP request. const ICE_GATHERING_COMPLETE_TIMEOUT_MS = 3_000; /** Reject if `promise` hasn't settled within `timeoutMs`; clears its timer either way. */ function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); }, ); }); } /** * Epoch ms after which a provider ICE list (with credential TTL `ttlSeconds`) * must be re-fetched. * - undefined ttl → never (a non-expiring, static-ish list resolved once). * - ttl > 0 → a refresh margin before expiry, with the margin clamped to half * the TTL so a short TTL doesn't make every reconnect attempt re-fetch. * - ttl <= 0 → now (re-resolve on the next attempt; e.g. a STUN-only fail-safe * self-heals once the relay is configured, rather than pinning forever). */ function computeIceServersRefreshAt(ttlSeconds: number | undefined): number { if (ttlSeconds === undefined) return Number.POSITIVE_INFINITY; if (ttlSeconds <= 0) return Date.now(); const ttlMs = ttlSeconds * 1000; const margin = Math.min(ICE_SERVERS_REFRESH_MARGIN_MS, ttlMs / 2); return Date.now() + (ttlMs - margin); } export class PeerConnectionManager { private pc: RTCPeerConnection | null = null; private config: PeerConnectionConfig; private transport: TwinTransport; private options: PeerConnectionManagerOptions; private isShuttingDown = false; private isReconnecting = false; private connectionId = 0; private pendingCandidates: RTCIceCandidateInit[] = []; private connectionTimeout: NodeJS.Timeout | null = null; private messageHandler: ((message: any) => void) | null = null; // True once THIS pc has reached 'connected' at least once. Reset on cleanup so // every fresh pc starts clean. Used to recognise a brand-new session (the remote // peer reloaded) vs the first offer of the current session. private connectionEstablishedOnce = false; // When a responder recognises a session restart, the new offer is stashed here // and answered on the freshly rebuilt pc — carried THROUGH the rebuild so it is // never dropped in the offer-race window. private pendingRestartOffer: { offer: RTCSessionDescriptionInit } | null = null; // Per-session id (see generatePeerId / PeerConnectionConfig.peerId). The // initiator mints one if not supplied; the responder fan-out supplies the // peer's id. null = plain responder (no peerId exchanged, no filtering). private peerId: string | null = null; // Signaling messages fed via ingestSignalingMessage() before the pc exists // (responder fan-out: the offer can arrive while connect() is still awaiting // WebRTC globals). Drained once the pc and its handlers are set up. private pendingIngress: any[] = []; // ICE servers resolved from config.iceServersProvider (if set). Reused across // attempts/rebuilds until due for refresh, then re-resolved (TURN credentials // are short-lived). null = no provider or it failed → createPeerConnection // falls back to the static stun/turn config. Intentionally NOT reset in // cleanup(): the cache must survive rebuilds, or every reconnect re-fetches. private resolvedIceServers: RTCIceServer[] | null = null; // Epoch ms after which resolvedIceServers must be re-fetched (see // computeIceServersRefreshAt). 0 forces a re-resolve on the next attempt. private resolvedIceServersRefreshAt = 0; // Epoch ms at which the resolved credentials actually EXPIRE (Infinity for a // non-expiring static list). Distinct from the refresh deadline above: on a // failed refresh we keep stale creds only while still before this, else drop. private resolvedIceServersExpiresAt = 0; constructor( config: PeerConnectionConfig, transport: TwinTransport, options: Partial = {}, ) { this.config = config; this.transport = transport; // Initiator mints an id so the responder can echo it back and so it can // ignore answers/ice meant for other peers on the same channel. The // responder leaves it null unless the fan-out supplied a specific peer id. this.peerId = config.peerId ?? (config.isInitiator ? generatePeerId() : null); this.options = { connectionTimeout: options.connectionTimeout ?? DEFAULT_WEBRTC_OPTIONS.connectionTimeout, initialRetryDelay: options.initialRetryDelay ?? DEFAULT_WEBRTC_OPTIONS.initialRetryDelay, maxRetryDelay: options.maxRetryDelay ?? DEFAULT_WEBRTC_OPTIONS.maxRetryDelay, }; } /** * The per-session peer id this manager talks to (or null for a plain responder * that received no peerId). */ getPeerId(): string | null { return this.peerId; } /** * Feed a signaling message in from an external dispatcher (responder fan-out). * Used when selfManagedSignaling is set, so the dispatcher owns the single * transport subscription and routes per-peer messages here. Buffered until the * pc exists, then handled in order. */ async ingestSignalingMessage(message: any): Promise { if (this.isShuttingDown) return; if (!this.pc) { this.pendingIngress.push(message); return; } try { await this.handleSignalingMessage(message); } catch (err) { console.error('[PeerConnectionManager] Error handling ingested message:', err); } } /** * Start the WebRTC connection process. * Returns the RTCPeerConnection once connected. */ async connect(): Promise { const available = await ensureWebRTCGlobals(); if (!available) { throw new Error('WebRTC native module not available in this environment'); } if (this.pc && this.pc.connectionState === 'connected') { return this.pc; } return this.attemptConnection(0, this.config.useStun, this.options.initialRetryDelay); } /** * Resolve config.iceServersProvider, refreshing when the cached credentials * are near expiry. Called at the start of every attemptConnection (initial, * STUN-toggle retry, and rebuild) so a reconnect after the TURN TTL has lapsed * fetches fresh credentials rather than reusing dead ones. Within one * connection's rapid retry chain the cache is still fresh, so this is a no-op. * * A throw leaves any previously-resolved servers in place (they may still be * valid; a transient fetch failure shouldn't drop a live relay); only when we * have never resolved any does it fall back to the static stun/turn config. An * empty result likewise falls back to static. */ private async resolveIceServers(): Promise { if (!this.config.iceServersProvider) return; const stillFresh = this.resolvedIceServers !== null && Date.now() < this.resolvedIceServersRefreshAt; if (stillFresh) return; try { // Bound the (consumer-supplied) provider call so a hung fetch can't wedge // the attempt — the connection-timeout isn't armed until after this resolves. const result = await withTimeout( Promise.resolve(this.config.iceServersProvider()), ICE_SERVERS_FETCH_TIMEOUT_MS, 'iceServersProvider', ); const iceServers = Array.isArray(result) ? result : result.iceServers; const ttlSeconds = Array.isArray(result) ? undefined : result.ttlSeconds; if (iceServers && iceServers.length > 0) { this.resolvedIceServers = iceServers; this.resolvedIceServersRefreshAt = computeIceServersRefreshAt(ttlSeconds); // Hard expiry: undefined ttl = non-expiring static-ish list; >0 = now+ttl; // <=0 = treat as already expired (so a failed refresh won't keep it). this.resolvedIceServersExpiresAt = ttlSeconds === undefined ? Number.POSITIVE_INFINITY : ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0; } else { // Provider returned no servers → fall back to the static stun/turn config. // Clear any previously-cached list so a region that lost its relay config // (or a now-unconfigured endpoint) self-heals instead of reusing dead creds. this.resolvedIceServers = null; this.resolvedIceServersRefreshAt = 0; this.resolvedIceServersExpiresAt = 0; } } catch (err) { console.error('[PeerConnectionManager] Failed to resolve ICE servers from provider:', err); // A refresh only runs once past the refresh deadline, so the cached creds are // at/near expiry. Keep them ONLY if still before their hard expiry (a brief // transient blip with creds still valid); once expired they'd just be rejected // by the relay, so drop to the static stun/turn config instead. if (this.resolvedIceServers && Date.now() >= this.resolvedIceServersExpiresAt) { this.resolvedIceServers = null; this.resolvedIceServersRefreshAt = 0; this.resolvedIceServersExpiresAt = 0; } } } /** * Get the current RTCPeerConnection (may be null if not connected) */ getPeerConnection(): RTCPeerConnection | null { return this.pc; } /** * Check if the connection is currently active */ isConnected(): boolean { return this.pc?.connectionState === 'connected'; } /** * Close the connection and clean up resources */ close(): void { this.isShuttingDown = true; this.pendingRestartOffer = null; this.cleanup(); } /** * Trigger a manual reconnection */ reconnect(): void { if (this.isReconnecting || this.isShuttingDown) { return; } this.triggerReconnect(0); } // =========================================================================== // Private Methods // =========================================================================== private async attemptConnection(retryCount: number, useStun: boolean, delay: number): Promise { if (this.isShuttingDown) { throw new Error('Connection attempt cancelled - shutting down'); } // Resolve (and refresh near expiry) provider ICE servers before the // synchronous pc creation below. Runs on every attempt — including rebuilds // long after the initial connect — so reconnects never reuse expired creds. await this.resolveIceServers(); // close() may have run during the await above; don't build a live pc (and // re-subscribe/arm a timeout) on a manager the caller has already torn down. if (this.isShuttingDown) { throw new Error('Connection attempt cancelled - shutting down'); } return new Promise((resolve, reject) => { const currentConnectionId = ++this.connectionId; let connectionEstablished = false; // Create peer connection FIRST, before setting up message handler console.log( `[PeerConnectionManager] Creating peer connection (useStun=${useStun}, isInitiator=${this.config.isInitiator})`, ); this.pc = this.createPeerConnection(useStun); const pc = this.pc; console.log(`[PeerConnectionManager] Peer connection created: ${pc.connectionState}`); // Setup message handler + subscription for signaling — UNLESS an external // dispatcher owns them (responder fan-out feeds us via ingestSignalingMessage). if (!this.config.selfManagedSignaling) { this.setupMessageHandler(currentConnectionId); // Subscribe to the target twin this.transport.subscribe(this.config.targetTwinId).catch((err) => { console.error('[PeerConnectionManager] Failed to subscribe to twin:', err); }); } // Setup ICE handling this.setupIceHandling(pc, currentConnectionId); // Setup connection state monitoring pc.onconnectionstatechange = () => { this.config.onSignalingStateChange?.(pc.signalingState); if (pc.connectionState === 'connected') { connectionEstablished = true; this.connectionEstablishedOnce = true; this.clearConnectionTimeout(); this.config.onConnected(); resolve(pc); } else if ( pc.connectionState === 'failed' || pc.connectionState === 'disconnected' || pc.connectionState === 'closed' ) { if (connectionEstablished && !this.isShuttingDown) { this.config.onDisconnected(); this.triggerReconnect(0); } else if (!connectionEstablished) { this.handleConnectionFailure(retryCount, useStun, delay, resolve, reject); } } }; pc.oniceconnectionstatechange = () => { this.config.onIceStateChange?.(pc.iceConnectionState); if (pc.iceConnectionState === 'failed' && connectionEstablished) { this.triggerReconnect(0); } }; // Call the setup callback (for adding data channels/tracks) before creating offer if (this.config.onPeerConnectionCreated) { try { const result = this.config.onPeerConnectionCreated(pc); if (result instanceof Promise) { result.catch((err) => console.error('[PeerConnectionManager] Error in onPeerConnectionCreated:', err)); } } catch (err) { console.error('[PeerConnectionManager] Error in onPeerConnectionCreated:', err); } } // Drain any signaling messages that arrived (via ingestSignalingMessage) // before the pc existed. Handlers/tracks are now wired by // onPeerConnectionCreated above, so a responder can answer a buffered offer. if (this.pendingIngress.length > 0) { const buffered = this.pendingIngress; this.pendingIngress = []; for (const bufferedMessage of buffered) { this.handleSignalingMessage(bufferedMessage).catch((err) => console.error('[PeerConnectionManager] Error handling buffered message:', err), ); } } // Start the offer/answer exchange if we're the initiator if (this.config.isInitiator) { this.createAndSendOffer(pc).catch((err) => { console.error('[PeerConnectionManager] Error creating offer:', err); this.handleConnectionFailure(retryCount, useStun, delay, resolve, reject); }); } else if (this.pendingRestartOffer) { // Responder session restart: answer the carried offer on the fresh pc. // Routing uses config.targetTwinId (the reply-to fixed when this session // was created), which is stable across the rebuild. const { offer } = this.pendingRestartOffer; this.pendingRestartOffer = null; this.answerOffer(offer).catch((err) => { console.error('[PeerConnectionManager] Error answering restart offer:', err); }); } // Set connection timeout this.connectionTimeout = setTimeout(() => { if (!connectionEstablished) { console.log(`[PeerConnectionManager] Connection timeout (${useStun ? 'with' : 'without'} STUN)`); this.handleConnectionFailure(retryCount, useStun, delay, resolve, reject); } }, this.options.connectionTimeout); }); } /** Static STUN+TURN list from config — the fallback when no provider is set/resolved. */ private buildStaticIceServers(): RTCIceServer[] { const iceServers: RTCIceServer[] = this.config.stunServers.map((url) => ({ urls: url })); for (const turn of this.config.turnServers) { iceServers.push({ urls: turn.urls, username: turn.username, credential: turn.credential }); } return iceServers; } private createPeerConnection(useStun: boolean): RTCPeerConnection { // Provider-supplied list wins; otherwise the static stun/turn config. The // STUN toggle is applied uniformly to either source: the no-STUN retry keeps // only TURN so a stuck srflx path can still fall back to relay. const source = this.resolvedIceServers ?? this.buildStaticIceServers(); const iceServers = useStun ? source : keepTurnServers(source); return new RTCPeerConnection({ iceServers, iceTransportPolicy: this.config.iceTransportPolicy, }); } private setupIceHandling(pc: RTCPeerConnection, connectionId: number): void { pc.onicecandidate = async (event) => { if (this.connectionId !== connectionId) return; console.log('[PeerConnectionManager] ICE candidate:', event.candidate?.candidate || 'gathering complete'); this.config.onIceCandidate?.(event.candidate); // Non-trickle path: when a signaling sink owns this peer's outbound signaling // (one-shot HTTP answer), the viewer has no channel to receive trickled // candidates — they ride embedded in the gathering-complete answer SDP — so // swallow the trickle send here. if (event.candidate && !this.config.signalingSink) { try { console.log('[PeerConnectionManager] Sending ICE candidate'); await this.sendSignalingMessage('ice', event.candidate); } catch (err) { console.error('[PeerConnectionManager] Error sending ICE candidate:', err); } } }; pc.onicegatheringstatechange = () => { console.log('[PeerConnectionManager] ICE gathering state:', pc.iceGatheringState); }; pc.oniceconnectionstatechange = () => { console.log('[PeerConnectionManager] ICE connection state:', pc.iceConnectionState); }; } private setupMessageHandler(connectionId: number): void { const twinId = this.transport.twinId; // Remove old handler if exists if (this.messageHandler) { this.transport.offMessage(twinId, this.messageHandler); } this.messageHandler = async (message: any) => { if (this.isShuttingDown || this.connectionId !== connectionId) return; // Filter: only process messages FROM the expected source twin. if (message.sourceTwinId !== this.config.targetTwinId) return; // Filter by peer id: ignore signals belonging to a different session sharing // the same channel (e.g. another peer's answer/ice). Require a matching id. if (this.peerId && message.data?.peerId !== this.peerId) return; try { await this.handleSignalingMessage(message); } catch (err) { console.error('[PeerConnectionManager] Error handling signaling message:', err); } }; // Register on listener twinId, filter by sourceTwinId in callback this.transport.onMessage(twinId, this.messageHandler); } private async handleSignalingMessage(message: any): Promise { if (!this.pc) return; const messageType = message.data?.type as string; if (!messageType) return; // Extract the signal type (offer/answer/ice) from the message type // Message type format: channel-{twinId}:{signalType} // We match on the signal type suffix since the channelId varies by perspective const prefix = `${this.config.channelPrefix}-`; if (!messageType.startsWith(prefix)) return; const signalType = messageType.split(':').pop(); console.log(`[PeerConnectionManager] Received signaling message: ${signalType}`); switch (signalType) { case 'offer': await this.handleOffer(message.data.data); break; case 'answer': await this.handleAnswer(message.data.data); break; case 'ice': await this.handleIceCandidate(message.data.data); break; } } /** * Responder offer gate (called from the signaling message handler). Decides * whether an inbound offer is a duplicate (ignore), a brand-new session * (rebuild), or the current session's offer to answer. The actual offer/answer * exchange is performed by answerOffer(). */ private async handleOffer(offer: RTCSessionDescriptionInit): Promise { if (this.config.isInitiator) return; // A rebuild is already in flight (session restart, liveness backstop, or // triggerReconnect). Don't disturb the transitional pc — the rebuild answers // the offer it carried, and the peer re-sends offers until connected, so a // newer session is picked up automatically once we're stable again. if (this.isReconnecting) return; if (!this.pc) return; const pc = this.pc; // Identify the session this offer belongs to by its ICE ufrag. A peer that // reloaded/reconnected presents a NEW ufrag; a duplicate or in-session // re-offer carries the SAME ufrag as the description we already hold. const incomingUfrag = extractIceUfrag(offer.sdp); const currentUfrag = extractIceUfrag(pc.remoteDescription?.sdp); // Duplicate offer for the session we are already on — ignore it. This stops // the offer/answer glare (setRemoteDescription/createAnswer "wrong state"). if (incomingUfrag && currentUfrag && incomingUfrag === currentUfrag) { return; } // Session-restart detection (responder only): only when this is a GENUINELY // new session — a different ufrag after we were already connected, or any // offer arriving while the current transport is dead. The remote is // presenting brand-new DTLS/ICE params; rebuild the pc and answer the new // offer on the fresh one rather than renegotiating onto a stale transport. const transportDead = pc.connectionState === 'failed' || pc.connectionState === 'disconnected' || pc.connectionState === 'closed'; const isNewSession = !!incomingUfrag && incomingUfrag !== currentUfrag; if ((this.connectionEstablishedOnce && isNewSession) || transportDead) { console.log('[PeerConnectionManager] New-session offer on established/dead pc — recycling session'); this.restartForIncomingOffer(offer); return; } await this.answerOffer(offer); } /** * Perform the offer/answer exchange on the current pc. Used by the normal * responder path (via handleOffer) and by the session-restart drain in * attemptConnection. Gating lives in handleOffer; this only executes. */ private async answerOffer(offer: RTCSessionDescriptionInit): Promise { const pc = this.pc; if (!pc) return; if (pc.signalingState === 'stable' || pc.signalingState === 'have-remote-offer') { if (pc.signalingState === 'have-remote-offer') { await pc.setLocalDescription({ type: 'rollback' }); } console.log('[PeerConnectionManager] Setting remote description (offer)'); await pc.setRemoteDescription(offer); console.log(`[PeerConnectionManager] Remote description set. signalingState=${pc.signalingState}`); await this.applyPendingCandidates(); console.log('[PeerConnectionManager] Creating answer...'); const answer = await pc.createAnswer(); console.log(`[PeerConnectionManager] Answer created. type=${answer.type}`); console.log('[PeerConnectionManager] Setting local description (answer)'); await pc.setLocalDescription(answer); console.log( `[PeerConnectionManager] Local description set. signalingState=${pc.signalingState}, iceGatheringState=${pc.iceGatheringState}`, ); if (this.config.signalingSink) { // One-shot (non-trickle) answer: wait for ICE gathering so the emitted // answer SDP carries its candidates, then hand the gathering-complete // local description to the sink. localDescription (not the pre-gather // `answer`) is what has the candidates folded in. await this.waitForIceGatheringComplete(ICE_GATHERING_COMPLETE_TIMEOUT_MS); const gatheredAnswer = this.pc?.localDescription ?? answer; await this.sendSignalingMessage('answer', gatheredAnswer); console.log('[PeerConnectionManager] Gathering-complete answer handed to signaling sink'); } else { await this.sendSignalingMessage('answer', answer); console.log('[PeerConnectionManager] Answer sent'); } } } /** * Resolve once ICE gathering reaches 'complete', or after `timeoutMs` (whichever * comes first). Used by the non-trickle one-shot answer path so the emitted * answer SDP embeds the gathered candidates. * * Uses addEventListener (never assigns pc.onicegatheringstatechange) so it does * not clobber the logging handler installed by setupIceHandling. */ async waitForIceGatheringComplete(timeoutMs: number): Promise { const pc = this.pc; if (!pc || pc.iceGatheringState === 'complete') return; await new Promise((resolve) => { let settled = false; const finish = (): void => { if (settled) return; settled = true; clearTimeout(timer); pc.removeEventListener('icegatheringstatechange', onGatheringChange); resolve(); }; const onGatheringChange = (): void => { if (pc.iceGatheringState === 'complete') finish(); }; const timer = setTimeout(finish, timeoutMs); pc.addEventListener('icegatheringstatechange', onGatheringChange); }); } private async handleAnswer(answer: RTCSessionDescriptionInit): Promise { if (!this.pc || !this.config.isInitiator) return; const pc = this.pc; if (pc.signalingState === 'have-local-offer') { await pc.setRemoteDescription(answer); await this.applyPendingCandidates(); } } private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise { if (!this.pc) return; if (this.pc.remoteDescription) { try { await this.pc.addIceCandidate(candidate); } catch (err) { console.error('[PeerConnectionManager] Error adding ICE candidate:', err); } } else { this.pendingCandidates.push(candidate); } } private async applyPendingCandidates(): Promise { if (!this.pc || this.pendingCandidates.length === 0) return; for (const candidate of this.pendingCandidates) { try { await this.pc.addIceCandidate(candidate); } catch (err) { console.error('[PeerConnectionManager] Error applying pending ICE candidate:', err); } } this.pendingCandidates = []; } private async createAndSendOffer(pc: RTCPeerConnection): Promise { console.log('[PeerConnectionManager] Creating offer...'); const offer = await pc.createOffer(); console.log(`[PeerConnectionManager] Offer created. type=${offer.type}`); console.log('[PeerConnectionManager] Setting local description (offer)'); await pc.setLocalDescription(offer); console.log( `[PeerConnectionManager] Local description set. signalingState=${pc.signalingState}, iceGatheringState=${pc.iceGatheringState}`, ); await this.sendSignalingMessage('offer', offer); console.log('[PeerConnectionManager] Offer sent'); } private async sendSignalingMessage( type: SignalingMessageType, data: RTCSessionDescriptionInit | RTCIceCandidateInit | RTCIceCandidate, ): Promise { // When a sink owns this peer's outbound signaling (one-shot HTTP answer), hand // it the message instead of publishing to the twin transport. The sink has no // return channel, so nothing goes on the wire from here. const signalingSink: SignalingSink | undefined = this.config.signalingSink; if (signalingSink) { signalingSink(type, data); return; } const targetTwinId = this.config.targetTwinId; const channelId = `${this.config.channelPrefix}-${targetTwinId}`; const envelope: { type: string; data: typeof data; peerId?: string } = { type: `${channelId}:${type}`, data, }; // Stamp the session id so the remote can demux us from other peers on the // same channel. Omitted when null (single-peer behavior). if (this.peerId) { envelope.peerId = this.peerId; } await this.transport.sendMessage(targetTwinId, envelope); } private handleConnectionFailure( retryCount: number, currentUseStun: boolean, delay: number, resolve: (pc: RTCPeerConnection) => void, reject: (error: Error) => void, ): void { this.cleanup(); if (this.isShuttingDown) { reject(new Error('Connection cancelled - shutting down')); return; } // First retry: toggle STUN setting if (retryCount === 0) { this.attemptConnection(1, !currentUseStun, this.options.initialRetryDelay).then(resolve).catch(reject); return; } // Subsequent retries: exponential backoff const nextDelay = Math.min(this.options.maxRetryDelay, delay * 2); setTimeout(() => { this.attemptConnection(retryCount + 1, currentUseStun, nextDelay) .then(resolve) .catch(reject); }, delay); } /** * Tear down the current pc and rebuild it. Shared by triggerReconnect * (delayed, STUN-toggling) and restartForIncomingOffer (immediate, carries a * peer's offer). On failure, retries via triggerReconnect with backoff. */ private beginRebuild(attempt: number, useStun: boolean, delayMs: number): void { if (this.isReconnecting || this.isShuttingDown) return; this.isReconnecting = true; this.config.onReconnecting?.(attempt); this.cleanup(); const run = () => { this.attemptConnection(0, useStun, this.options.initialRetryDelay) .then(() => { this.isReconnecting = false; this.config.onReconnected?.(attempt); }) .catch((err) => { console.error(`[PeerConnectionManager] Rebuild attempt ${attempt} failed:`, err); this.triggerReconnect(attempt + 1); }); }; if (delayMs > 0) { setTimeout(run, delayMs); } else { run(); } } private triggerReconnect(attempt: number): void { // Fan-out responder peers (selfManagedSignaling) are teardown-only: they own // no transport subscription to receive a re-offer after cleanup, and a // responder never creates offers, so an in-place rebuild would just spin. // Recovery is the dispatcher's job (drop the peer) + the initiator's // (re-offer → a fresh per-peer session). The disconnect already tore us down. if (this.config.selfManagedSignaling) return; const delay = Math.min(this.options.maxRetryDelay, this.options.initialRetryDelay * Math.pow(2, attempt)); // Toggle STUN across reconnect attempts to break a stuck path. this.beginRebuild(attempt, !this.config.useStun, delay); } /** * Responder-only: rebuild the pc to answer a fresh offer from a peer that * reloaded. Carries the incoming offer THROUGH the rebuild (drained in * attemptConnection) so it is never lost in the offer-race window, and answers * immediately rather than waiting for the peer to re-send. */ private restartForIncomingOffer(offer: RTCSessionDescriptionInit): void { if (this.isReconnecting || this.isShuttingDown) return; // Carry the offer through the rebuild so it isn't lost in the offer-race // window; it's answered on the fresh pc (routing via config.targetTwinId, // the reply-to fixed when this session was created). this.pendingRestartOffer = { offer }; this.beginRebuild(0, this.config.useStun, 0); } private clearConnectionTimeout(): void { if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } } private cleanup(): void { this.clearConnectionTimeout(); if (this.messageHandler) { this.transport.offMessage(this.transport.twinId, this.messageHandler); this.messageHandler = null; } this.connectionEstablishedOnce = false; if (this.pc) { this.pc.onicecandidate = null; this.pc.onconnectionstatechange = null; this.pc.oniceconnectionstatechange = null; try { this.pc.close(); } catch (err) { console.error('[PeerConnectionManager] Error closing peer connection:', err); } this.pc = null; } this.pendingCandidates = []; // Drop any signaling buffered for the old pc so a later attemptConnection // can't replay stale offers/candidates onto a freshly-created connection. this.pendingIngress = []; } }