peerpigeon
Version:
WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server
1,199 lines (1,012 loc) • 46.2 kB
JavaScript
import { EventEmitter } from './EventEmitter.js';
import { PeerConnection } from './PeerConnection.js';
import { environmentDetector } from './EnvironmentDetector.js';
import DebugLogger from './DebugLogger.js';
/**
* Manages individual peer connections, timeouts, and connection attempts
*/
export class ConnectionManager extends EventEmitter {
constructor(mesh) {
super();
this.mesh = mesh;
this.debug = DebugLogger.create('ConnectionManager');
this.peers = new Map();
this.connectionAttempts = new Map();
this.pendingIceCandidates = new Map();
this.disconnectionInProgress = new Set();
this.cleanupInProgress = new Set();
this.lastConnectionAttempt = new Map(); // Track last attempt time per peer
// Renegotiation management to prevent conflicts
this.activeRenegotiations = new Set();
this.renegotiationQueue = new Map();
this.maxConcurrentRenegotiations = 1; // Only allow 1 renegotiation at a time
// Configuration
this.maxConnectionAttempts = 3;
this.retryDelay = 500; // Faster retry - 500ms between attempts to same peer
// Start periodic cleanup of stale peers
this.startPeriodicCleanup();
// Start monitoring for stuck connections
this.startStuckConnectionMonitoring();
// Set up mesh-level event listeners for crypto-gated media
this.setupMeshEventListeners();
}
/**
* Set up event listeners for mesh-level events
*/
setupMeshEventListeners() {
// Listen for successful key exchanges to enable media sharing
this.mesh.addEventListener('peerKeyAdded', (event) => {
this.handlePeerKeyAdded(event.peerId);
});
}
/**
* Handle when a peer's crypto key is successfully added
* @param {string} peerId - The peer ID whose key was added
*/
async handlePeerKeyAdded(peerId) {
this.debug.log(`🔐 Key added for ${peerId.substring(0, 8)}... - crypto verification complete`);
// NOTE: We do NOT automatically enable remote streams here
// Media streams (both local and remote) must be manually invoked by the user via the "Start Media" button
// This ensures complete control over when ANY media streams are allowed
this.debug.log(`🔐 Crypto verified for ${peerId.substring(0, 8)}... - user must manually invoke media to enable streams`);
}
async connectToPeer(targetPeerId) {
this.debug.log(`connectToPeer called for ${targetPeerId.substring(0, 8)}...`);
// Enhanced duplicate connection prevention
if (this.peers.has(targetPeerId)) {
this.debug.log(`Already connected to ${targetPeerId.substring(0, 8)}...`);
return;
}
// Check if we're already attempting through PeerDiscovery
if (this.mesh.peerDiscovery.isAttemptingConnection(targetPeerId)) {
this.debug.log(`Already attempting connection to ${targetPeerId.substring(0, 8)}... via PeerDiscovery`);
return;
}
// INITIATOR LOGIC: Use deterministic peer ID comparison to prevent race conditions
// Only become initiator if our peer ID is lexicographically greater than target's
const shouldBeInitiator = this.mesh.peerId > targetPeerId;
if (!shouldBeInitiator) {
this.debug.log(`🔄 INITIATOR LOGIC: Not becoming initiator for ${targetPeerId.substring(0, 8)}... (our ID: ${this.mesh.peerId.substring(0, 8)}... is smaller)`);
return; // Let the other peer initiate
}
this.debug.log(`🔄 INITIATOR LOGIC: Becoming initiator for ${targetPeerId.substring(0, 8)}... (our ID: ${this.mesh.peerId.substring(0, 8)}... is greater)`);
if (!this.mesh.canAcceptMorePeers()) {
this.debug.log(`Cannot connect to ${targetPeerId.substring(0, 8)}... (max peers reached: ${this.mesh.maxPeers})`);
return;
}
// Check retry cooldown (only apply after first attempt)
const now = Date.now();
const attempts = this.connectionAttempts.get(targetPeerId) || 0;
if (attempts > 0) {
const lastAttempt = this.lastConnectionAttempt.get(targetPeerId) || 0;
// Use shorter delay for isolated peers to help them connect faster
const connectedCount = this.getConnectedPeerCount();
const retryDelay = connectedCount === 0 ? 200 : this.retryDelay; // 200ms for isolated peers, 500ms otherwise
if (now - lastAttempt < retryDelay) {
const remaining = retryDelay - (now - lastAttempt);
this.debug.log(`Connection to ${targetPeerId.substring(0, 8)}... on cooldown (${Math.round(remaining / 1000)}s remaining, isolated: ${connectedCount === 0})`);
return;
}
}
// Check connection attempt count
if (attempts >= this.maxConnectionAttempts) {
this.mesh.emit('statusChanged', { type: 'warning', message: `Max connection attempts reached for ${targetPeerId.substring(0, 8)}...` });
this.mesh.peerDiscovery.removeDiscoveredPeer(targetPeerId);
return;
}
this.debug.log(`Starting connection to ${targetPeerId.substring(0, 8)}... (attempt ${attempts + 1})`);
this.connectionAttempts.set(targetPeerId, attempts + 1);
this.lastConnectionAttempt.set(targetPeerId, now);
this.mesh.peerDiscovery.trackConnectionAttempt(targetPeerId);
try {
this.debug.log(`Creating PeerConnection for ${targetPeerId.substring(0, 8)}...`);
// SECURITY: NO automatic media sharing - all media must be manually invoked
const options = {
localStream: null, // Always null - media must be manually added later
// ALWAYS enable both audio and video transceivers for maximum compatibility
// This allows peers to receive media even if they don't have media when connecting
enableAudio: true,
enableVideo: true
// allowRemoteStreams defaults to false - streams only invoked when user clicks "Start Media"
};
this.debug.log(`🔄 INITIATOR SETUP: Creating PeerConnection(${targetPeerId.substring(0, 8)}..., isInitiator=true)`);
const peerConnection = new PeerConnection(targetPeerId, true, options);
// Set up event handlers BEFORE creating connection to catch all events
this.setupPeerConnectionHandlers(peerConnection);
this.peers.set(targetPeerId, peerConnection);
this.debug.log(`Creating WebRTC connection for ${targetPeerId.substring(0, 8)}...`);
await peerConnection.createConnection();
this.debug.log(`Creating offer for ${targetPeerId.substring(0, 8)}...`);
const offer = await peerConnection.createOffer();
this.debug.log(`Offer created for ${targetPeerId.substring(0, 8)}...`, {
type: offer.type,
sdpLength: offer.sdp?.length || 0,
hasAudio: offer.sdp?.includes('m=audio') || false,
hasVideo: offer.sdp?.includes('m=video') || false
});
this.debug.log(`Sending offer to ${targetPeerId.substring(0, 8)}...`);
await this.mesh.sendSignalingMessage({
type: 'offer',
data: offer
}, targetPeerId);
this.mesh.emit('statusChanged', { type: 'info', message: `Offer sent to ${targetPeerId.substring(0, 8)}...` });
} catch (error) {
this.debug.error('Failed to connect to peer:', error);
this.mesh.emit('statusChanged', { type: 'error', message: `Failed to connect to ${targetPeerId.substring(0, 8)}...: ${error.message}` });
this.cleanupFailedConnection(targetPeerId);
}
}
cleanupFailedConnection(peerId) {
this.debug.log(`Cleaning up failed connection for ${peerId.substring(0, 8)}...`);
// Remove peer connection
let peerRemoved = false;
if (this.peers.has(peerId)) {
const peer = this.peers.get(peerId);
const status = peer.getStatus();
this.debug.log(`Removing peer ${peerId.substring(0, 8)}... with status: ${status}`);
try {
if (typeof peer.markAsFailed === 'function') {
peer.markAsFailed('failed');
}
peer.close();
} catch (error) {
this.debug.error('Error closing failed connection:', error);
}
this.peers.delete(peerId);
peerRemoved = true;
this.debug.log(`Successfully removed peer ${peerId.substring(0, 8)}... from peers Map`);
} else {
this.debug.log(`Peer ${peerId.substring(0, 8)}... was not in peers Map`);
}
// Clean up related data
this.mesh.peerDiscovery.clearConnectionAttempt(peerId);
this.pendingIceCandidates.delete(peerId);
// Always emit peersUpdated if we removed a peer or to force UI refresh
if (peerRemoved) {
this.debug.log(`Emitting peersUpdated after removing ${peerId.substring(0, 8)}...`);
this.emit('peersUpdated');
}
}
cleanupRaceCondition(peerId) {
// Remove peer connection but preserve connection attempts
if (this.peers.has(peerId)) {
const peer = this.peers.get(peerId);
try {
peer.close();
} catch (error) {
this.debug.error('Error closing race condition connection:', error);
}
this.peers.delete(peerId);
}
// Don't clear connection attempts or discovery data - just the active connection
this.pendingIceCandidates.delete(peerId);
this.emit('peersUpdated');
}
setupPeerConnectionHandlers(peerConnection) {
peerConnection.addEventListener('iceCandidate', async (event) => {
try {
this.debug.log('Sending ICE candidate to', event.peerId);
await this.mesh.sendSignalingMessage({
type: 'ice-candidate',
data: event.candidate
}, event.peerId);
} catch (error) {
this.debug.error('Failed to send ICE candidate:', error);
}
});
peerConnection.addEventListener('connected', (event) => {
this.debug.log(`[EVENT] Connected event received from ${event.peerId.substring(0, 8)}...`);
// Reset connection attempts on successful connection
this.connectionAttempts.delete(event.peerId);
// Don't emit peerConnected here - wait for data channel to be ready
this.mesh.emit('statusChanged', { type: 'info', message: `WebRTC connected to ${event.peerId.substring(0, 8)}...` });
this.mesh.peerDiscovery.clearConnectionAttempt(event.peerId);
this.mesh.peerDiscovery.updateDiscoveryTimestamp(event.peerId);
this.emit('peersUpdated');
});
peerConnection.addEventListener('disconnected', (event) => {
this.mesh.emit('statusChanged', { type: 'info', message: `Disconnected from ${event.peerId.substring(0, 8)}...` });
this.handlePeerDisconnection(event.peerId, event.reason);
});
peerConnection.addEventListener('dataChannelOpen', (event) => {
this.debug.log(`[EVENT] DataChannelOpen event received from ${event.peerId.substring(0, 8)}...`);
this.mesh.emit('statusChanged', { type: 'info', message: `Data channel ready with ${event.peerId.substring(0, 8)}...` });
this.emit('peersUpdated');
// Track successful connections to reset isolation timer
if (this.mesh.peerDiscovery) {
this.mesh.peerDiscovery.onConnectionEstablished();
}
// Automatically initiate key exchange when crypto is enabled
if (this.mesh.cryptoManager) {
// Check if we already have this peer's key to avoid duplicate exchanges
const hasExistingKey = this.mesh.cryptoManager.peerKeys.has(event.peerId);
if (!hasExistingKey) {
this.debug.log(`🔐 Automatically exchanging keys with newly connected peer ${event.peerId.substring(0, 8)}...`);
// PERFORMANCE: Defer key exchange to prevent blocking data channel establishment
setTimeout(() => {
this.mesh.exchangeKeysWithPeer(event.peerId).catch(error => {
this.debug.error(`🔐 Failed to exchange keys with ${event.peerId.substring(0, 8)}:`, error);
});
}, 0);
} else {
this.debug.log(`🔐 Skipping key exchange with ${event.peerId.substring(0, 8)}... - key already exists`);
}
}
this.mesh.emit('peerConnected', { peerId: event.peerId });
});
peerConnection.addEventListener('message', (event) => {
this.handleIncomingMessage(event.message, event.peerId);
});
peerConnection.addEventListener('remoteStream', (event) => {
this.debug.log(`[EVENT] Remote stream received from ${event.peerId.substring(0, 8)}...`);
this.emit('remoteStream', event);
// DISABLED: Media forwarding causes cascade renegotiation issues with 3+ peers
// Each peer should manage their own direct streams to avoid conflicts
// this._forwardStreamToOtherPeers(event.stream, event.peerId);
this.debug.log('🔄 MEDIA FORWARDING: Disabled to prevent renegotiation conflicts with 3+ peers');
});
peerConnection.addEventListener('renegotiationNeeded', async (event) => {
this.debug.log(`🔄 Renegotiation needed for ${event.peerId.substring(0, 8)}...`);
// SMART RENEGOTIATION: Queue renegotiations to prevent conflicts
if (this.activeRenegotiations.size >= this.maxConcurrentRenegotiations) {
this.debug.log(`🔄 QUEUE: Renegotiation for ${event.peerId.substring(0, 8)}... queued (${this.activeRenegotiations.size} active)`);
this.renegotiationQueue.set(event.peerId, event);
return;
}
await this._performRenegotiation(peerConnection, event);
});
// ...existing code...
}
handlePeerDisconnection(peerId, reason) {
// Prevent duplicate disconnection handling
if (this.disconnectionInProgress.has(peerId)) {
this.debug.log(`Disconnection already in progress for ${peerId.substring(0, 8)}..., skipping duplicate`);
return;
}
this.debug.log(`Handling peer disconnection: ${peerId.substring(0, 8)}... (${reason})`);
// Mark disconnection in progress
this.disconnectionInProgress.add(peerId);
try {
// Clear all related data
if (this.peers.has(peerId)) {
const peerConnection = this.peers.get(peerId);
try {
peerConnection.close();
} catch (error) {
this.debug.error('Error closing peer connection:', error);
}
this.peers.delete(peerId);
}
this.mesh.peerDiscovery.clearConnectionAttempt(peerId);
this.pendingIceCandidates.delete(peerId);
// Clean up eviction tracking
this.mesh.evictionManager.clearEvictionTracking(peerId);
// Don't remove from discovered peers immediately - let it timeout naturally
// unless it's a goodbye or explicit removal
if (reason === 'left network' || reason === 'manually removed') {
this.mesh.peerDiscovery.removeDiscoveredPeer(peerId);
this.connectionAttempts.delete(peerId);
} else if (reason === 'connection failed' || reason === 'connection disconnected' || reason === 'ICE connection closed') {
// For connection failures, clear the attempt so we can retry later
this.connectionAttempts.delete(peerId);
this.debug.log(`Cleared connection attempt for ${peerId.substring(0, 8)}... due to ${reason} - will retry later`);
}
this.mesh.emit('peerDisconnected', { peerId, reason });
this.emit('peersUpdated');
// Only trigger optimization if we're significantly under capacity
const connectedCount = this.getConnectedPeerCount();
const needsOptimization = connectedCount === 0; // Only optimize if completely disconnected
if (needsOptimization && this.mesh.autoDiscovery && this.mesh.peerDiscovery.getDiscoveredPeers().length > 0) {
this.debug.log(`Completely disconnected (${connectedCount}/${this.mesh.maxPeers}), scheduling mesh optimization`);
setTimeout(() => {
// Check if we still need optimization
const currentCount = this.getConnectedPeerCount();
if (currentCount === 0) {
this.debug.log(`Still completely disconnected (${currentCount}/${this.mesh.maxPeers}), attempting optimization`);
this.mesh.peerDiscovery.optimizeMeshConnections(this.peers);
} else {
this.debug.log(`Connection recovered (${currentCount}/${this.mesh.maxPeers}), skipping optimization`);
}
}, 500); // Wait 500ms before optimizing - faster response
} else {
this.debug.log(`Peer count appropriate at ${connectedCount}/${this.mesh.maxPeers}, no optimization needed`);
}
} finally {
// Always clean up the disconnection tracking
this.disconnectionInProgress.delete(peerId);
}
}
disconnectAllPeers() {
this.peers.forEach((peerConnection, peerId) => {
peerConnection.close();
this.mesh.emit('peerDisconnected', { peerId, reason: 'mesh disconnected' });
});
}
disconnectPeer(peerId, reason) {
this.handlePeerDisconnection(peerId, reason);
}
removePeer(peerId) {
this.mesh.peerDiscovery.removeDiscoveredPeer(peerId);
this.mesh.peerDiscovery.clearConnectionAttempt(peerId);
this.connectionAttempts.delete(peerId);
if (this.peers.has(peerId)) {
const peer = this.peers.get(peerId);
if (peer.connection) {
peer.connection.close();
}
this.peers.delete(peerId);
this.mesh.emit('peerDisconnected', { peerId, reason: 'manually removed' });
}
this.emit('peersUpdated');
}
canAcceptMorePeers() {
// Count connected peers first (these are guaranteed slots)
const connectedCount = this.getConnectedPeerCount();
// If we have room for connected peers, always accept
if (connectedCount < this.mesh.maxPeers) {
return true;
}
// If at max connected peers, check if we have stale peers we can evict
const stalePeerCount = this.getStalePeerCount();
const totalPeerCount = this.peers.size;
// Accept if we have stale peers that can be cleaned up
if (stalePeerCount > 0 && totalPeerCount >= this.mesh.maxPeers) {
this.debug.log(`At capacity (${connectedCount}/${this.mesh.maxPeers} connected, ${totalPeerCount} total) but have ${stalePeerCount} stale peers that can be evicted`);
return true;
}
// Reject if we're at capacity with all viable peers
this.debug.log(`Cannot accept more peers: ${connectedCount}/${this.mesh.maxPeers} connected, ${totalPeerCount} total peers in Map`);
return false;
}
/**
* Count peers that are in stale/non-viable states
*/
getStalePeerCount() {
const now = Date.now();
const STALE_THRESHOLD = 45000; // 45 seconds (shorter than cleanup threshold)
return Array.from(this.peers.values()).filter(peerConnection => {
const status = peerConnection.getStatus();
const connectionAge = now - peerConnection.connectionStartTime;
return connectionAge > STALE_THRESHOLD &&
(status === 'failed' || status === 'disconnected' || status === 'closed');
}).length;
}
getConnectedPeerCount() {
return Array.from(this.peers.values()).filter(peerConnection =>
peerConnection.getStatus() === 'connected'
).length;
}
getConnectedPeers() {
return Array.from(this.peers.values()).filter(peerConnection =>
peerConnection.getStatus() === 'connected'
);
}
getPeers() {
return Array.from(this.peers.entries()).map(([peerId, peerConnection]) => ({
peerId,
status: peerConnection.getStatus(),
isInitiator: peerConnection.isInitiator,
connectionStartTime: peerConnection.connectionStartTime
}));
}
hasPeer(peerId) {
return this.peers.has(peerId);
}
getPeer(peerId) {
return this.peers.get(peerId);
}
sendMessage(content) {
if (!content || typeof content !== 'string') {
this.debug.error('Invalid message content:', content);
return 0;
}
// Use gossip protocol to broadcast messages throughout the mesh network
this.debug.log(`Broadcasting message via gossip protocol: "${content}"`);
const messageId = this.mesh.gossipManager.broadcastMessage(content, 'chat');
if (messageId) {
// Return the number of directly connected peers for UI feedback
// (the message will propagate to the entire network via gossip)
const connectedCount = this.getConnectedPeerCount();
this.debug.log(`Message broadcasted via gossip to ${connectedCount} directly connected peer(s), will propagate to entire network`);
return connectedCount;
} else {
this.debug.error('Failed to broadcast message via gossip protocol');
return 0;
}
}
/**
* Send a message directly to a specific peer via data channel
* @param {string} peerId - The peer ID to send to
* @param {Object} message - The message object to send
* @returns {boolean} - True if message was sent successfully
*/
sendDirectMessage(peerId, message) {
const peerConnection = this.peers.get(peerId);
if (!peerConnection) {
this.debug.warn(`Cannot send direct message to ${peerId?.substring(0, 8)}: peer not connected`);
return false;
}
try {
this.debug.log(`📤 Sending direct message to ${peerId?.substring(0, 8)}:`, message);
peerConnection.sendMessage(message);
return true;
} catch (error) {
this.debug.error(`Failed to send direct message to ${peerId?.substring(0, 8)}:`, error);
return false;
}
}
async handleIceCandidate(candidate, fromPeerId) {
this.debug.log('Handling ICE candidate from', fromPeerId);
const peerConnection = this.peers.get(fromPeerId);
if (peerConnection) {
try {
await peerConnection.handleIceCandidate(candidate);
} catch (error) {
this.debug.error('Failed to add ICE candidate:', error);
}
} else {
// No peer connection exists yet - buffer the candidate for when it's created
this.debug.log('Buffering ICE candidate for', fromPeerId, '(no peer connection yet)');
if (!this.pendingIceCandidates.has(fromPeerId)) {
this.pendingIceCandidates.set(fromPeerId, []);
}
this.pendingIceCandidates.get(fromPeerId).push(candidate);
}
}
async processPendingIceCandidates(peerId) {
const candidates = this.pendingIceCandidates.get(peerId);
if (candidates && candidates.length > 0) {
this.debug.log(`Processing ${candidates.length} buffered ICE candidates for`, peerId);
const peerConnection = this.peers.get(peerId);
if (peerConnection) {
for (const candidate of candidates) {
try {
await peerConnection.handleIceCandidate(candidate);
} catch (error) {
this.debug.error('Failed to add buffered ICE candidate:', error);
}
}
// Clear the buffer after processing
this.pendingIceCandidates.delete(peerId);
}
}
}
cleanup() {
// Stop periodic cleanup
this.stopPeriodicCleanup();
this.peers.clear();
this.connectionAttempts.clear();
this.pendingIceCandidates.clear();
this.disconnectionInProgress.clear();
this.cleanupInProgress.clear();
this.lastConnectionAttempt.clear();
}
/**
* Start periodic cleanup of stale peer connections
*/
startPeriodicCleanup() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
// Run cleanup every 30 seconds - environment-aware timer
if (environmentDetector.isBrowser) {
this.cleanupInterval = window.setInterval(() => {
this.cleanupStalePeers();
}, 30000);
} else {
this.cleanupInterval = setInterval(() => {
this.cleanupStalePeers();
}, 30000);
}
}
/**
* Stop periodic cleanup
*/
stopPeriodicCleanup() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
/**
* Clean up peers that are in non-viable states for too long
*/
cleanupStalePeers() {
const now = Date.now();
const STALE_THRESHOLD = 60000; // 60 seconds
const DISCONNECTED_THRESHOLD = 5000; // 5 seconds for disconnected peers
const peersToCleanup = [];
this.peers.forEach((peerConnection, peerId) => {
const status = peerConnection.getStatus();
const connectionAge = now - peerConnection.connectionStartTime;
// Immediately clean up disconnected peers
if (status === 'disconnected' && connectionAge > DISCONNECTED_THRESHOLD) {
this.debug.log(`Disconnected peer detected: ${peerId.substring(0, 8)}... (status: ${status}, age: ${Math.round(connectionAge / 1000)}s)`);
peersToCleanup.push(peerId);
} else if (connectionAge > STALE_THRESHOLD) {
if (status === 'connecting' || status === 'channel-connecting' ||
status === 'failed' || status === 'closed') {
this.debug.log(`Stale peer detected: ${peerId.substring(0, 8)}... (status: ${status}, age: ${Math.round(connectionAge / 1000)}s)`);
peersToCleanup.push(peerId);
}
}
});
if (peersToCleanup.length > 0) {
this.debug.log(`Cleaning up ${peersToCleanup.length} stale peer(s)`);
peersToCleanup.forEach(peerId => {
this.cleanupFailedConnection(peerId);
});
}
}
/**
* Force cleanup of peers that are not in connected state (for debugging)
*/
forceCleanupInvalidPeers() {
this.debug.log('Force cleaning up peers not in connected state...');
const peersToRemove = [];
this.peers.forEach((peerConnection, peerId) => {
const status = peerConnection.getStatus();
if (status !== 'connected') {
this.debug.log(`Found peer ${peerId.substring(0, 8)}... in invalid state: ${status}`);
peersToRemove.push(peerId);
}
});
peersToRemove.forEach(peerId => {
this.debug.log(`Force removing peer ${peerId.substring(0, 8)}...`);
this.cleanupFailedConnection(peerId);
});
if (peersToRemove.length > 0) {
this.debug.log(`Force cleaned up ${peersToRemove.length} invalid peers`);
this.emit('peersUpdated');
}
return peersToRemove.length;
}
/**
* Get a summary of all peer states for debugging
*/
getPeerStateSummary() {
const summary = {
total: this.peers.size,
connected: 0,
connecting: 0,
channelConnecting: 0,
failed: 0,
disconnected: 0,
closed: 0,
other: 0,
stale: this.getStalePeerCount()
};
this.peers.forEach((peerConnection) => {
const status = peerConnection.getStatus();
switch (status) {
case 'connected':
summary.connected++;
break;
case 'connecting':
summary.connecting++;
break;
case 'channel-connecting':
summary.channelConnecting++;
break;
case 'failed':
summary.failed++;
break;
case 'disconnected':
summary.disconnected++;
break;
case 'closed':
summary.closed++;
break;
default:
summary.other++;
}
});
return summary;
}
getDetailedPeerStatus() {
const peerStatuses = {};
this.peers.forEach((peerConnection, peerId) => {
peerStatuses[peerId.substring(0, 8) + '...'] = {
status: peerConnection.getStatus(),
isInitiator: peerConnection.isInitiator,
dataChannelReady: peerConnection.dataChannelReady,
connectionStartTime: peerConnection.connectionStartTime,
connectionState: peerConnection.connection?.connectionState,
iceConnectionState: peerConnection.connection?.iceConnectionState
};
});
return peerStatuses;
}
// Get all peer connections
getAllConnections() {
return Array.from(this.peers.values());
}
/**
* Route incoming messages based on their type
*/
handleIncomingMessage(message, fromPeerId) {
if (!message || typeof message !== 'object') {
this.debug.warn('Received invalid message from', fromPeerId?.substring(0, 8));
return;
}
// Define message types that should be filtered from peer-readable messages
// These messages are processed but not emitted as regular messages to UI/applications
const filteredMessageTypes = new Set([
'signaling-relay',
'peer-announce-relay',
'bootstrap-keepalive',
'client-peer-announcement',
'cross-bootstrap-signaling'
]);
// Check if this message type should be filtered from peer-readable messages
const isFilteredMessage = filteredMessageTypes.has(message.type);
if (isFilteredMessage) {
this.debug.log(`🔇 FILTER: Processing filtered message type '${message.type}' from ${fromPeerId?.substring(0, 8)} (not emitted to UI)`);
}
// Route based on message type
switch (message.type) {
case 'gossip':
// Gossip protocol messages (async call, but we don't wait for it)
this.mesh.gossipManager.handleGossipMessage(message, fromPeerId).catch(error => {
this.debug.error('Error handling gossip message:', error);
});
break;
case 'eviction':
// Handle eviction notices
this.handleEvictionMessage(message, fromPeerId);
break;
case 'dht':
// WebDHT messages
if (this.mesh.webDHT) {
this.mesh.webDHT.handleMessage(message, fromPeerId);
}
break;
case 'renegotiation-offer':
// Handle renegotiation offers from peers
this.handleRenegotiationOffer(message, fromPeerId);
break;
case 'renegotiation-answer':
// Handle renegotiation answers from peers
this.handleRenegotiationAnswer(message, fromPeerId);
break;
case 'signaling':
// Handle wrapped signaling messages sent via mesh
this.debug.log(`🔄 MESH SIGNALING: Received ${message.data?.type} from ${fromPeerId?.substring(0, 8)}...`);
if (message.data && message.data.type) {
// Unwrap and handle the signaling message
const signalingMessage = {
type: message.data.type,
data: message.data.data,
fromPeerId: message.fromPeerId || fromPeerId,
targetPeerId: this.mesh.peerId,
timestamp: message.timestamp
};
// Route to signaling handler
this.mesh.signalingHandler.handleSignalingMessage(signalingMessage);
}
break;
case 'signaling-relay':
// Process signaling relay messages but don't emit as peer-readable
this.debug.log(`🔇 FILTER: Processing signaling-relay from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
// Handle the signaling relay internally - extract and process the actual signaling message
if (message.data && message.targetPeerId === this.mesh.peerId) {
this.mesh.signalingHandler.handleSignalingMessage({
type: message.data.type,
data: message.data.data,
fromPeerId: message.fromPeerId || fromPeerId,
targetPeerId: message.targetPeerId,
timestamp: message.timestamp
});
}
return; // Early return to prevent fallback to gossip handler
case 'peer-announce-relay':
// Process peer announce relay messages but don't emit as peer-readable
this.debug.log(`🔇 FILTER: Processing peer-announce-relay from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
// Handle the peer announcement internally
if (message.data && this.mesh.signalingHandler) {
this.mesh.signalingHandler.handlePeerAnnouncement(message.data, fromPeerId);
}
return; // Early return to prevent fallback to gossip handler
case 'bootstrap-keepalive':
// Process bootstrap keepalive messages but don't emit as peer-readable
this.debug.log(`🔇 FILTER: Processing bootstrap-keepalive from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
// Handle keepalive internally - update peer discovery timestamps
if (this.mesh.peerDiscovery) {
this.mesh.peerDiscovery.updateDiscoveryTimestamp(fromPeerId);
}
return; // Early return to prevent fallback to gossip handler
case 'client-peer-announcement':
// Process client peer announcement messages but don't emit as peer-readable
this.debug.log(`🔇 FILTER: Processing client-peer-announcement from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
// Handle client peer announcement internally
if (message.clientPeerId && this.mesh.signalingHandler) {
this.mesh.signalingHandler.handlePeerAnnouncement(message.clientPeerId);
}
return; // Early return to prevent fallback to gossip handler
case 'cross-bootstrap-signaling':
// Process cross-bootstrap signaling messages but don't emit as peer-readable
this.debug.log(`🔇 FILTER: Processing cross-bootstrap-signaling from ${fromPeerId?.substring(0, 8)} (filtered from UI)`);
// Handle cross-bootstrap signaling internally
if (message.originalMessage && message.targetPeerId === this.mesh.peerId && this.mesh.signalingHandler) {
// Extract and process the wrapped signaling message
this.mesh.signalingHandler.handleSignalingMessage({
type: message.originalMessage.type,
data: message.originalMessage.data,
fromPeerId: message.originalMessage.fromPeerId || fromPeerId,
targetPeerId: message.targetPeerId,
timestamp: message.originalMessage.timestamp || message.timestamp
});
}
return; // Early return to prevent fallback to gossip handler
default:
// For non-filtered message types, check if they should be emitted
if (!isFilteredMessage) {
// Unknown message type - try gossip as fallback for backward compatibility
this.debug.warn(`Unknown message type '${message.type}' from ${fromPeerId?.substring(0, 8)}, trying gossip handler`);
this.mesh.gossipManager.handleGossipMessage(message, fromPeerId).catch(error => {
this.debug.error('Error handling unknown message as gossip:', error);
});
} else {
// This is a filtered message type that doesn't have a specific handler
this.debug.log(`🔇 FILTER: Filtered message type '${message.type}' processed but not emitted`);
}
break;
}
}
/**
* Handle eviction messages
*/
handleEvictionMessage(message, fromPeerId) {
this.debug.log(`Received eviction notice from ${fromPeerId?.substring(0, 8)}: ${message.reason}`);
// Emit eviction event for UI notification
this.mesh.emit('peerEvicted', {
peerId: fromPeerId,
reason: message.reason,
initiatedByPeer: true
});
// Close the connection gracefully
const peerConnection = this.peers.get(fromPeerId);
if (peerConnection) {
peerConnection.close();
this.peers.delete(fromPeerId);
}
}
/**
* Perform a single renegotiation with conflict prevention
* @private
*/
async _performRenegotiation(peerConnection, event) {
const peerId = event.peerId;
// Mark this renegotiation as active
this.activeRenegotiations.add(peerId);
try {
this.debug.log(`🔄 ACTIVE: Starting renegotiation for ${peerId.substring(0, 8)}... (${this.activeRenegotiations.size} active)`);
// Check connection state - allow renegotiation for stable connections or those stuck in "have-local-offer"
const signalingState = peerConnection.connection.signalingState;
if (signalingState !== 'stable' && signalingState !== 'have-local-offer') {
this.debug.log(`Skipping renegotiation for ${peerId.substring(0, 8)}... - connection in unsupported state (${signalingState})`);
return;
}
// Additional check for connection state
if (peerConnection.connection.connectionState !== 'connected') {
this.debug.log(`Skipping renegotiation for ${peerId.substring(0, 8)}... - not connected (${peerConnection.connection.connectionState})`);
return;
}
this.debug.log(`🔄 Creating renegotiation offer for ${peerId.substring(0, 8)}... (signaling state: ${signalingState})`);
// Create new offer for renegotiation
const offer = await peerConnection.connection.createOffer();
this.debug.log('🔍 RENEGOTIATION OFFER SDP DEBUG:');
this.debug.log(` SDP length: ${offer.sdp.length}`);
this.debug.log(` Contains video: ${offer.sdp.includes('m=video')}`);
this.debug.log(` Contains audio: ${offer.sdp.includes('m=audio')}`);
await peerConnection.connection.setLocalDescription(offer);
// Send the renegotiation offer
await this.mesh.sendSignalingMessage({
type: 'renegotiation-offer',
data: offer
}, peerId);
this.debug.log(`✅ ACTIVE: Sent renegotiation offer to ${peerId.substring(0, 8)}...`);
} catch (error) {
this.debug.error(`❌ ACTIVE: Failed to renegotiate with ${peerId.substring(0, 8)}...`, error);
} finally {
// Always clean up and process queue
this.activeRenegotiations.delete(peerId);
this.debug.log(`🔄 ACTIVE: Completed renegotiation for ${peerId.substring(0, 8)}... (${this.activeRenegotiations.size} active)`);
// Process next queued renegotiation
this._processRenegotiationQueue();
}
}
/**
* Process the next renegotiation in the queue
* @private
*/
_processRenegotiationQueue() {
if (this.activeRenegotiations.size >= this.maxConcurrentRenegotiations || this.renegotiationQueue.size === 0) {
return;
}
// Get next queued renegotiation
const [nextPeerId, nextEvent] = this.renegotiationQueue.entries().next().value;
this.renegotiationQueue.delete(nextPeerId);
const peerConnection = this.peers.get(nextPeerId);
if (peerConnection) {
this.debug.log(`🔄 QUEUE: Processing queued renegotiation for ${nextPeerId.substring(0, 8)}...`);
this._performRenegotiation(peerConnection, nextEvent);
}
}
/**
* Handle renegotiation offers from peers
*/
async handleRenegotiationOffer(message, fromPeerId) {
this.debug.log(`🔄 Handling renegotiation offer via mesh from ${fromPeerId.substring(0, 8)}...`);
// Find the existing peer connection
const peerConnection = this.peers.get(fromPeerId);
if (!peerConnection) {
this.debug.error(`No peer connection found for renegotiation from ${fromPeerId.substring(0, 8)}...`);
return;
}
try {
// Handle the renegotiation offer and get the answer
const answer = await peerConnection.handleOffer(message.data);
this.debug.log(`✅ Renegotiation offer processed, sending answer to ${fromPeerId.substring(0, 8)}...`);
// Send the answer back to complete the renegotiation handshake
await this.mesh.sendSignalingMessage({
type: 'renegotiation-answer',
data: answer
}, fromPeerId);
this.debug.log(`✅ Renegotiation completed via mesh with ${fromPeerId.substring(0, 8)}...`);
} catch (error) {
this.debug.error(`❌ Failed to handle renegotiation offer via mesh from ${fromPeerId.substring(0, 8)}...`, error);
}
}
async handleRenegotiationAnswer(message, fromPeerId) {
this.debug.log(`🔄 Handling renegotiation answer via mesh from ${fromPeerId.substring(0, 8)}...`);
// Find the existing peer connection
const peerConnection = this.peers.get(fromPeerId);
if (!peerConnection) {
this.debug.error(`No peer connection found for renegotiation answer from ${fromPeerId.substring(0, 8)}...`);
return;
}
try {
// Handle the renegotiation answer
await peerConnection.handleAnswer(message.data);
this.debug.log(`✅ Renegotiation answer processed from ${fromPeerId.substring(0, 8)}... - renegotiation complete`);
} catch (error) {
this.debug.error(`❌ Failed to handle renegotiation answer via mesh from ${fromPeerId.substring(0, 8)}...`, error);
}
}
/**
* Monitor and fix stuck connections that remain in "have-local-offer" state
* This is called periodically to detect and fix connections that get stuck
*/
monitorAndFixStuckConnections() {
if (!this.mesh.connected) return;
const stuckConnections = [];
for (const [peerId, peerConnection] of this.peers) {
if (peerConnection.connection?.signalingState === 'have-local-offer') {
const connectionAge = Date.now() - peerConnection.connectionStartTime;
// If connection has been stuck in "have-local-offer" for more than 10 seconds, fix it
// This timeout balances between allowing time for signaling and detecting truly stuck connections
if (connectionAge > 10000) {
stuckConnections.push(peerId);
}
}
}
if (stuckConnections.length > 0) {
this.debug.log(`🚨 STUCK MONITOR: Found ${stuckConnections.length} stuck connections - forcing recovery`);
// Log a warning about local testing requirements
if (typeof window !== 'undefined' && window.location?.hostname === 'localhost') {
console.warn('⚠️ LOCAL TESTING: WebRTC connections on localhost require media permissions!');
console.warn(' Go to the Media tab and click "Start Media" to grant permissions.');
console.warn(' See docs/LOCAL_TESTING.md for details.');
}
for (const peerId of stuckConnections) {
this.forceConnectionRecovery(peerId).catch(error => {
this.debug.error(`Failed to recover stuck connection for ${peerId}:`, error);
});
}
}
}
/**
* Force recovery of a stuck connection by completely recreating it
*/
async forceConnectionRecovery(peerId) {
const peerConnection = this.getPeer(peerId);
if (!peerConnection) {
this.debug.error(`Cannot recover - peer ${peerId} not found`);
return null;
}
this.debug.log(`🔄 FORCE RECOVERY: Completely recreating connection for ${peerId.substring(0, 8)}...`);
try {
// Preserve the current media stream
const currentLocalStream = peerConnection.getLocalStream();
// Close the stuck connection
peerConnection.close();
// Remove from peers map
this.peers.delete(peerId);
// Create a completely fresh connection
const freshConnection = await this.connectToPeer(peerId, false, {
localStream: currentLocalStream
});
if (freshConnection && currentLocalStream) {
// Apply the media stream to the fresh connection
await freshConnection.setLocalStream(currentLocalStream);
this.debug.log(`✅ FORCE RECOVERY: Fresh connection created with media for ${peerId.substring(0, 8)}...`);
}
return freshConnection;
} catch (error) {
this.debug.error(`❌ FORCE RECOVERY: Failed to recreate connection for ${peerId.substring(0, 8)}...`, error);
throw error;
}
}
/**
* Start monitoring for stuck connections
*/
startStuckConnectionMonitoring() {
// Check every 2 seconds for stuck connections - much more responsive
setInterval(() => {
this.monitorAndFixStuckConnections();
}, 2000);
this.debug.log('🔍 Started stuck connection monitoring');
}
/**
* Forward a received stream to all other connected peers (except the sender)
* This implements media forwarding through the mesh topology
* @param {MediaStream} stream - The stream to forward
* @param {string} sourcePeerId - The peer ID that sent the stream (don't forward back to them)
* @private
*/
async _forwardStreamToOtherPeers(stream, sourcePeerId) {
if (!stream || !sourcePeerId) {
this.debug.warn('Cannot forward stream - invalid parameters');
return;
}
this.debug.log(`🔄 FORWARD STREAM: Forwarding stream from ${sourcePeerId.substring(0, 8)}... to other connected peers`);
// Get the original source peer ID from the stream metadata
const originalSourcePeerId = stream._peerPigeonSourcePeerId || sourcePeerId;
// Count how many peers we're forwarding to
let forwardCount = 0;
// Forward to all connected peers except the source and the original sender
for (const [peerId, connection] of this.peers) {
// Skip the peer who sent us the stream
if (peerId === sourcePeerId) {
this.debug.log(`🔄 FORWARD STREAM: Skipping source peer ${peerId.substring(0, 8)}...`);
continue;
}
// Skip the original peer who created the stream (to prevent loops)
if (peerId === originalSourcePeerId) {
this.debug.log(`🔄 FORWARD STREAM: Skipping original stream creator ${peerId.substring(0, 8)}...`);
continue;
}
// Only forward to connected peers
if (connection.getStatus() !== 'connected') {
this.debug.log(`🔄 FORWARD STREAM: Skipping disconnected peer ${peerId.substring(0, 8)}...`);
continue;
}
try {
this.debug.log(`🔄 FORWARD STREAM: Setting forwarded stream for peer ${peerId.substring(0, 8)}...`);
// CRITICAL: Clone the stream to avoid conflicts
const forwardedStream = stream.clone();
// Mark the forwarded stream with original source information
Object.defineProperty(forwardedStream, '_peerPigeonSourcePeerId', {
value: originalSourcePeerId,
writable: false,
enumerable: false,
configurable: false
});
Object.defineProperty(forwardedStream, '_peerPigeonOrigin', {
value: 'forwarded',
writable: false,
enumerable: false,
configurable: false
});
// Set the cloned stream as the local stream for this connection
await connection.setLocalStream(forwardedStream);
forwardCount++;
this.debug.log(`✅ FORWARD STREAM: Successfully forwarded stream to peer ${peerId.substring(0, 8)}...`);
} catch (error) {
this.debug.error(`❌ FORWARD STREAM: Failed to forward stream to peer ${peerId.substring(0, 8)}...`, error);
}
}
this.debug.log(`🔄 FORWARD STREAM: Forwarded stream from ${sourcePeerId.substring(0, 8)}... to ${forwardCount} peer(s)`);
}
}