UNPKG

peerpigeon

Version:

WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server

1,176 lines (1,005 loc) 58.9 kB
import { EventEmitter } from './EventEmitter.js'; import { environmentDetector } from './EnvironmentDetector.js'; import DebugLogger from './DebugLogger.js'; export class PeerConnection extends EventEmitter { constructor(peerId, isInitiator = false, options = {}) { super(); this.peerId = peerId; this.debug = DebugLogger.create('PeerConnection'); this.isInitiator = isInitiator; this.connection = null; this.dataChannel = null; this.remoteDescriptionSet = false; this.dataChannelReady = false; this.connectionStartTime = Date.now(); this.pendingIceCandidates = []; this.isClosing = false; // Flag to prevent disconnection events during intentional close this.iceTimeoutId = null; // Timeout ID for ICE negotiation this._forcedStatus = null; // Track forced status (e.g., failed) // Media stream support this.localStream = options.localStream || null; this.remoteStream = null; this.enableVideo = options.enableVideo || false; this.enableAudio = options.enableAudio || false; this.audioTransceiver = null; this.videoTransceiver = null; // SECURITY: Never automatically invoke remote streams - only when user explicitly requests this.allowRemoteStreams = options.allowRemoteStreams === true; // Default to false - streams must be explicitly invoked this.pendingRemoteStreams = []; // Buffer remote streams until user invokes them } /** * Force this connection into a terminal state (e.g., failed/timeout) */ markAsFailed(reason = 'failed') { this._forcedStatus = reason; try { this.close(); } catch (e) {} } async createConnection() { // Validate WebRTC support before creating connection if (!environmentDetector.hasWebRTC) { const error = new Error('WebRTC not supported in this environment'); this.emit('connectionFailed', { peerId: this.peerId, reason: error.message }); throw error; } // Get PigeonRTC instance for cross-platform WebRTC support const pigeonRTC = environmentDetector.getPigeonRTC(); if (!pigeonRTC) { const error = new Error('PigeonRTC not initialized - call initWebRTCAsync() first'); this.emit('connectionFailed', { peerId: this.peerId, reason: error.message }); throw error; } // Create peer connection using PigeonRTC this.connection = pigeonRTC.createPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' }, { urls: 'stun:stun3.l.google.com:19302' }, { urls: 'stun:stun4.l.google.com:19302' } ], iceCandidatePoolSize: 10, // Pre-gather ICE candidates for faster connection bundlePolicy: 'max-bundle', // Bundle all media on single transport for efficiency rtcpMuxPolicy: 'require' // Multiplex RTP and RTCP for faster setup }); this.setupConnectionHandlers(); // CRITICAL FIX: Use addTrack() instead of pre-allocated transceivers to trigger ontrack events // This ensures that ontrack events are properly fired on the receiving side this.debug.log('🔄 Using addTrack() approach for proper ontrack event firing'); // Add local media stream if provided using addTrack() if (this.localStream) { this.debug.log('Adding local stream tracks using addTrack() method'); await this.addLocalStreamWithAddTrack(this.localStream); } if (this.isInitiator) { this.debug.log(`🚀 INITIATOR: Creating data channel for ${this.peerId.substring(0, 8)}... (WE are initiator)`); this.dataChannel = this.connection.createDataChannel('messages', { ordered: true }); this.setupDataChannel(); } else { this.debug.log(`👥 RECEIVER: Waiting for data channel from ${this.peerId.substring(0, 8)}... (THEY are initiator)`); this.connection.ondatachannel = (event) => { this.debug.log(`📨 RECEIVED: Data channel received from ${this.peerId.substring(0, 8)}...`); this.dataChannel = event.channel; this.setupDataChannel(); }; } } setupConnectionHandlers() { this.connection.onicecandidate = (event) => { if (event.candidate) { this.debug.log(`🧊 Generated ICE candidate for ${this.peerId.substring(0, 8)}...`, { type: event.candidate.type, protocol: event.candidate.protocol, address: event.candidate.address?.substring(0, 10) + '...' || 'unknown' }); this.emit('iceCandidate', { peerId: this.peerId, candidate: event.candidate }); } else { this.debug.log(`🧊 ICE gathering complete for ${this.peerId.substring(0, 8)}...`); } }; // Handle remote media streams this.connection.ontrack = (event) => { this.debug.log('🎵 Received remote media stream from', this.peerId); const stream = event.streams[0]; const track = event.track; this.debug.log(`🎵 Track received: kind=${track.kind}, id=${track.id}, enabled=${track.enabled}, readyState=${track.readyState}`); // CRITICAL: Enhanced loopback detection and stream validation this.debug.log('🔍 ONTRACK DEBUG: Starting stream validation...'); if (!this.validateRemoteStream(stream, track)) { this.debug.error('❌ ONTRACK DEBUG: Stream validation FAILED - rejecting remote stream'); return; // Don't process invalid or looped back streams } this.debug.log('✅ ONTRACK DEBUG: Stream validation PASSED - processing remote stream'); if (stream) { this.remoteStream = stream; const audioTracks = stream.getAudioTracks(); const videoTracks = stream.getVideoTracks(); this.debug.log(`🎵 Remote stream tracks: ${audioTracks.length} audio, ${videoTracks.length} video`); this.debug.log(`🎵 Remote stream ID: ${stream.id} (vs local: ${this.localStream?.id || 'none'})`); // Mark stream as genuinely remote to prevent future confusion this.markStreamAsRemote(stream); audioTracks.forEach((audioTrack, index) => { this.debug.log(`🎵 Audio track ${index}: enabled=${audioTrack.enabled}, readyState=${audioTrack.readyState}, muted=${audioTrack.muted}, id=${audioTrack.id}`); // Add audio data monitoring this.setupAudioDataMonitoring(audioTrack, index); }); this.debug.log('🚨 ONTRACK DEBUG: About to emit remoteStream event'); // Check if remote streams are allowed (crypto gating) if (this.allowRemoteStreams) { this.emit('remoteStream', { peerId: this.peerId, stream: this.remoteStream }); this.debug.log('✅ ONTRACK DEBUG: remoteStream event emitted successfully'); } else { // Buffer the stream until crypto allows it this.debug.log('🔒 ONTRACK DEBUG: Buffering remote stream until crypto verification'); this.pendingRemoteStreams.push({ peerId: this.peerId, stream: this.remoteStream }); } } else { this.debug.error('❌ ONTRACK DEBUG: No stream in ontrack event - this should not happen'); } }; this.connection.onconnectionstatechange = () => { this.debug.log(`🔗 Connection state with ${this.peerId}: ${this.connection.connectionState} (previous signaling: ${this.connection.signalingState})`); // Log additional context about transceivers and media with Node.js compatibility try { const transceivers = this.connection.getTransceivers(); const audioSending = this.audioTransceiver && this.audioTransceiver.sender && this.audioTransceiver.sender.track; const videoSending = this.videoTransceiver && this.videoTransceiver.sender && this.videoTransceiver.sender.track; this.debug.log(`🔗 Media context: Audio sending=${!!audioSending}, Video sending=${!!videoSending}, Transceivers=${transceivers.length}`); } catch (error) { // Handle Node.js WebRTC compatibility issues this.debug.log(`🔗 Media context: Unable to access transceiver details (${error.message})`); } if (this.connection.connectionState === 'connected') { this.debug.log(`✅ Connection established with ${this.peerId}`); this.emit('connected', { peerId: this.peerId }); } else if (this.connection.connectionState === 'connecting') { this.debug.log(`🔄 Connection to ${this.peerId} is connecting...`); } else if (this.connection.connectionState === 'disconnected') { // Give WebRTC more time to recover - it's common for connections to briefly disconnect during renegotiation this.debug.log(`⚠️ WebRTC connection disconnected for ${this.peerId}, waiting for potential recovery...`); // Longer recovery time for disconnected state when there are multiple peers (3+) // This helps prevent cascade failures when multiple renegotiations happen const recoveryTime = 12000; // 12 seconds for disconnected state recovery setTimeout(() => { if (this.connection && this.connection.connectionState === 'disconnected' && !this.isClosing) { this.debug.log(`❌ WebRTC connection remained disconnected for ${this.peerId} after ${recoveryTime}ms, treating as failed`); this.emit('disconnected', { peerId: this.peerId, reason: 'connection disconnected' }); } }, recoveryTime); } else if (this.connection.connectionState === 'failed') { if (!this.isClosing) { this.debug.log(`❌ Connection failed for ${this.peerId}`); this.emit('disconnected', { peerId: this.peerId, reason: 'connection failed' }); } } else if (this.connection.connectionState === 'closed') { if (!this.isClosing) { this.debug.log(`❌ Connection closed for ${this.peerId}`); this.emit('disconnected', { peerId: this.peerId, reason: 'connection closed' }); } } }; this.connection.oniceconnectionstatechange = () => { this.debug.log(`🧊 ICE connection state with ${this.peerId}: ${this.connection.iceConnectionState}`); if (this.connection.iceConnectionState === 'connected') { this.debug.log(`✅ ICE connection established with ${this.peerId}`); // Clear any existing ICE timeout if (this.iceTimeoutId) { clearTimeout(this.iceTimeoutId); this.iceTimeoutId = null; } } else if (this.connection.iceConnectionState === 'checking') { this.debug.log(`🔄 ICE checking for ${this.peerId}...`); // Set a timeout for ICE negotiation to prevent hanging if (this.iceTimeoutId) { clearTimeout(this.iceTimeoutId); } this.iceTimeoutId = setTimeout(() => { if (this.connection && this.connection.iceConnectionState === 'checking' && !this.isClosing) { this.debug.error(`❌ ICE negotiation timeout for ${this.peerId} - connection stuck in checking state`); this.emit('disconnected', { peerId: this.peerId, reason: 'ICE negotiation timeout' }); } }, 30000); // 30 second timeout for ICE negotiation } else if (this.connection.iceConnectionState === 'failed') { // Check if signaling is available before attempting ICE restart const hasSignaling = this.mesh && this.mesh.signalingClient && this.mesh.signalingClient.isConnected(); const hasMeshConnectivity = this.mesh && this.mesh.connected && this.mesh.connectionManager.getConnectedPeerCount() > 0; if (hasSignaling || hasMeshConnectivity) { this.debug.log(`❌ ICE connection failed for ${this.peerId}, attempting restart (signaling: ${hasSignaling}, mesh: ${hasMeshConnectivity})`); try { // For ICE restart, we need to coordinate new ICE candidates through signaling this.restartIceViaSignaling().catch(error => { this.debug.error('Failed to restart ICE after failure:', error); this.emit('disconnected', { peerId: this.peerId, reason: 'ICE failed' }); }); } catch (error) { this.debug.error('Failed to restart ICE after failure:', error); this.emit('disconnected', { peerId: this.peerId, reason: 'ICE failed' }); } } else { this.debug.log(`❌ ICE connection failed for ${this.peerId}, disconnecting`); this.emit('disconnected', { peerId: this.peerId, reason: 'ICE failed' }); } } else if (this.connection.iceConnectionState === 'disconnected') { // Give more time for ICE reconnection - this is common during renegotiation this.debug.log(`⚠️ ICE connection disconnected for ${this.peerId}, waiting for potential reconnection...`); setTimeout(() => { if (this.connection && this.connection.iceConnectionState === 'disconnected' && !this.isClosing) { // Check if signaling is available before attempting ICE restart const hasSignaling = this.mesh && this.mesh.signalingClient && this.mesh.signalingClient.isConnected(); const hasMeshConnectivity = this.mesh && this.mesh.connected && this.mesh.connectionManager.getConnectedPeerCount() > 0; if (hasSignaling || hasMeshConnectivity) { this.debug.log(`❌ ICE remained disconnected for ${this.peerId}, attempting restart (signaling: ${hasSignaling}, mesh: ${hasMeshConnectivity})`); try { this.restartIceViaSignaling().catch(error => { this.debug.error('Failed to restart ICE after disconnection:', error); this.emit('disconnected', { peerId: this.peerId, reason: 'ICE disconnected' }); }); } catch (error) { this.debug.error('Failed to restart ICE after disconnection:', error); this.emit('disconnected', { peerId: this.peerId, reason: 'ICE disconnected' }); } } else { this.debug.log(`❌ ICE remained disconnected for ${this.peerId}, disconnecting`); this.emit('disconnected', { peerId: this.peerId, reason: 'ICE disconnected' }); } } }, 5000); // Faster ICE reconnection - 5 seconds } }; // Handle renegotiation when tracks are added/removed this.connection.onnegotiationneeded = () => { this.debug.log(`🔄 Negotiation needed for ${this.peerId} (WebRTC detected track changes)`); // CRITICAL: Renegotiation IS needed when tracks are added/replaced, even with pre-allocated transceivers // Pre-allocated transceivers only avoid m-line changes, but SDP still needs to be renegotiated this.debug.log('✅ RENEGOTIATION: Track changes detected - triggering renegotiation as expected'); // Log debug info about current transceivers (with error handling for Node.js WebRTC) try { const transceivers = this.connection.getTransceivers(); this.debug.log('🔄 Transceivers state during renegotiation:', transceivers.map(t => ({ kind: t.receiver?.track?.kind || 'unknown', direction: t.direction, hasTrack: !!t.sender?.track, mid: t.mid }))); } catch (error) { this.debug.log('🔄 Cannot inspect transceivers (Node.js WebRTC limitation):', error.message); } // Emit renegotiation needed event to trigger SDP exchange this.emit('renegotiationNeeded', { peerId: this.peerId }); }; // CRITICAL FIX: Handle track changes manually after renegotiation // Since we use replaceTrack() with pre-allocated transceivers, ontrack events don't fire // We need to monitor transceivers for new tracks after SDP exchanges this.connection.onsignalingstatechange = () => { this.debug.log(`🔄 Signaling state changed for ${this.peerId}: ${this.connection.signalingState}`); // When signaling becomes stable after renegotiation, check for new remote tracks if (this.connection.signalingState === 'stable') { this.debug.log('🔍 Signaling stable - checking for new remote tracks...'); this.checkForNewRemoteTracks(); } }; } setupDataChannel() { this.dataChannel.onopen = () => { this.debug.log(`Data channel opened with ${this.peerId}`); this.dataChannelReady = true; this.emit('dataChannelOpen', { peerId: this.peerId }); }; this.dataChannel.onclose = () => { this.debug.log(`Data channel closed with ${this.peerId}`); this.dataChannelReady = false; // Only emit disconnection if we're not intentionally closing if (!this.isClosing) { this.emit('disconnected', { peerId: this.peerId, reason: 'data channel closed' }); } }; this.dataChannel.onmessage = (event) => { try { const message = JSON.parse(event.data); this.emit('message', { peerId: this.peerId, message }); } catch (error) { this.debug.error('Failed to parse message:', error); this.emit('message', { peerId: this.peerId, message: { content: event.data } }); } }; this.dataChannel.onerror = (error) => { this.debug.error(`Data channel error with ${this.peerId}:`, error); this.dataChannelReady = false; // Only emit disconnection if we're not intentionally closing if (!this.isClosing) { this.emit('disconnected', { peerId: this.peerId, reason: 'data channel error' }); } }; } // CRITICAL: Check and force data channel state after answer processing checkDataChannelState() { if (this.dataChannel) { this.debug.log(`🔍 DATA CHANNEL CHECK: State for ${this.peerId.substring(0, 8)}... is ${this.dataChannel.readyState}`); // If data channel is open but we haven't triggered the open event yet if (this.dataChannel.readyState === 'open' && !this.dataChannelReady) { this.debug.log(`🚀 FORCE OPEN: Triggering data channel open for ${this.peerId.substring(0, 8)}...`); this.dataChannelReady = true; this.emit('dataChannelOpen', { peerId: this.peerId }); } // If data channel is connecting, set up a check in case the event doesn't fire else if (this.dataChannel.readyState === 'connecting') { this.debug.log(`⏳ CONNECTING: Data channel connecting for ${this.peerId.substring(0, 8)}..., setting up backup check`); setTimeout(() => { if (this.dataChannel && this.dataChannel.readyState === 'open' && !this.dataChannelReady) { this.debug.log(`🚀 BACKUP OPEN: Backup trigger for data channel open for ${this.peerId.substring(0, 8)}...`); this.dataChannelReady = true; this.emit('dataChannelOpen', { peerId: this.peerId }); } }, 100); // Short delay to allow normal event to fire first } else { this.debug.log(`❌ DATA CHANNEL CHECK: No data channel found for ${this.peerId.substring(0, 8)}...`); } } } async createOffer() { // Create offer with optimized settings for faster connection and timeout protection try { const offer = await Promise.race([ this.connection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true, iceRestart: false // Don't restart ICE unless necessary }), new Promise((resolve, reject) => setTimeout(() => reject(new Error('createOffer timeout')), 10000) ) ]); await Promise.race([ this.connection.setLocalDescription(offer), new Promise((resolve, reject) => setTimeout(() => reject(new Error('setLocalDescription timeout')), 10000) ) ]); return offer; } catch (error) { this.debug.error(`❌ Failed to create offer for ${this.peerId}:`, error); throw error; } } async handleOffer(offer) { // Validate offer data structure if (!offer || typeof offer !== 'object') { this.debug.error(`Invalid offer from ${this.peerId} - not an object:`, offer); throw new Error('Invalid offer: not an object'); } if (!offer.type || offer.type !== 'offer') { this.debug.error(`Invalid offer from ${this.peerId} - wrong type:`, offer.type); throw new Error(`Invalid offer: expected type 'offer', got '${offer.type}'`); } if (!offer.sdp || typeof offer.sdp !== 'string') { this.debug.error(`Invalid offer from ${this.peerId} - missing or invalid SDP:`, typeof offer.sdp); throw new Error('Invalid offer: missing or invalid SDP'); } // Basic SDP validation if (offer.sdp.length < 10 || !offer.sdp.includes('v=0')) { this.debug.error(`Invalid offer SDP from ${this.peerId} - malformed:`, offer.sdp.substring(0, 100) + '...'); throw new Error('Invalid offer: malformed SDP'); } // ENHANCED DEBUGGING: Log detailed state before processing offer this.debug.log(`🔄 OFFER DEBUG: Processing offer from ${this.peerId.substring(0, 8)}...`); this.debug.log(`🔄 OFFER DEBUG: Current signaling state: ${this.connection.signalingState}`); this.debug.log(`🔄 OFFER DEBUG: Current connection state: ${this.connection.connectionState}`); this.debug.log(`🔄 OFFER DEBUG: Current ICE state: ${this.connection.iceConnectionState}`); this.debug.log(`🔄 OFFER DEBUG: Offer SDP length: ${offer.sdp.length}`); // Check if we're in the right state to handle an offer if (this.connection.signalingState !== 'stable') { this.debug.log(`❌ OFFER DEBUG: Cannot handle offer from ${this.peerId} - connection state is ${this.connection.signalingState} (expected: stable)`); throw new Error(`Cannot handle offer in state: ${this.connection.signalingState}`); } this.debug.log(`🔄 OFFER DEBUG: State validation passed, processing offer from ${this.peerId.substring(0, 8)}... SDP length: ${offer.sdp.length}`); try { await Promise.race([ this.connection.setRemoteDescription(offer), new Promise((resolve, reject) => setTimeout(() => reject(new Error('setRemoteDescription timeout')), 10000) ) ]); this.remoteDescriptionSet = true; this.debug.log(`✅ OFFER DEBUG: Offer processed successfully from ${this.peerId.substring(0, 8)}...`); this.debug.log(`✅ OFFER DEBUG: New signaling state after offer: ${this.connection.signalingState}`); await this.processPendingIceCandidates(); const answer = await Promise.race([ this.connection.createAnswer(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('createAnswer timeout')), 10000) ) ]); await Promise.race([ this.connection.setLocalDescription(answer), new Promise((resolve, reject) => setTimeout(() => reject(new Error('setLocalDescription timeout')), 10000) ) ]); this.debug.log(`✅ OFFER DEBUG: Answer created for offer from ${this.peerId.substring(0, 8)}...`); this.debug.log(`✅ OFFER DEBUG: Final signaling state after answer: ${this.connection.signalingState}`); // CRITICAL: Force check data channel state after offer/answer processing this.checkDataChannelState(); return answer; } catch (error) { this.debug.error(`❌ OFFER DEBUG: Failed to process offer from ${this.peerId}:`, error); this.debug.error('OFFER DEBUG: Offer SDP that failed:', offer.sdp); this.debug.error('OFFER DEBUG: Current connection state:', this.connection.signalingState); this.debug.error('OFFER DEBUG: Current ICE state:', this.connection.iceConnectionState); throw error; } } async handleAnswer(answer) { // Validate answer data structure if (!answer || typeof answer !== 'object') { this.debug.error(`Invalid answer from ${this.peerId} - not an object:`, answer); throw new Error('Invalid answer: not an object'); } if (!answer.type || answer.type !== 'answer') { this.debug.error(`Invalid answer from ${this.peerId} - wrong type:`, answer.type); throw new Error(`Invalid answer: expected type 'answer', got '${answer.type}'`); } if (!answer.sdp || typeof answer.sdp !== 'string') { this.debug.error(`Invalid answer from ${this.peerId} - missing or invalid SDP:`, typeof answer.sdp); throw new Error('Invalid answer: missing or invalid SDP'); } // Basic SDP validation if (answer.sdp.length < 10 || !answer.sdp.includes('v=0')) { this.debug.error(`Invalid answer SDP from ${this.peerId} - malformed:`, answer.sdp.substring(0, 100) + '...'); throw new Error('Invalid answer: malformed SDP'); } // ENHANCED DEBUGGING: Log detailed state before processing answer this.debug.log(`🔄 ANSWER DEBUG: Processing answer from ${this.peerId.substring(0, 8)}...`); this.debug.log(`🔄 ANSWER DEBUG: Current signaling state: ${this.connection.signalingState}`); this.debug.log(`🔄 ANSWER DEBUG: Current connection state: ${this.connection.connectionState}`); this.debug.log(`🔄 ANSWER DEBUG: Current ICE state: ${this.connection.iceConnectionState}`); this.debug.log(`🔄 ANSWER DEBUG: Answer SDP length: ${answer.sdp.length}`); // Check if we're in the right state to handle an answer if (this.connection.signalingState !== 'have-local-offer') { this.debug.log(`❌ ANSWER DEBUG: Cannot handle answer from ${this.peerId} - connection state is ${this.connection.signalingState} (expected: have-local-offer)`); // If we're already stable, the connection might already be established if (this.connection.signalingState === 'stable') { this.debug.log('✅ ANSWER DEBUG: Connection already stable, answer not needed'); return; } throw new Error(`Cannot handle answer in state: ${this.connection.signalingState}`); } this.debug.log(`🔄 ANSWER DEBUG: State validation passed, processing answer from ${this.peerId.substring(0, 8)}... SDP length: ${answer.sdp.length}`); try { await Promise.race([ this.connection.setRemoteDescription(answer), new Promise((resolve, reject) => setTimeout(() => reject(new Error('setRemoteDescription timeout')), 10000) ) ]); this.remoteDescriptionSet = true; this.debug.log(`✅ ANSWER DEBUG: Answer processed successfully from ${this.peerId.substring(0, 8)}...`); this.debug.log(`✅ ANSWER DEBUG: New signaling state: ${this.connection.signalingState}`); this.debug.log(`✅ ANSWER DEBUG: New connection state: ${this.connection.connectionState}`); await this.processPendingIceCandidates(); // CRITICAL: Force check data channel state after answer processing this.checkDataChannelState(); } catch (error) { this.debug.error(`❌ ANSWER DEBUG: Failed to set remote description for answer from ${this.peerId}:`, error); this.debug.error('ANSWER DEBUG: Answer SDP that failed:', answer.sdp); this.debug.error('ANSWER DEBUG: Current connection state:', this.connection.signalingState); this.debug.error('ANSWER DEBUG: Current ICE state:', this.connection.iceConnectionState); throw error; } } async handleIceCandidate(candidate) { // Validate ICE candidate data structure if (!candidate || typeof candidate !== 'object') { this.debug.error(`Invalid ICE candidate from ${this.peerId} - not an object:`, candidate); throw new Error('Invalid ICE candidate: not an object'); } // Basic ICE candidate validation if (!candidate.candidate || typeof candidate.candidate !== 'string') { this.debug.error(`Invalid ICE candidate from ${this.peerId} - missing candidate string:`, candidate); throw new Error('Invalid ICE candidate: missing candidate string'); } this.debug.log(`🧊 Received ICE candidate for ${this.peerId.substring(0, 8)}...`, { type: candidate.type, protocol: candidate.protocol, candidateLength: candidate.candidate?.length || 0 }); if (!this.remoteDescriptionSet) { this.debug.log(`🧊 Buffering ICE candidate for ${this.peerId.substring(0, 8)}... (remote description not set yet)`); this.pendingIceCandidates.push(candidate); return; } try { await Promise.race([ this.connection.addIceCandidate(candidate), new Promise((resolve, reject) => setTimeout(() => reject(new Error('addIceCandidate timeout')), 5000) ) ]); this.debug.log(`🧊 Successfully added ICE candidate for ${this.peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`🧊 Failed to add ICE candidate for ${this.peerId.substring(0, 8)}...:`, error); this.debug.error('ICE candidate that failed:', candidate); this.debug.error('Current connection state:', this.connection.connectionState); this.debug.error('Current ICE state:', this.connection.iceConnectionState); // Don't rethrow - ICE candidate failures are often recoverable } } async processPendingIceCandidates() { if (this.pendingIceCandidates.length > 0) { this.debug.log(`🧊 Processing ${this.pendingIceCandidates.length} buffered ICE candidates for ${this.peerId.substring(0, 8)}...`); for (const candidate of this.pendingIceCandidates) { try { await Promise.race([ this.connection.addIceCandidate(candidate), new Promise((resolve, reject) => setTimeout(() => reject(new Error('addIceCandidate timeout')), 5000) ) ]); this.debug.log(`🧊 Successfully added buffered ICE candidate (${candidate.type}) for ${this.peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`🧊 Failed to add buffered ICE candidate for ${this.peerId.substring(0, 8)}...:`, error); } } this.pendingIceCandidates = []; this.debug.log(`🧊 Finished processing buffered ICE candidates for ${this.peerId.substring(0, 8)}...`); } } sendMessage(message) { if (this.dataChannel && this.dataChannel.readyState === 'open') { try { this.dataChannel.send(JSON.stringify(message)); return true; } catch (error) { this.debug.error(`Failed to send message to ${this.peerId}:`, error); return false; } } return false; } /** * CRITICAL FIX: Manually check for new remote tracks after renegotiation * This is needed because replaceTrack() doesn't trigger ontrack events */ checkForNewRemoteTracks() { this.debug.log(`🔍 TRACK CHECK: Checking transceivers for new remote tracks from ${this.peerId.substring(0, 8)}...`); try { const transceivers = this.connection.getTransceivers(); let foundNewTracks = false; transceivers.forEach((transceiver, index) => { const track = transceiver.receiver.track; if (track && track.readyState === 'live') { this.debug.log(`🔍 TRACK CHECK: Transceiver ${index} has live ${track.kind} track: ${track.id.substring(0, 8)}...`); // Check if this is a new track we haven't processed const isNewTrack = !this.processedTrackIds || !this.processedTrackIds.has(track.id); if (isNewTrack) { this.debug.log(`🎵 NEW TRACK FOUND: Processing new ${track.kind} track from ${this.peerId.substring(0, 8)}...`); // Create a stream from this track (simulate ontrack event) const stream = new MediaStream([track]); // Validate and process like ontrack would if (this.validateRemoteStream(stream, track)) { this.remoteStream = stream; this.markStreamAsRemote(stream); // Track that we've processed this track if (!this.processedTrackIds) this.processedTrackIds = new Set(); this.processedTrackIds.add(track.id); this.debug.log('🚨 TRACK CHECK: Emitting remoteStream event for new track'); // Check if remote streams are allowed (crypto gating) if (this.allowRemoteStreams) { this.emit('remoteStream', { peerId: this.peerId, stream: this.remoteStream }); } else { // Buffer the stream until crypto allows it this.debug.log('🔒 TRACK CHECK: Buffering remote stream until crypto verification'); this.pendingRemoteStreams.push({ peerId: this.peerId, stream: this.remoteStream }); } foundNewTracks = true; } } } }); if (!foundNewTracks) { this.debug.log('🔍 TRACK CHECK: No new remote tracks found'); } } catch (error) { this.debug.error('❌ TRACK CHECK: Failed to check for remote tracks:', error); } } /** * Enhanced validation to ensure received stream is genuinely remote */ validateRemoteStream(stream, track) { this.debug.log('🔍 VALIDATION: Starting remote stream validation...'); // Check 0: Ensure stream and track are valid if (!stream) { this.debug.error('❌ VALIDATION: Stream is null or undefined'); return false; } if (!track) { this.debug.error('❌ VALIDATION: Track is null or undefined'); return false; } // Check 1: Stream ID collision (basic loopback detection) if (this.localStream && stream.id === this.localStream.id) { this.debug.error('❌ LOOPBACK DETECTED: Received our own local stream as remote!'); this.debug.error('Local stream ID:', this.localStream.id); this.debug.error('Received stream ID:', stream.id); return false; } this.debug.log('✅ VALIDATION: Stream ID check passed'); // Check 2: Track ID collision (more granular loopback detection) if (this.localStream) { const localTracks = this.localStream.getTracks(); const isOwnTrack = localTracks.some(localTrack => localTrack.id === track.id); if (isOwnTrack) { this.debug.error('❌ TRACK LOOPBACK: This track is our own local track!'); this.debug.error('Local track ID:', track.id); return false; } } this.debug.log('✅ VALIDATION: Track ID check passed'); // Check 3: Verify track comes from remote peer transceiver if (this.connection) { const transceivers = this.connection.getTransceivers(); this.debug.log(`🔍 VALIDATION: Checking ${transceivers.length} transceivers for track ${track.id.substring(0, 8)}...`); const sourceTransceiver = transceivers.find(t => t.receiver.track === track); if (!sourceTransceiver) { this.debug.warn('⚠️ VALIDATION: Track not found in any transceiver - may be invalid'); this.debug.warn('Available transceivers:', transceivers.map(t => ({ kind: t.receiver?.track?.kind || 'no-track', direction: t.direction, trackId: t.receiver?.track?.id?.substring(0, 8) || 'none' }))); // TEMPORARY FIX: Don't reject just because transceiver lookup fails // return false; this.debug.log('⚠️ VALIDATION: Allowing track despite transceiver lookup failure (temporary fix)'); } else { // Ensure this is a receiving transceiver (not sending our own track back) if (sourceTransceiver.direction === 'sendonly') { this.debug.error('❌ Invalid direction: Receiving track from sendonly transceiver'); return false; } this.debug.log(`✅ VALIDATION: Transceiver check passed (direction: ${sourceTransceiver.direction})`); } } // Check 4: Verify stream hasn't been marked as local origin (with safe property access) if (stream && stream._peerPigeonOrigin === 'local') { this.debug.error('❌ Stream marked as local origin - preventing synchronization loop'); return false; } this.debug.log('✅ VALIDATION: Local origin check passed'); this.debug.log('✅ Remote stream validation passed for peer', this.peerId.substring(0, 8)); return true; } /** * Mark a stream as genuinely remote to prevent future confusion */ markStreamAsRemote(stream) { // Add internal marker to prevent future misidentification Object.defineProperty(stream, '_peerPigeonOrigin', { value: 'remote', writable: false, enumerable: false, configurable: false }); Object.defineProperty(stream, '_peerPigeonSourcePeerId', { value: this.peerId, writable: false, enumerable: false, configurable: false }); this.debug.log(`🔒 Stream ${stream.id} marked as remote from peer ${this.peerId.substring(0, 8)}`); } /** * Mark local stream to prevent it from being treated as remote */ markStreamAsLocal(stream) { if (!stream) return; Object.defineProperty(stream, '_peerPigeonOrigin', { value: 'local', writable: false, enumerable: false, configurable: false }); this.debug.log(`🔒 Stream ${stream.id} marked as local origin`); } /** * Add local stream using addTrack() method to trigger ontrack events */ async addLocalStreamWithAddTrack(stream) { if (!stream || !this.connection) return; this.debug.log('🎥 Adding local stream using addTrack() for proper ontrack events'); const audioTracks = stream.getAudioTracks(); const videoTracks = stream.getVideoTracks(); // Mark stream as local origin to prevent loopback this.markStreamAsLocal(stream); // Add audio tracks using addTrack() audioTracks.forEach((audioTrack, index) => { this.debug.log(`🎤 Adding audio track ${index} using addTrack()`); try { const audioSender = this.connection.addTrack(audioTrack, stream); this.audioTransceiver = this.connection.getTransceivers().find(t => t.sender === audioSender); // Setup audio sending monitoring this.setupAudioSendingMonitoring(audioTrack); this.debug.log(`🎤 SENDING AUDIO to peer ${this.peerId.substring(0, 8)} - track enabled: ${audioTrack.enabled}`); } catch (error) { this.debug.error(`❌ Failed to add audio track ${index}:`, error); } }); // Add video tracks using addTrack() videoTracks.forEach((videoTrack, index) => { this.debug.log(`🎥 Adding video track ${index} using addTrack()`); try { const videoSender = this.connection.addTrack(videoTrack, stream); this.videoTransceiver = this.connection.getTransceivers().find(t => t.sender === videoSender); this.debug.log(`🎥 SENDING VIDEO to peer ${this.peerId.substring(0, 8)} - track enabled: ${videoTrack.enabled}`); } catch (error) { this.debug.error(`❌ Failed to add video track ${index}:`, error); } }); this.localStream = stream; this.debug.log('✅ Local stream added using addTrack() method'); // DEBUG: Log transceivers after adding tracks const transceivers = this.connection.getTransceivers(); this.debug.log('🔍 Transceivers after addTrack():', transceivers.map(t => ({ kind: t.receiver?.track?.kind || 'unknown', direction: t.direction, hasTrack: !!t.sender?.track, trackId: t.sender?.track?.id?.substring(0, 8) || 'none', mid: t.mid }))); } /** * Add or replace local media stream */ async setLocalStream(stream) { if (!this.connection) { throw new Error('Connection not initialized'); } this.debug.log(`Setting local stream for ${this.peerId}, current state: ${this.connection.connectionState}, signaling: ${this.connection.signalingState}`); // First, remove any existing local tracks const senders = this.connection.getSenders(); for (const sender of senders) { if (sender.track) { this.debug.log('�️ Removing existing track:', sender.track.kind); this.connection.removeTrack(sender); } } // Clear transceiver references this.audioTransceiver = null; this.videoTransceiver = null; if (stream) { this.debug.log('🎥 Adding new stream using addTrack() method'); await this.addLocalStreamWithAddTrack(stream); } else { this.localStream = null; this.debug.log('✅ All tracks removed'); } this.debug.log('Updated local media stream for', this.peerId); // CRITICAL: Force renegotiation when media changes this.debug.log('✅ Stream updated - forcing renegotiation for media changes'); this.debug.log(` Current state: connectionState=${this.connection.connectionState}, signalingState=${this.connection.signalingState}`); // Always trigger renegotiation when stream changes if (stream) { setTimeout(() => { this.debug.log('🔄 Forcing renegotiation for media stream changes'); this.emit('renegotiationNeeded', { peerId: this.peerId }); }, 200); } } /** * Force connection recovery for stuck connections */ async forceConnectionRecovery() { this.debug.log(`🆘 FORCE RECOVERY: Attempting emergency recovery for ${this.peerId.substring(0, 8)}...`); try { // Create a new offer to break the stuck state const offer = await Promise.race([ this.connection.createOffer({ iceRestart: true }), new Promise((resolve, reject) => setTimeout(() => reject(new Error('forceConnectionRecovery createOffer timeout')), 10000) ) ]); await Promise.race([ this.connection.setLocalDescription(offer), new Promise((resolve, reject) => setTimeout(() => reject(new Error('forceConnectionRecovery setLocalDescription timeout')), 10000) ) ]); // Emit recovery offer via mesh signaling if (this.mesh && this.mesh.sendSignalingMessage) { await this.mesh.sendSignalingMessage({ type: 'recovery-offer', data: offer, emergency: true }, this.peerId); this.debug.log(`✅ RECOVERY: Emergency offer sent for ${this.peerId.substring(0, 8)}...`); } else { this.debug.error(`❌ RECOVERY: No mesh signaling available for ${this.peerId.substring(0, 8)}...`); } } catch (error) { this.debug.error(`❌ RECOVERY: Emergency recovery failed for ${this.peerId.substring(0, 8)}...`, error); throw error; } } /** * Setup audio data monitoring for received audio tracks */ setupAudioDataMonitoring(audioTrack, trackIndex) { this.debug.log(`🎵 Setting up audio data monitoring for track ${trackIndex} from peer ${this.peerId.substring(0, 8)}`); try { // Create audio context for analyzing audio data const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) { this.debug.warn('🎵 AudioContext not available - cannot monitor audio data'); return; } // Create a MediaStream with just this audio track for analysis const trackStream = new MediaStream([audioTrack]); const audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(trackStream); const analyser = audioContext.createAnalyser(); analyser.fftSize = 256; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); source.connect(analyser); let lastLogTime = 0; let totalSamples = 0; let samplesWithAudio = 0; let maxLevel = 0; // Monitor audio levels periodically const monitorAudio = () => { if (audioTrack.readyState === 'ended') { this.debug.log(`🎵 Audio track ${trackIndex} ended, stopping monitoring for peer ${this.peerId.substring(0, 8)}`); audioContext.close(); return; } analyser.getByteFrequencyData(dataArray); // Calculate audio level (0-255) const average = dataArray.reduce((sum, value) => sum + value, 0) / bufferLength; const currentTime = Date.now(); totalSamples++; if (average > 5) { // Threshold for detecting audio activity samplesWithAudio++; maxLevel = Math.max(maxLevel, average); } // Log every 5 seconds if (currentTime - lastLogTime > 5000) { const audioActivity = totalSamples > 0 ? (samplesWithAudio / totalSamples * 100) : 0; this.debug.log(`🎵 Audio data from peer ${this.peerId.substring(0, 8)} track ${trackIndex}:`, { enabled: audioTrack.enabled, readyState: audioTrack.readyState, muted: audioTrack.muted, currentLevel: Math.round(average), maxLevel: Math.round(maxLevel), activityPercent: Math.round(audioActivity), samplesAnalyzed: totalSamples, hasAudioData: samplesWithAudio > 0 }); lastLogTime = currentTime; // Reset counters for next period totalSamples = 0; samplesWithAudio = 0; maxLevel = 0; } // Continue monitoring if track is still active if (audioTrack.readyState === 'live') { requestAnimationFrame(monitorAudio); } }; // Start monitoring requestAnimationFrame(monitorAudio); // Track state changes audioTrack.addEventListener('ended', () => { this.debug.log(`🎵 Audio track ${trackIndex} from peer ${this.peerId.substring(0, 8)} ended`); audioContext.close(); }); audioTrack.addEventListener('mute', () => { this.debug.log(`🎵 Audio track ${trackIndex} from peer ${this.peerId.substring(0, 8)} muted`); }); audioTrack.addEventListener('unmute', () => { this.debug.log(`🎵 Audio track ${trackIndex} from peer ${this.peerId.substring(0, 8)} unmuted`); }); this.debug.log(`🎵 Audio monitoring started for track ${trackIndex} from peer ${this.peerId.substring(0, 8)}`); } catch (error) { this.debug.error(`🎵 Failed to setup audio monitoring for track ${trackIndex}:`, error); } } /** * Setup audio sending monitoring for outgoing audio tracks */ setupAudioSendingMonitoring(audioTrack) { this.debug.log(`🎤 Setting up audio SENDING monitoring to peer ${this.peerId.substring(0, 8)}`); try { // Monitor track state changes audioTrack.addEventListener('ended', () => { this.debug.log(`🎤 Audio SENDING track ended to peer ${this.peerId.substring(0, 8)}`); }); audioTrack.addEventListener('mute', () => { this.debug.log(`🎤 Audio SENDING track muted to peer ${this.peerId.substring(0, 8)}`); }); audioTrack.addEventListener('unmute', () => { this.debug.log(`🎤 Audio SENDING track unmuted to peer ${this.peerId.substring(0, 8)}`); }); // Monitor audio input levels if possible const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) { this.debug.warn('🎤 AudioContext not available - basic sending monitoring only'); return; } const trackStream = new MediaStream([audioTrack]); const audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(trackStream); const analyser = audioContext.createAnalyser(); analyser.fftSize = 256; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); source.connect(analyser); let lastLogTime = 0; let totalSamples = 0; let activeSamples = 0; let maxSendLevel = 0; const monitorSending = () => { if (audioTrack.readyState === 'ended') { this.debug.log(`🎤 Audio sending track ended, stopping monitoring to peer ${this.peerId.substring(0, 8)}`); audioContext.close(); return; } analyser.getByteFrequencyData(dataArray); const average = dataArray.reduce((sum, value) => sum + value, 0) / bufferLength; const currentTime = Date.now(); totalSamples++; if (average > 5) { activeSamples++; maxSendLevel = Math.max(maxSendLevel, average); } // Log every 5 seconds if (currentTime - lastLogTime > 5000) { const sendingActivity = totalSamples > 0 ? (activeSamples / totalSamples * 100) : 0; this.debug.log(`🎤 Audio SENDING to peer ${this.peerId.substring(0, 8)}:`, { trackEnabled: audioTrack.enabled, trackReadyState: audioTrack.readyState, trackMuted: audioTrack.muted, currentSendLevel: Math.round(average), maxSendLevel: Math.round(maxSendLevel), sendingActivityPercent: Math.round(sendingActivity), samplesAnalyzed: totalSamples, audioBeingSent: activeSamples > 0 }); lastLogTime = currentTime; totalSamples = 0; activeSamples = 0; maxSendLevel = 0; } if (audioTrack.readyState === 'live') { requestAnimationFrame(monitorSending); } }; requestAnimationFrame(monitorSending); this.debug.log(`🎤 Audio sending monitoring started to peer ${this.peerId.substring(0, 8)}`); } catch (error) { this.debug.error(`🎤 Failed to setup audio sending monitoring to peer ${this.peerId.substring(0, 8)}:`, error); } } /** * Get remote media stream */ getRemoteStream() { return this.remoteStream; } /** * Get local media stream */ getLocalStream() { return this.localStream; } /** * Allow remote streams to be emitted (called after crypto verification) */ allowRemoteStreamEmission() { this.debug.log(`🔓 CRYPTO: Allowing remote stream emission for ${this.peerId.substring(0, 8)}...`); this.allowRemoteStreams = true;