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
JavaScript
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