import { CONNECTION_STATE_CONNECTED, DATA_CHANNEL_STATE_OPEN, P2P_TYPE_VOICE_STATE_REQUEST, PEER_DISCONNECT_GRACE_MS, PEER_RECONNECT_INTERVAL_MS, PEER_RECONNECT_MAX_ATTEMPTS } from '../../realtime.constants'; import { PeerConnectionManagerContext, PeerConnectionManagerState, RecoveryHandlers, RemovePeerOptions } from '../shared'; import { clearAllPingTimers, stopPingInterval } from '../messaging/ping'; /** * Close and remove a peer connection, data channel, and emit a disconnect event. */ export function removePeer( context: PeerConnectionManagerContext, peerId: string, options?: RemovePeerOptions ): void { const { state } = context; const peerData = state.activePeerConnections.get(peerId); const preserveReconnectState = options?.preserveReconnectState === true; clearPeerDisconnectGraceTimer(state, peerId); if (!preserveReconnectState) { clearPeerReconnectTimer(state, peerId); state.disconnectedPeerTracker.delete(peerId); } state.remotePeerStreams.delete(peerId); state.remotePeerVoiceStreams.delete(peerId); state.remotePeerScreenShareStreams.delete(peerId); if (peerData) { if (peerData.dataChannel) peerData.dataChannel.close(); peerData.connection.close(); state.activePeerConnections.delete(peerId); state.peerNegotiationQueue.delete(peerId); removeFromConnectedPeers(state, peerId); stopPingInterval(state, peerId); state.peerLatencies.delete(peerId); state.pendingPings.delete(peerId); state.peerDisconnected$.next(peerId); } } /** Close every active peer connection and clear internal state. */ export function closeAllPeers(state: PeerConnectionManagerState): void { clearAllPeerReconnectTimers(state); clearAllPeerDisconnectGraceTimers(state); clearAllPingTimers(state); state.activePeerConnections.forEach((peerData) => { if (peerData.dataChannel) peerData.dataChannel.close(); peerData.connection.close(); }); state.activePeerConnections.clear(); state.remotePeerStreams.clear(); state.remotePeerVoiceStreams.clear(); state.remotePeerScreenShareStreams.clear(); state.peerNegotiationQueue.clear(); state.peerLatencies.clear(); state.pendingPings.clear(); state.connectedPeersChanged$.next([]); } export function trackDisconnectedPeer(state: PeerConnectionManagerState, peerId: string): void { state.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 }); } export function clearPeerReconnectTimer( state: PeerConnectionManagerState, peerId: string ): void { const timer = state.peerReconnectTimers.get(peerId); if (timer) { clearInterval(timer); state.peerReconnectTimers.delete(peerId); } } export function clearPeerDisconnectGraceTimer( state: PeerConnectionManagerState, peerId: string ): void { const timer = state.peerDisconnectGraceTimers.get(peerId); if (timer) { clearTimeout(timer); state.peerDisconnectGraceTimers.delete(peerId); } } /** Cancel all pending peer reconnect timers and clear the tracker. */ export function clearAllPeerReconnectTimers(state: PeerConnectionManagerState): void { state.peerReconnectTimers.forEach((timer) => clearInterval(timer)); state.peerReconnectTimers.clear(); state.disconnectedPeerTracker.clear(); } export function clearAllPeerDisconnectGraceTimers(state: PeerConnectionManagerState): void { state.peerDisconnectGraceTimers.forEach((timer) => clearTimeout(timer)); state.peerDisconnectGraceTimers.clear(); } export function schedulePeerDisconnectRecovery( context: PeerConnectionManagerContext, peerId: string, handlers: RecoveryHandlers ): void { const { logger, state } = context; if (state.peerDisconnectGraceTimers.has(peerId)) return; logger.warn('Peer temporarily disconnected; waiting before reconnect', { peerId }); const timer = setTimeout(() => { state.peerDisconnectGraceTimers.delete(peerId); const peerData = state.activePeerConnections.get(peerId); if (!peerData) return; const connectionState = peerData.connection.connectionState; if (connectionState === CONNECTION_STATE_CONNECTED || connectionState === 'connecting') { logger.info('Peer recovered before disconnect grace expired', { peerId, state: connectionState }); return; } logger.warn('Peer still disconnected after grace period; recreating connection', { peerId, state: connectionState }); trackDisconnectedPeer(state, peerId); handlers.removePeer(peerId, { preserveReconnectState: true }); schedulePeerReconnect(context, peerId, handlers); }, PEER_DISCONNECT_GRACE_MS); state.peerDisconnectGraceTimers.set(peerId, timer); } export function schedulePeerReconnect( context: PeerConnectionManagerContext, peerId: string, handlers: RecoveryHandlers ): void { const { callbacks, logger, state } = context; if (state.peerReconnectTimers.has(peerId)) return; logger.info('Scheduling P2P reconnect', { peerId }); const timer = setInterval(() => { const info = state.disconnectedPeerTracker.get(peerId); if (!info) { clearPeerReconnectTimer(state, peerId); return; } info.reconnectAttempts++; logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts }); if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) { logger.info('P2P reconnect max attempts reached', { peerId }); clearPeerReconnectTimer(state, peerId); state.disconnectedPeerTracker.delete(peerId); return; } if (!callbacks.isSignalingConnected()) { logger.info('Skipping P2P reconnect - no signaling connection', { peerId }); return; } attemptPeerReconnect(context, peerId, handlers); }, PEER_RECONNECT_INTERVAL_MS); state.peerReconnectTimers.set(peerId, timer); } export function attemptPeerReconnect( context: PeerConnectionManagerContext, peerId: string, handlers: RecoveryHandlers ): void { const { callbacks, logger, state } = context; if (state.activePeerConnections.has(peerId)) { handlers.removePeer(peerId, { preserveReconnectState: true }); } const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null; if (!localOderId) { logger.info('Skipping reconnect offer until logical identity is ready', { peerId }); handlers.createPeerConnection(peerId, false); return; } const shouldInitiate = peerId !== localOderId && localOderId < peerId; handlers.createPeerConnection(peerId, shouldInitiate); if (shouldInitiate) { void handlers.createAndSendOffer(peerId); return; } logger.info('Waiting for remote reconnect offer based on deterministic initiator selection', { localOderId, peerId }); } export function requestVoiceStateFromPeer( state: PeerConnectionManagerState, logger: PeerConnectionManagerContext['logger'], peerId: string ): void { const peerData = state.activePeerConnections.get(peerId); if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { try { peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST })); } catch (error) { logger.warn('Failed to request voice state', error); } } } /** Return a snapshot copy of the currently-connected peer IDs. */ export function getConnectedPeerIds(state: PeerConnectionManagerState): string[] { return [...state.connectedPeersList]; } export function addToConnectedPeers(state: PeerConnectionManagerState, peerId: string): void { if (!state.connectedPeersList.includes(peerId)) { state.connectedPeersList = [...state.connectedPeersList, peerId]; state.connectedPeersChanged$.next(state.connectedPeersList); } } /** * Remove a peer from the connected list and notify subscribers. */ export function removeFromConnectedPeers( state: PeerConnectionManagerState, peerId: string ): void { state.connectedPeersList = state.connectedPeersList.filter( (connectedId) => connectedId !== peerId ); state.connectedPeersChanged$.next(state.connectedPeersList); } /** Reset the connected peers list to empty and notify subscribers. */ export function resetConnectedPeers(state: PeerConnectionManagerState): void { state.connectedPeersList = []; state.connectedPeersChanged$.next([]); }