UNPKG

peerpigeon

Version:

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

1,357 lines (1,142 loc) 87.3 kB
import { EventEmitter } from './EventEmitter.js'; import { SignalingClient } from './SignalingClient.js'; import { PeerDiscovery } from './PeerDiscovery.js'; import { ConnectionManager } from './ConnectionManager.js'; import { SignalingHandler } from './SignalingHandler.js'; import { EvictionManager } from './EvictionManager.js'; import { MeshOptimizer } from './MeshOptimizer.js'; import { CleanupManager } from './CleanupManager.js'; import { StorageManager } from './StorageManager.js'; import { GossipManager } from './GossipManager.js'; import { MediaManager } from './MediaManager.js'; import { WebDHT } from './WebDHT.js'; import { CryptoManager } from './CryptoManager.js'; import { DistributedStorageManager } from './DistributedStorageManager.js'; import { environmentDetector } from './EnvironmentDetector.js'; import DebugLogger from './DebugLogger.js'; export class PeerPigeonMesh extends EventEmitter { constructor(options = {}) { super(); this.debug = DebugLogger.create('PeerPigeonMesh'); // Validate environment capabilities this.environmentReport = this.validateEnvironment(options); this.peerId = null; this.providedPeerId = options.peerId || null; this.signalingClient = null; this.peerDiscovery = null; // Network namespace configuration this.networkName = options.networkName || 'global'; this.allowGlobalFallback = options.allowGlobalFallback !== false; // Default to true this.isInFallbackMode = false; this.originalNetworkName = this.networkName; // Configuration - Optional features enabled by default (opt-out) this.maxPeers = options.maxPeers !== undefined ? options.maxPeers : 3; this.minPeers = options.minPeers !== undefined ? options.minPeers : 2; // Connectivity floor: ensure each peer obtains at least this many connections for robust gossip // Default to 3 across browsers (capped by maxPeers) for consistent propagation const defaultFloor = Math.min(3, this.maxPeers ?? 3); this.connectivityFloor = options.connectivityFloor !== undefined ? options.connectivityFloor : defaultFloor; this.autoConnect = options.autoConnect !== false; // Default to true, can be disabled by setting to false this.autoDiscovery = options.autoDiscovery !== false; this.evictionStrategy = options.evictionStrategy !== false; this.xorRouting = options.xorRouting !== false; this.enableWebDHT = options.enableWebDHT !== false; // Default to true, can be disabled by setting to false this.enableCrypto = options.enableCrypto !== false; // Default to true, can be disabled by setting to false // State this.connected = false; this.polling = false; // Only WebSocket is supported this.signalingUrl = null; this.discoveredPeers = new Map(); // Track ongoing key exchange attempts to prevent duplicates across all channels this.ongoingKeyExchanges = new Set(); // Track peers for which we've already emitted peerKeyAdded events to prevent UI spam this.emittedPeerKeyEvents = new Set(); // Gossip stream tracking this.gossipStreams = new Map(); // streamId -> { chunks: Map, metadata, controller, totalChunks } // Initialize managers this.storageManager = new StorageManager(this); this.mediaManager = new MediaManager(); this.connectionManager = new ConnectionManager(this); this.evictionManager = new EvictionManager(this, this.connectionManager); this.meshOptimizer = new MeshOptimizer(this, this.connectionManager, this.evictionManager); this.cleanupManager = new CleanupManager(this); this.signalingHandler = new SignalingHandler(this, this.connectionManager); this.gossipManager = new GossipManager(this, this.connectionManager); this.webDHT = null; // Will be initialized after peerId is set this.distributedStorage = null; // Will be initialized after WebDHT is set // Initialize crypto manager if enabled this.cryptoManager = null; if (this.enableCrypto) { this.cryptoManager = new CryptoManager(); } // Set up inter-module event forwarding this.setupManagerEventHandlers(); // Set up unload handlers this.cleanupManager.setupUnloadHandlers(); // Load saved signaling URL immediately this.storageManager.loadSignalingUrlFromStorage(); // Connectivity enforcement timer handle this._connectivityEnforcementTimer = null; } setupManagerEventHandlers() { // Forward events from managers to main mesh this.connectionManager.addEventListener('peersUpdated', () => { this.emit('peersUpdated'); }); // Handle peer disconnections this.addEventListener('peerDisconnected', (data) => { this.debug.log(`Peer ${data.peerId.substring(0, 8)}... disconnected: ${data.reason}`); // Clear tracking for this peer to allow fresh key exchange if they reconnect this.emittedPeerKeyEvents.delete(data.peerId); this.ongoingKeyExchanges.delete(data.peerId); }); // Handle gossip messages and intercept mesh signaling this.gossipManager.addEventListener('messageReceived', (data) => { // Check if this is a mesh signaling message if (data.message && data.message.type === 'mesh_signaling') { this._handleMeshSignalingMessage(data.message, data.from); return; // Don't emit as regular message } // Handle crypto key exchange messages if (data.message && (data.message.type === 'key_exchange' || data.message.type === 'key_exchange_response')) { this._handleKeyExchange(data.message, data.from).catch(err => { this.debug.error('Key exchange handling failed:', err); }); return; // Don't emit as regular message } // Handle DHT messages - route to WebDHT if (data.message && data.message.type === 'dht' && this.webDHT) { this.webDHT.handleMessage(data.message, data.from); return; // Don't emit as regular message } // Handle gossip stream chunks if (data.message && data.message.type === 'stream-chunk') { this._handleGossipStreamChunk(data.message, data.from); return; // Don't emit as regular message } // Handle gossip stream control messages if (data.message && data.message.type === 'stream-control') { this._handleGossipStreamControl(data.message, data.from); return; // Don't emit as regular message } // Additional safety filter for message types that should not be emitted to UI // These should already be filtered at the GossipManager level, but this provides defense in depth if (data.content && typeof data.content === 'string') { try { const parsedContent = JSON.parse(data.content); const filteredTypes = ['signaling-relay', 'peer-announce-relay', 'bootstrap-keepalive', 'client-peer-announcement', 'cross-bootstrap-signaling']; if (filteredTypes.includes(parsedContent.type)) { console.debug(`🔇 MESH FILTER: Blocked filtered message type '${parsedContent.type}' from UI emission`); return; // Don't emit to UI } } catch (e) { // Not JSON, continue normally } } this.emit('messageReceived', data); }); // CRITICAL: Handle remote stream announcements from gossip // When we hear about a stream from an indirectly connected peer, // establish a direct connection to receive the media this.addEventListener('remoteStreamAnnouncement', (data) => { this._handleRemoteStreamAnnouncement(data); }); // Forward media events this.mediaManager.addEventListener('localStreamStarted', (data) => { this.emit('localStreamStarted', data); // Gossip stream start announcement to all peers in the mesh this.gossipManager.broadcastMessage({ event: 'streamStarted', peerId: this.peerId, hasVideo: data.hasVideo, hasAudio: data.hasAudio, timestamp: Date.now() }, 'mediaEvent').catch(err => { this.debug.error('Failed to broadcast stream started event:', err); }); }); this.mediaManager.addEventListener('localStreamStopped', () => { this.emit('localStreamStopped'); // Gossip stream stop announcement to all peers in the mesh this.gossipManager.broadcastMessage({ event: 'streamStopped', peerId: this.peerId, timestamp: Date.now() }, 'mediaEvent').catch(err => { this.debug.error('Failed to broadcast stream stopped event:', err); }); }); this.mediaManager.addEventListener('error', (data) => { this.emit('mediaError', data); }); // Forward remote stream events from ConnectionManager this.connectionManager.addEventListener('remoteStream', (data) => { this.emit('remoteStream', data); }); // Handle crypto events if crypto is enabled if (this.cryptoManager) { this.cryptoManager.addEventListener('cryptoReady', (data) => { this.emit('cryptoReady', data); }); this.cryptoManager.addEventListener('cryptoError', (data) => { this.emit('cryptoError', data); }); this.cryptoManager.addEventListener('peerKeyAdded', (data) => { // Only emit peerKeyAdded event once per peer to prevent UI spam from duplicate key exchanges if (!this.emittedPeerKeyEvents.has(data.peerId)) { this.emittedPeerKeyEvents.add(data.peerId); this.emit('peerKeyAdded', data); } }); this.cryptoManager.addEventListener('userAuthenticated', (data) => { this.emit('userAuthenticated', data); }); } } validateEnvironment(options = {}) { const report = environmentDetector.getEnvironmentReport(); const warnings = []; const errors = []; // Log environment info this.debug.log('🔍 PeerPigeon Environment Detection:', { runtime: `${report.runtime.isBrowser ? 'Browser' : ''}${report.runtime.isNodeJS ? 'Node.js' : ''}${report.runtime.isWorker ? 'Worker' : ''}${report.runtime.isNativeScript ? 'NativeScript' : ''}`, webrtc: report.capabilities.webrtc, websocket: report.capabilities.webSocket, browser: report.browser?.name || 'N/A', nativescript: report.nativescript?.platform || 'N/A' }); // Check WebRTC support (required for peer connections) if (!report.capabilities.webrtc) { if (report.runtime.isBrowser) { errors.push('WebRTC is not supported in this browser. PeerPigeon requires WebRTC for peer-to-peer connections.'); } else if (report.runtime.isNodeJS) { warnings.push('WebRTC support not detected in Node.js environment. PeerPigeon includes @koush/wrtc for automatic WebRTC support - ensure it is properly installed.'); } else if (report.runtime.isNativeScript) { warnings.push('WebRTC support not detected in NativeScript environment. Consider using a native WebRTC plugin.'); } } // Check WebSocket support (required for signaling) if (!report.capabilities.webSocket) { if (report.runtime.isBrowser) { errors.push('WebSocket is not supported in this browser. PeerPigeon requires WebSocket for signaling.'); } else if (report.runtime.isNodeJS) { warnings.push('WebSocket support not detected. Install the "ws" package for WebSocket support in Node.js.'); } else if (report.runtime.isNativeScript) { warnings.push('WebSocket support not detected. Consider using a native WebSocket plugin or polyfill.'); } } // Check storage capabilities for persistent peer ID if ((report.runtime.isBrowser || report.runtime.isNativeScript) && !report.capabilities.localStorage && !report.capabilities.sessionStorage) { warnings.push('No storage mechanism available. Peer ID will not persist between sessions.'); } // Check crypto support for secure peer ID generation if (!report.capabilities.randomValues) { warnings.push('Crypto random values not available. Peer ID generation may be less secure.'); } // Network connectivity checks if (report.runtime.isBrowser && !report.network.online) { warnings.push('Browser reports offline status. Mesh networking may not function properly.'); } // Environment-specific warnings if (report.runtime.isBrowser) { // Browser-specific checks const browser = report.browser; if (browser && browser.name === 'ie') { errors.push('Internet Explorer is not supported. Please use a modern browser.'); } // Check for secure context in production if (typeof location !== 'undefined' && location.protocol === 'http:' && location.hostname !== 'localhost') { warnings.push('Running on HTTP in production. Some WebRTC features may be limited. Consider using HTTPS.'); } } if (report.runtime.isNativeScript) { // NativeScript-specific checks const nativeScript = report.nativescript; if (nativeScript && nativeScript.platform) { this.debug.log(`🔮 Running on NativeScript ${nativeScript.platform} platform`); // Platform-specific considerations if (nativeScript.platform === 'android') { warnings.push('Android WebRTC may require network permissions and appropriate security configurations.'); } else if (nativeScript.platform === 'ios' || nativeScript.platform === 'visionos') { warnings.push('iOS/visionOS WebRTC may require camera/microphone permissions for media features.'); } } } // Handle errors and warnings if (errors.length > 0) { const errorMessage = 'PeerPigeon environment validation failed:\n' + errors.join('\n'); this.debug.error(errorMessage); if (!options.ignoreEnvironmentErrors) { throw new Error(errorMessage); } } if (warnings.length > 0) { this.debug.warn('PeerPigeon environment warnings:\n' + warnings.join('\n')); } // Store capabilities for runtime checks this.capabilities = report.capabilities; this.runtimeInfo = report.runtime; return report; } async init() { try { // Initialize PigeonRTC for cross-platform WebRTC support try { const webrtcInitialized = await environmentDetector.initWebRTCAsync(); if (webrtcInitialized) { const adapterName = environmentDetector.getPigeonRTC()?.getAdapterName(); this.debug.log(`🌐 PigeonRTC initialized successfully (${adapterName})`); } } catch (error) { this.debug.warn('PigeonRTC initialization failed:', error.message); } // Request media permissions for localhost WebRTC to work unless explicitly disabled // Added DISABLE_LOCALHOST_MEDIA_REQUEST override for automated/headless test environments if (environmentDetector.isBrowser) { try { const disableLocalhostMedia = typeof window !== 'undefined' && window.DISABLE_LOCALHOST_MEDIA_REQUEST; const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.hostname === ''; if (!disableLocalhostMedia && isLocalhost && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { this.debug.log('🎤 Requesting media permissions for localhost WebRTC (not disabled)...'); // Request minimal audio permission (no video to reduce intrusiveness) const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }).catch(err => { this.debug.warn('Media permission denied - connections may fail on localhost:', err.message); return null; }); // Immediately stop all tracks - we only needed the permission if (stream) { stream.getTracks().forEach(track => track.stop()); this.debug.log('✅ Media permissions granted - localhost connections will work'); } } else if (disableLocalhostMedia) { this.debug.log('🚫 Skipping localhost media permission request (DISABLE_LOCALHOST_MEDIA_REQUEST flag set)'); } } catch (error) { this.debug.warn('Could not request media permissions:', error.message); // Continue anyway - non-localhost environments don't need this } } // Use provided peer ID if valid, otherwise generate one if (this.providedPeerId) { if (PeerPigeonMesh.validatePeerId(this.providedPeerId)) { this.peerId = this.providedPeerId; this.debug.log(`Using provided peer ID: ${this.peerId}`); } else { this.debug.warn(`Invalid peer ID provided: ${this.providedPeerId}. Must be 40-character SHA-1 hex string. Generating new one.`); this.peerId = await PeerPigeonMesh.generatePeerId(); } } else { this.peerId = await PeerPigeonMesh.generatePeerId(); } // Initialize WebDHT now that we have a peerId (if enabled) if (this.enableWebDHT) { // Initialize WebDHT - Low-level distributed hash table for raw key-value storage this.webDHT = new WebDHT(this); this.debug.log('WebDHT (low-level DHT) initialized and enabled'); // Setup WebDHT event handlers now that it's initialized this.setupWebDHTEventHandlers(); // Initialize DistributedStorageManager - High-level storage with encryption/access control // Note: This uses WebDHT as its storage backend but provides a separate high-level API this.distributedStorage = new DistributedStorageManager(this); this.debug.log('DistributedStorageManager (high-level encrypted storage) initialized'); // Setup DistributedStorageManager event handlers this.setupDistributedStorageEventHandlers(); } else { this.debug.log('WebDHT disabled by configuration'); } // Load signaling URL from query params or storage const savedUrl = this.storageManager.loadSignalingUrlFromQuery(); if (savedUrl) { this.signalingUrl = savedUrl; } this.signalingClient = new SignalingClient(this.peerId, this.maxPeers, this); this.setupSignalingHandlers(); this.peerDiscovery = new PeerDiscovery(this.peerId, { autoDiscovery: this.autoDiscovery, evictionStrategy: this.evictionStrategy, xorRouting: this.xorRouting, minPeers: this.minPeers, maxPeers: this.maxPeers }); this.setupDiscoveryHandlers(); // Start connectivity enforcement loop once discovery is ready this.startConnectivityEnforcement(); // Initialize crypto manager if enabled if (this.cryptoManager) { try { this.debug.log('🔐 Initializing crypto manager with automatic key persistence...'); // Add timeout to prevent hanging const cryptoInitPromise = this.cryptoManager.initWithPeerId(this.peerId); const timeoutPromise = new Promise((resolve, reject) => { setTimeout(() => reject(new Error('Crypto initialization timeout')), 10000); }); await Promise.race([cryptoInitPromise, timeoutPromise]); this.debug.log('🔐 Crypto manager initialized successfully with persistent keys'); } catch (error) { this.debug.error('Failed to initialize crypto manager:', error); // Continue without crypto - don't fail the entire init this.enableCrypto = false; this.cryptoManager = null; } } this.emit('statusChanged', { type: 'initialized', peerId: this.peerId }); } catch (error) { this.debug.error('Failed to initialize mesh:', error); this.emit('statusChanged', { type: 'error', message: `Initialization failed: ${error.message}` }); throw error; } } setupSignalingHandlers() { this.signalingClient.addEventListener('connected', () => { this.connected = true; this.polling = false; this.peerDiscovery.start(); this.emit('statusChanged', { type: 'connected' }); }); // IMPORTANT: When the WebSocket signaling connection is lost, we do NOT disconnect peers! // WebRTC connections are peer-to-peer and remain active even without the signaling server. // Peers can continue to communicate directly through their existing WebRTC data channels. // The signaling server is only needed for: // 1. Initial peer discovery and connection negotiation // 2. Discovering new peers that join the network // Once peers are connected via WebRTC, they are independent of the signaling server. this.signalingClient.addEventListener('disconnected', () => { this.connected = false; this.polling = false; this.peerDiscovery.stop(); // Don't disconnect peers - WebRTC connections are peer-to-peer and persist without signaling server this.emit('statusChanged', { type: 'disconnected' }); }); this.signalingClient.addEventListener('signalingMessage', (message) => { this.signalingHandler.handleSignalingMessage(message); }); this.signalingClient.addEventListener('statusChanged', (data) => { this.emit('statusChanged', data); }); } setupDiscoveryHandlers() { this.peerDiscovery.addEventListener('peerDiscovered', (data) => { this.emit('peerDiscovered', data); // Check if we should return from global fallback to original network if (this.isInFallbackMode && this.originalNetworkName !== 'global') { this._tryReturnToOriginalNetwork(); } }); this.peerDiscovery.addEventListener('connectToPeer', (data) => { this.debug.log(`PeerDiscovery requested connection to: ${data.peerId.substring(0, 8)}...`); this.connectionManager.connectToPeer(data.peerId); }); this.peerDiscovery.addEventListener('evictPeer', (data) => { this.evictionManager.evictPeer(data.peerId, data.reason); }); this.peerDiscovery.addEventListener('optimizeMesh', () => { this.peerDiscovery.optimizeMeshConnections(this.connectionManager.peers); }); this.peerDiscovery.addEventListener('optimizeConnections', (data) => { this.meshOptimizer.handleOptimizeConnections(data.unconnectedPeers); }); // Monitor network health and activate fallback if needed this.addEventListener('peersUpdated', () => { this._checkNetworkHealth(); }); this.peerDiscovery.addEventListener('peersUpdated', (data) => { this.emit('statusChanged', { type: 'info', message: `Cleaned up ${data.removedCount} stale peer(s)` }); this.emit('peersUpdated'); }); // Handle capacity checks this.peerDiscovery.addEventListener('checkCapacity', () => { const canAccept = this.connectionManager.canAcceptMorePeers(); const currentConnectionCount = this.connectionManager.getConnectedPeerCount(); this.debug.log(`Capacity check: ${canAccept} (${currentConnectionCount}/${this.maxPeers} peers)`); this.peerDiscovery._canAcceptMorePeers = canAccept; this.peerDiscovery._currentConnectionCount = currentConnectionCount; }); // Handle eviction checks this.peerDiscovery.addEventListener('checkEviction', (data) => { const evictPeerId = this.evictionManager.shouldEvictForPeer(data.newPeerId); this.debug.log(`Eviction check for ${data.newPeerId.substring(0, 8)}...: ${evictPeerId ? evictPeerId.substring(0, 8) + '...' : 'none'}`); this.peerDiscovery._shouldEvictForPeer = evictPeerId; }); } async connect(signalingUrl) { this.signalingUrl = signalingUrl; this.storageManager.saveSignalingUrlToStorage(signalingUrl); this.polling = false; // Only WebSocket is supported // Don't emit connecting here - SignalingClient will handle it with more detail try { await this.signalingClient.connect(signalingUrl); } catch (error) { this.debug.error('Connection failed:', error); this.polling = false; this.emit('statusChanged', { type: 'error', message: `Connection failed: ${error.message}` }); throw error; } } disconnect() { if (this.connected) { this.cleanupManager.sendGoodbyeMessage(); } this.connected = false; this.polling = false; if (this.signalingClient) { this.signalingClient.disconnect(); } if (this.peerDiscovery) { this.peerDiscovery.cleanup(); // Use cleanup() instead of stop() for full cleanup } if (this._connectivityEnforcementTimer) { clearInterval(this._connectivityEnforcementTimer); this._connectivityEnforcementTimer = null; } this.connectionManager.disconnectAllPeers(); this.connectionManager.cleanup(); // WebDHT persists in the mesh - no cleanup needed on disconnect this.evictionManager.cleanup(); this.cleanupManager.cleanup(); this.gossipManager.cleanup(); this.emit('statusChanged', { type: 'disconnected' }); } /** * Periodically attempt extra connections for under-connected peers until reaching connectivityFloor. */ startConnectivityEnforcement() { if (this._connectivityEnforcementTimer) return; const intervalMs = 4000; // Slower enforcement to avoid connection storms this._connectivityEnforcementTimer = setInterval(() => { if (!this.connected || !this.peerDiscovery) return; const connectedCount = this.connectionManager.getConnectedPeerCount(); if (connectedCount >= this.connectivityFloor) return; // met floor // Gather candidate peer IDs const discovered = this.peerDiscovery.getDiscoveredPeers().map(p => p.peerId); const notConnected = discovered.filter(pid => !this.connectionManager.hasPeer(pid) && !this.peerDiscovery.isAttemptingConnection(pid) ); if (notConnected.length === 0) return; // Prioritize by XOR distance for diversity and stability const prioritized = notConnected.sort((a, b) => { const distA = this.peerDiscovery.calculateXorDistance(this.peerId, a); const distB = this.peerDiscovery.calculateXorDistance(this.peerId, b); return distA < distB ? -1 : 1; }); // Conservative: attempt exactly what's needed, one at a time const needed = this.connectivityFloor - connectedCount; const attemptLimit = Math.min(prioritized.length, Math.max(1, needed)); const batch = prioritized.slice(0, attemptLimit); batch.forEach(pid => { this.debug.log(`🔧 CONNECTIVITY FLOOR (${connectedCount}/${this.connectivityFloor}) attempting extra connection to ${pid.substring(0,8)}...`); // Use normal connect, but with initiator override below floor this.connectionManager.connectToPeer(pid); }); }, intervalMs); } // Configuration methods setMaxPeers(maxPeers) { this.maxPeers = Math.max(1, Math.min(50, maxPeers)); if (this.connectionManager.peers.size > this.maxPeers) { this.evictionManager.disconnectExcessPeers(); } return this.maxPeers; } setMinPeers(minPeers) { this.minPeers = Math.max(0, Math.min(49, minPeers)); // If we're below minimum and auto-discovery is enabled, trigger optimization if (this.connectionManager.getConnectedPeerCount() < this.minPeers && this.autoDiscovery && this.connected) { this.peerDiscovery.optimizeMeshConnections(this.connectionManager.peers); } return this.minPeers; } setXorRouting(enabled) { this.xorRouting = enabled; this.emit('statusChanged', { type: 'setting', setting: 'xorRouting', value: enabled }); // If XOR routing is disabled, we might need to adjust our connection strategy if (!enabled && this.evictionStrategy) { this.emit('statusChanged', { type: 'warning', message: 'XOR routing disabled - eviction strategy effectiveness reduced' }); } } setAutoDiscovery(enabled) { this.autoDiscovery = enabled; this.emit('statusChanged', { type: 'setting', setting: 'autoDiscovery', value: enabled }); } setAutoConnect(enabled) { this.autoConnect = enabled; this.emit('statusChanged', { type: 'setting', setting: 'autoConnect', value: enabled }); } setEvictionStrategy(enabled) { this.evictionStrategy = enabled; this.emit('statusChanged', { type: 'setting', setting: 'evictionStrategy', value: enabled }); } // Network namespace management methods setNetworkName(networkName) { if (this.connected) { throw new Error('Cannot change network name while connected. Disconnect first.'); } this.networkName = networkName || 'global'; this.originalNetworkName = this.networkName; this.isInFallbackMode = false; this.emit('statusChanged', { type: 'setting', setting: 'networkName', value: this.networkName }); return this.networkName; } getNetworkName() { return this.networkName; } getOriginalNetworkName() { return this.originalNetworkName; } isUsingGlobalFallback() { return this.isInFallbackMode; } setAllowGlobalFallback(allow) { this.allowGlobalFallback = allow; this.emit('statusChanged', { type: 'setting', setting: 'allowGlobalFallback', value: allow }); // If we're currently in fallback mode and fallback is disabled, try to return to original network if (!allow && this.isInFallbackMode) { this._tryReturnToOriginalNetwork(); } return this.allowGlobalFallback; } async _tryReturnToOriginalNetwork() { if (!this.isInFallbackMode || this.originalNetworkName === 'global') { return; } // Check if there are now peers in the original network const originalNetworkPeerCount = await this._getNetworkPeerCount(this.originalNetworkName); if (originalNetworkPeerCount > 0) { this.debug.log(`Returning from global fallback to original network: ${this.originalNetworkName}`); this.networkName = this.originalNetworkName; this.isInFallbackMode = false; this.emit('statusChanged', { type: 'network', message: `Returned to network: ${this.networkName}`, networkName: this.networkName, fallbackMode: false }); // Trigger reconnection to rebuild mesh in correct network if (this.connected) { this.disconnect(); setTimeout(() => { if (this.signalingUrl) { this.connect(this.signalingUrl); } }, 1000); } } } async _activateGlobalFallback() { if (this.originalNetworkName === 'global' || this.isInFallbackMode || !this.allowGlobalFallback) { return false; } this.debug.log(`Activating global fallback from network: ${this.originalNetworkName}`); this.networkName = 'global'; this.isInFallbackMode = true; this.emit('statusChanged', { type: 'network', message: `Fallback to global network from: ${this.originalNetworkName}`, networkName: this.networkName, originalNetwork: this.originalNetworkName, fallbackMode: true }); return true; } async _getNetworkPeerCount(networkName) { // This would need to be implemented with signaling server support // For now, return 0 to indicate we can't determine peer count return 0; } _checkNetworkHealth() { // TEMPORARILY DISABLED - aggressive fallback for debugging return; if (this.originalNetworkName === 'global' || !this.allowGlobalFallback) { return; } const connectedCount = this.connectionManager.getConnectedPeerCount(); const discoveredCount = this.discoveredPeers.size; // If we're in the original network but have insufficient peers, activate fallback if (!this.isInFallbackMode && this.networkName === this.originalNetworkName) { if (connectedCount === 0 && discoveredCount === 0) { this.debug.log(`Network ${this.originalNetworkName} appears empty, activating global fallback`); this._activateGlobalFallback().then(activated => { if (activated && this.connected && this.signalingUrl) { // Reconnect to signaling server with global network this.disconnect(); setTimeout(() => { this.connect(this.signalingUrl); }, 1000); } }); } } } // Status and information methods getStatus() { const connectedCount = this.connectionManager.getConnectedPeerCount(); const totalCount = this.connectionManager.peers.size; return { peerId: this.peerId, connected: this.connected, polling: false, // Only WebSocket is supported signalingUrl: this.signalingUrl, networkName: this.networkName, originalNetworkName: this.originalNetworkName, isInFallbackMode: this.isInFallbackMode, allowGlobalFallback: this.allowGlobalFallback, connectedCount, totalPeerCount: totalCount, // Include total count for debugging minPeers: this.minPeers, maxPeers: this.maxPeers, discoveredCount: this.discoveredPeers.size, autoConnect: this.autoConnect, autoDiscovery: this.autoDiscovery, evictionStrategy: this.evictionStrategy, xorRouting: this.xorRouting }; } getPeers() { return this.connectionManager.getPeers(); } getPeerStatus(peerConnection) { return peerConnection.getStatus(); } getDiscoveredPeers() { if (!this.peerDiscovery) { return []; } const discoveredPeers = this.peerDiscovery.getDiscoveredPeers(); // Enrich with connection state from the actual peer connections return discoveredPeers.map(peer => { const peerConnection = this.connectionManager.getPeer(peer.peerId); let isConnected = false; if (peerConnection) { const status = peerConnection.getStatus(); // Consider peer connected if WebRTC connection is established isConnected = status === 'connected' || status === 'channel-connecting'; } return { ...peer, isConnected }; }); } /** * Send a direct message to a specific peer via gossip routing * @param {string} targetPeerId - The destination peer's ID * @param {string|object} content - The message content * @returns {string|null} The message ID if sent, or null on error */ async sendDirectMessage(targetPeerId, content) { if (!targetPeerId || typeof targetPeerId !== 'string') { this.debug.error('Invalid targetPeerId for direct message'); return null; } return await this.gossipManager.sendDirectMessage(targetPeerId, content); } /** * Send a broadcast (gossip) message to all peers * @param {string|object} content - The message content * @returns {string|null} The message ID if sent, or null on error */ async sendMessage(content) { // For clarity, this is a broadcast/gossip message return await this.gossipManager.broadcastMessage(content, 'chat'); } /** * Send binary data to a specific peer * @param {string} targetPeerId - The destination peer's ID * @param {Uint8Array|ArrayBuffer} binaryData - The binary data to send * @returns {boolean} True if sent successfully */ async sendBinaryData(targetPeerId, binaryData) { if (!targetPeerId || typeof targetPeerId !== 'string') { this.debug.error('Invalid targetPeerId for binary message'); return false; } const peerConnection = this.connectionManager.peers.get(targetPeerId); if (!peerConnection) { this.debug.error(`No connection to peer ${targetPeerId.substring(0, 8)}...`); return false; } return peerConnection.sendMessage(binaryData); } /** * Broadcast binary data to all connected peers * @param {Uint8Array|ArrayBuffer} binaryData - The binary data to broadcast * @returns {number} Number of peers the data was sent to */ async broadcastBinaryData(binaryData) { const peers = this.getConnectedPeers(); let sentCount = 0; for (const peer of peers) { if (await this.sendBinaryData(peer.peerId, binaryData)) { sentCount++; } } this.debug.log(`📦 Binary data broadcasted to ${sentCount}/${peers.length} peers`); return sentCount; } /** * Create a writable stream to send data to a specific peer * @param {string} targetPeerId - The destination peer's ID * @param {object} options - Stream options (filename, mimeType, totalSize, etc.) * @returns {WritableStream} A writable stream */ createStreamToPeer(targetPeerId, options = {}) { if (!targetPeerId || typeof targetPeerId !== 'string') { throw new Error('Invalid targetPeerId for stream'); } const peerConnection = this.connectionManager.peers.get(targetPeerId); if (!peerConnection) { throw new Error(`No connection to peer ${targetPeerId.substring(0, 8)}...`); } return peerConnection.createWritableStream(options); } /** * Send a ReadableStream to a peer * @param {string} targetPeerId - The destination peer's ID * @param {ReadableStream} readableStream - The stream to send * @param {object} options - Stream options (filename, mimeType, etc.) * @returns {Promise<void>} */ async sendStream(targetPeerId, readableStream, options = {}) { const writableStream = this.createStreamToPeer(targetPeerId, options); try { await readableStream.pipeTo(writableStream); this.debug.log(`✅ Stream sent successfully to ${targetPeerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`❌ Failed to send stream to ${targetPeerId.substring(0, 8)}...:`, error); throw error; } } /** * Send a File to a peer using streams * @param {string} targetPeerId - The destination peer's ID * @param {File} file - The file to send * @returns {Promise<void>} */ async sendFile(targetPeerId, file) { this.debug.log(`📁 Sending file "${file.name}" (${file.size} bytes) to ${targetPeerId.substring(0, 8)}...`); const options = { filename: file.name, mimeType: file.type, totalSize: file.size, type: 'file' }; const stream = file.stream(); await this.sendStream(targetPeerId, stream, options); } /** * Send a Blob to a peer using streams * @param {string} targetPeerId - The destination peer's ID * @param {Blob} blob - The blob to send * @param {object} options - Additional options * @returns {Promise<void>} */ async sendBlob(targetPeerId, blob, options = {}) { this.debug.log(`📦 Sending blob (${blob.size} bytes) to ${targetPeerId.substring(0, 8)}...`); const streamOptions = { ...options, mimeType: blob.type, totalSize: blob.size, type: 'blob' }; const stream = blob.stream(); await this.sendStream(targetPeerId, stream, streamOptions); } /** * Create a writable stream that broadcasts data to all peers in the mesh using gossip * @param {object} options - Stream options (filename, mimeType, totalSize, etc.) * @returns {WritableStream} A writable stream that broadcasts via gossip protocol */ createBroadcastStream(options = {}) { const streamId = options.streamId || this._generateStreamId(); const metadata = { streamId, type: options.type || 'broadcast', filename: options.filename, mimeType: options.mimeType, totalSize: options.totalSize, timestamp: Date.now(), ...options }; // Store chunks locally for gossip distribution const chunks = []; let chunkIndex = 0; let totalBytesWritten = 0; const self = this; this.debug.log(`📡 Creating gossip broadcast stream: ${streamId}`); return new WritableStream({ async write(chunk) { const chunkData = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); const chunkId = chunkIndex++; // Store chunk chunks.push({ index: chunkId, data: chunkData }); totalBytesWritten += chunkData.length; // Broadcast chunk via gossip protocol try { await self.gossipManager.broadcastMessage({ streamId, chunkIndex: chunkId, data: Array.from(chunkData), // Convert to regular array for JSON totalSize: metadata.totalSize, metadata: chunkId === 0 ? metadata : undefined // Include metadata with first chunk }, 'stream-chunk'); self.debug.log(`📡 Gossiped chunk ${chunkId} (${chunkData.length} bytes) for stream ${streamId.substring(0, 8)}...`); } catch (error) { self.debug.error(`Failed to gossip chunk ${chunkId}:`, error); throw error; } }, async close() { // Broadcast stream end via gossip try { await self.gossipManager.broadcastMessage({ streamId, action: 'end', totalChunks: chunkIndex, totalBytes: totalBytesWritten, metadata }, 'stream-control'); self.debug.log(`📡 Broadcast stream completed via gossip: ${totalBytesWritten} bytes in ${chunkIndex} chunks`); // Emit completion event self.emit('broadcastStreamComplete', { streamId, totalBytes: totalBytesWritten, totalChunks: chunkIndex, metadata }); } catch (error) { self.debug.error('Failed to broadcast stream end:', error); throw error; } }, async abort(reason) { // Broadcast stream abort via gossip try { await self.gossipManager.broadcastMessage({ streamId, action: 'abort', reason: reason?.message || String(reason), metadata }, 'stream-control'); self.debug.log(`❌ Broadcast stream aborted via gossip: ${reason}`); // Emit abort event self.emit('broadcastStreamAborted', { streamId, reason: reason?.message || String(reason), metadata }); } catch (error) { self.debug.error('Failed to broadcast stream abort:', error); } } }); } /** * Broadcast a ReadableStream to all connected peers * @param {ReadableStream} readableStream - The stream to broadcast * @param {object} options - Stream options (filename, mimeType, etc.) * @returns {Promise<void>} */ async broadcastStream(readableStream, options = {}) { const writableStream = this.createBroadcastStream(options); try { await readableStream.pipeTo(writableStream); this.debug.log(`✅ Stream broadcasted successfully to all peers`); } catch (error) { this.debug.error(`❌ Failed to broadcast stream:`, error); throw error; } } /** * Broadcast a File to all connected peers using streams * @param {File} file - The file to broadcast * @returns {Promise<void>} */ async broadcastFile(file) { this.debug.log(`📁 Broadcasting file "${file.name}" (${file.size} bytes) to all peers`); const options = { filename: file.name, mimeType: file.type, totalSize: file.size, type: 'file' }; const stream = file.stream(); await this.broadcastStream(stream, options); } /** * Broadcast a Blob to all connected peers using streams * @param {Blob} blob - The blob to broadcast * @param {object} options - Additional options * @returns {Promise<void>} */ async broadcastBlob(blob, options = {}) { this.debug.log(`📦 Broadcasting blob (${blob.size} bytes) to all peers`); const streamOptions = { ...options, mimeType: blob.type, totalSize: blob.size, type: 'blob' }; const stream = blob.stream(); await this.broadcastStream(stream, streamOptions); } /** * Generate a unique stream ID * @private */ _generateStreamId() { return `stream_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } // Helper methods for backward compatibility canAcceptMorePeers() { return this.connectionManager.canAcceptMorePeers(); } getConnectedPeerCount() { return this.connectionManager.getConnectedPeerCount(); } // Expose peers Map for backward compatibility get peers() { return this.connectionManager.peers; } // Get peer status method for UI compatibility getPeerUIStatus(peer) { if (!peer) return 'unknown'; return peer.getStatus ? peer.getStatus() : 'unknown'; } // Get connected peer IDs as array for UI compatibility getConnectedPeerIds() { return this.connectionManager.getPeers() .filter(peer => peer.status === 'connected') .map(peer => peer.peerId); } // Advanced methods async cleanupStaleSignalingData() { return this.cleanupManager.cleanupStaleSignalingData(); } forceConnectToAllPeers() { return this.meshOptimizer.forceConnectToAllPeers(); } // Debugging and maintenance methods forceCleanupInvalidPeers() { this.debug.log('Force cleaning up peers not in connected state...'); return this.connectionManager.forceCleanupInvalidPeers(); } cleanupStalePeers() { this.debug.log('Manually cleaning up stale peers...'); return this.connectionManager.cleanupStalePeers(); } getPeerStateSummary() { return this.connectionManager.getPeerStateSummary(); } debugConnectivity() { return this.meshOptimizer.debugConnectivity(); } // Media management methods async initializeMedia() { return await this.mediaManager.init(); } async startMedia(options = {}) { const { video = false, audio = false, deviceIds = {} } = options; try { const stream = await this.mediaManager.startLocalStream({ video, audio, deviceIds }); // Update all existing peer connections with the new stream - but only if crypto allows it const connections = this.connectionManager.getAllConnections(); this.debug.log(`📡 MEDIA START: Applying stream to ${connections.length} connections (with crypto verification)`); for (const connection of connections) { // SECURITY: Only share media if crypto keys are established or crypto is disabled let shouldShareMedia = true; if (this.enableCrypto && this.cryptoManager) { const hasKeys = this.cryptoManager.peerKeys && this.cryptoManager.peerKeys.has(connection.peerId); if (!hasKeys) { this.debug.log(`� MEDIA START: Skipping media share with ${connection.peerId.substring(0, 8)}... - no crypto keys established`); shouldShareMedia = false; } else { this.debug.log(`🔒 MEDIA START: Crypto keys verified for ${connection.peerId.substring(0, 8)}... - sharing media`); } } if (shouldShareMedia) { this.debug.log(`📡 MEDIA START: Setting stream for peer ${connection.peerId.substring(0, 8)}...`); await connection.setLocalStream(stream); this.debug.log(`✅ MEDIA START: Stream applied to ${connection.peerId.substring(0, 8)}...`); // REMOVED: No forced immediate renegotiation - let natural renegotiation handle it // This prevents cascade renegotiation conflicts when multiple peers join } // End of shouldShareMedia block } // End of connections loop return stream; } catch (error) { this.debug.error('Failed to start media:', error); throw error; } } async stopMedia() { this.mediaManager.stopLocalStream(); // Update all existing peer connections to remove the stream const connections = this.connectionManager.getAllConnections(); for (const connection of connections) { await connection.setLocalStream(null); } } toggleVideo() { return this.mediaManager.toggleVideo(); } toggleAudio() { return this.mediaManager.toggleAudio(); } getMediaState() { return this.mediaManager.getMediaState(); } getMediaDevices() { return this.mediaManager.devices; } async enumerateMediaDevices() { return await this.mediaManager.enumerateDevices(); } getLocalStream() { return this.mediaManager.localStream; } // Get remote streams from all connected peers getRemoteStreams() { const streams = new Map(); const connections = this.connectionManager.getAllConnections(); for (const connection of connections) { const remoteStream = connection.getRemoteStream(); if (remoteStream) { streams.set(connection.peerId, remoteStream); } } return streams; } // === SELECTIVE STREAMING CONTROL METHODS === /** * Enable streaming to specific peers only (1:1 or 1:many patterns) * @param {string|string[]} peerIds - Single peer ID or array of peer IDs to stream to * @param {Object} options - Stream options (video, audio, deviceIds) * @returns {Promise<MediaStream>} The local stream */ async startSelectiveStream(peerIds, options = {}) { // Normalize to array const targetPeerIds = Array.isArray(peerIds) ? peerIds : [peerIds]; this.debug.log(`🎯 SELECTIVE STREAM: Starting stream to ${targetPeerIds.length} specific peer(s)`); // Start local media stream const stream = await this.mediaManager.startLocalStream(options); // Get all connections const connections = this.connectionManager.getAllConnections(); // Apply stream only to target peers for (const connection of connections) { if (targetPeerIds.includes(connection.peerId)) { this.debug.log(`📡 SELECTIVE STREAM: Enabling stream for target pe