UNPKG

peerpigeon

Version:

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

1,245 lines (1,053 loc) โ€ข 71.4 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) // Binary payload coordination (standard + streaming chunks) this._pendingBinaryPayloads = []; // Stream handling this._activeStreams = new Map(); // streamId -> { reader/writer, type, metadata } this._streamChunks = new Map(); // streamId -> chunks array for reassembly this._streamMetadata = new Map(); // streamId -> metadata // 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; } // Optimize STUN usage for automated/local headless tests to reduce offer timeouts const testMode = (typeof window !== 'undefined' && (window.AUTOMATED_TEST === true)) || (typeof global !== 'undefined' && (global.AUTOMATED_TEST === true)); const iceServers = testMode ? [ // Single STUN server (or none) is sufficient for localhost; fewer servers reduces Firefox slowdown warnings { urls: 'stun:stun.l.google.com:19302' } ] : [ { 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' } ]; this.debug.log(`๐Ÿงช Automated test mode: ${testMode} (iceServers before create = ${iceServers.length})`); this.connection = pigeonRTC.createPeerConnection({ iceServers, iceCandidatePoolSize: testMode ? 4 : 10, bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require' }); 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 { // ICE gathering complete - don't send null/empty candidate 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() { if (!this.dataChannel) { this.debug.error('setupDataChannel called without data channel instance'); return; } this.dataChannel.binaryType = 'arraybuffer'; 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; this._pendingBinaryPayloads.length = 0; if (!this.isClosing) { this.emit('disconnected', { peerId: this.peerId, reason: 'data channel closed' }); } }; this.dataChannel.onmessage = (event) => { const handleBinaryPayload = (buffer) => { if (!buffer) return; if (this._pendingBinaryPayloads.length === 0) { this.debug.warn('Received unexpected binary payload without metadata'); return; } const payload = this._pendingBinaryPayloads.shift(); const uint8Array = new Uint8Array(buffer); if (payload.type === 'binaryMessage') { if (payload.size && payload.size !== uint8Array.byteLength) { this.debug.warn(`Binary message size mismatch: expected ${payload.size}, got ${uint8Array.byteLength}`); } this.debug.log(`๐Ÿ“ฆ Received binary message (${uint8Array.byteLength} bytes) from ${this.peerId.substring(0, 8)}...`); this.emit('message', { peerId: this.peerId, message: { type: 'binary', data: uint8Array, size: uint8Array.byteLength } }); return; } if (payload.type === 'streamChunk') { this._handleStreamChunkBinary(payload.header, uint8Array); return; } this.debug.warn(`Received binary payload with unknown handler type: ${payload.type}`); }; if (event.data instanceof ArrayBuffer) { handleBinaryPayload(event.data); return; } if (typeof Blob !== 'undefined' && event.data instanceof Blob) { event.data.arrayBuffer() .then(handleBinaryPayload) .catch(error => this.debug.error('Failed to read binary payload:', error)); return; } try { const message = JSON.parse(event.data); if (message.type === '__BINARY__') { this._pendingBinaryPayloads.push({ type: 'binaryMessage', size: message.size }); this.debug.log(`๐Ÿ“ฆ Binary message header received, expecting ${message.size} bytes`); return; } if (message.type && message.type.startsWith('__STREAM_')) { this._handleStreamMessage(message); return; } 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; this._pendingBinaryPayloads.length = 0; 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'); } // Allow empty candidate string (end-of-candidates signal from some browsers) if (!candidate.candidate || typeof candidate.candidate !== 'string' || candidate.candidate.trim() === '') { this.debug.log(`๐ŸงŠ Received end-of-candidates signal for ${this.peerId.substring(0, 8)}...`); // Some browsers send empty candidate as end-of-candidates - ignore it return; } 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 { // Check if message is binary data (ArrayBuffer or TypedArray like Uint8Array) if (message instanceof ArrayBuffer || ArrayBuffer.isView(message)) { this.debug.log(`๐Ÿ“ฆ Sending binary message (${message.byteLength || message.buffer.byteLength} bytes) to ${this.peerId.substring(0, 8)}...`); // For TypedArray views, send the underlying buffer const buffer = ArrayBuffer.isView(message) ? message.buffer : message; // Send a small JSON header first to indicate binary message follows const header = JSON.stringify({ type: '__BINARY__', size: buffer.byteLength }); this.dataChannel.send(header); // Then send the actual binary data this.dataChannel.send(buffer); return true; } else { // Regular JSON message 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; } /** * Create a WritableStream for sending data to this peer * @param {object} options - Stream options * @returns {WritableStream} A writable stream for sending data */ createWritableStream(options = {}) { const streamId = options.streamId || this._generateStreamId(); const metadata = { streamId, type: options.type || 'binary', filename: options.filename, mimeType: options.mimeType, totalSize: options.totalSize, chunkSize: options.chunkSize, timestamp: Date.now() }; this.debug.log(`๐Ÿ“ค Creating writable stream ${streamId} to ${this.peerId.substring(0, 8)}...`); const configuredChunkSize = (typeof metadata.chunkSize === 'number' && Number.isFinite(metadata.chunkSize)) ? metadata.chunkSize : 16384; const chunkSize = Math.max(4096, Math.min(configuredChunkSize, 65536)); metadata.chunkSize = chunkSize; const highWaterMark = chunkSize * 32; const lowWaterMark = chunkSize * 8; const ensureChannelOpen = () => { if (!this.dataChannel || this.dataChannel.readyState !== 'open') { throw new Error('Data channel not open'); } }; const waitForBufferDrain = () => { if (!this.dataChannel || this.dataChannel.readyState !== 'open') { return Promise.resolve(); } if (this.dataChannel.bufferedAmount <= lowWaterMark) { return Promise.resolve(); } return new Promise((resolve) => { const checkBuffer = () => { if (!this.dataChannel || this.dataChannel.readyState !== 'open') { resolve(); return; } if (this.dataChannel.bufferedAmount <= lowWaterMark) { resolve(); } else { setTimeout(checkBuffer, 20); } }; checkBuffer(); }); }; // Send stream initialization message this.sendMessage({ type: '__STREAM_INIT__', streamId, metadata }); let chunkIndex = 0; const self = this; return new WritableStream({ async write(chunk) { ensureChannelOpen(); const data = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); let offset = 0; while (offset < data.byteLength) { ensureChannelOpen(); const sliceEnd = Math.min(offset + chunkSize, data.byteLength); const slice = data.subarray(offset, sliceEnd); const sliceBuffer = (slice.byteOffset === 0 && slice.byteLength === slice.buffer.byteLength) ? slice.buffer : slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength); const chunkMessage = { type: '__STREAM_CHUNK__', streamId, chunkIndex: chunkIndex++, size: slice.byteLength, encoding: 'binary' }; try { self.dataChannel.send(JSON.stringify(chunkMessage)); self.dataChannel.send(sliceBuffer); } catch (error) { throw error; } offset = sliceEnd; if (self.dataChannel && self.dataChannel.bufferedAmount > highWaterMark) { await waitForBufferDrain(); } } }, async close() { if (self.dataChannel && self.dataChannel.readyState === 'open') { await waitForBufferDrain(); self.sendMessage({ type: '__STREAM_END__', streamId, totalChunks: chunkIndex }); } self._activeStreams.delete(streamId); self.debug.log(`๐Ÿ“ค Closed writable stream ${streamId}`); }, async abort(reason) { if (self.dataChannel && self.dataChannel.readyState === 'open') { self.sendMessage({ type: '__STREAM_ABORT__', streamId, reason: reason?.message || 'Stream aborted' }); } self._activeStreams.delete(streamId); self.debug.log(`โŒ Aborted writable stream ${streamId}: ${reason}`); } }); } /** * Handle incoming stream messages and create ReadableStream * @private */ _handleStreamMessage(message) { const { type } = message; switch (type) { case '__STREAM_INIT__': this._handleStreamInit(message); break; case '__STREAM_CHUNK__': if (message.encoding === 'binary') { this._pendingBinaryPayloads.push({ type: 'streamChunk', header: message }); } else { this._handleStreamChunkLegacy(message); } break; case '__STREAM_END__': this._handleStreamEnd(message); break; case '__STREAM_ABORT__': this._handleStreamAbort(message); break; } } _handleStreamInit(message) { const { streamId, metadata } = message; this.debug.log(`๐Ÿ“ฅ Receiving stream ${streamId} from ${this.peerId.substring(0, 8)}...`); // Store metadata this._streamMetadata.set(streamId, metadata); this._streamChunks.set(streamId, []); // Create ReadableStream const chunks = this._streamChunks.get(streamId); let controller; const readable = new ReadableStream({ start(ctrl) { controller = ctrl; }, cancel: (reason) => { this.debug.log(`๐Ÿ“ฅ Stream ${streamId} cancelled: ${reason}`); } }); this._activeStreams.set(streamId, { type: 'readable', stream: readable, controller, metadata, chunks }); // Emit stream event this.emit('streamReceived', { peerId: this.peerId, streamId, stream: readable, metadata }); } _handleStreamChunkLegacy(message) { const { streamId, chunkIndex, data } = message; const streamData = this._activeStreams.get(streamId); if (!streamData) { this.debug.warn(`Received chunk for unknown stream ${streamId}`); return; } // Convert array back to Uint8Array const chunk = new Uint8Array(data); // Enqueue chunk to readable stream if (streamData.controller) { streamData.controller.enqueue(chunk); } if (Array.isArray(streamData.chunks)) { streamData.chunks.push(chunk); } this.debug.log(`๐Ÿ“ฅ Received chunk ${chunkIndex} for stream ${streamId} (${chunk.length} bytes)`); } _handleStreamChunkBinary(header, chunk) { const { streamId, chunkIndex, size } = header; const streamData = this._activeStreams.get(streamId); if (!streamData) { this.debug.warn(`Received binary chunk for unknown stream ${streamId}`); return; } if (size && size !== chunk.byteLength) { this.debug.warn(`Stream ${streamId} chunk ${chunkIndex} size mismatch: expected ${size}, got ${chunk.byteLength}`); } if (streamData.controller) { streamData.controller.enqueue(chunk); } if (Array.isArray(streamData.chunks)) { streamData.chunks.push(chunk); } this.debug.log(`๐Ÿ“ฅ Received chunk ${chunkIndex} for stream ${streamId} (${chunk.byteLength} bytes)`); } _handleStreamEnd(message) { const { streamId, totalChunks } = message; const streamData = this._activeStreams.get(streamId); if (!streamData) { this.debug.warn(`Received end for unknown stream ${streamId}`); return; } // Close the readable stream if (streamData.controller) { streamData.controller.close(); } this.debug.log(`๐Ÿ“ฅ Stream ${streamId} completed (${totalChunks} chunks)`); // Cleanup this._activeStreams.delete(streamId); this._streamChunks.delete(streamId); this._streamMetadata.delete(streamId); this.emit('streamCompleted', { peerId: this.peerId, streamId, totalChunks }); } _handleStreamAbort(message) { const { streamId, reason } = message; const streamData = this._activeStreams.get(streamId); if (streamData && streamData.controller) { streamData.controller.error(new Error(reason)); } this.debug.log(`โŒ Stream ${streamId} aborted: ${reason}`); // Cleanup this._activeStreams.delete(streamId); this._streamChunks.delete(streamId); this._streamMetadata.delete(streamId); this.emit('streamAborted', { peerId: this.peerId, streamId, reason }); } _generateStreamId() { return `stream-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } /** * 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(); cons