UNPKG

peerpigeon

Version:

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

1,227 lines (1,049 loc) โ€ข 87.7 kB
// PeerPigeon WebSocket Server - Node.js module import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import { EventEmitter } from 'events'; import { URL } from 'url'; import { PeerPigeonMesh } from '../src/PeerPigeonMesh.js'; // Inject WebSocket into global scope for use by SignalingClient in Node.js // This is necessary for hub mesh initialization if (typeof global !== 'undefined' && typeof global.WebSocket === 'undefined') { global.WebSocket = WebSocket; } /** * PeerPigeon WebSocket signaling server * Can be used programmatically or as a standalone server */ export class PeerPigeonServer extends EventEmitter { constructor(options = {}) { super(); this.port = options.port || 3000; this.host = options.host || 'localhost'; this.maxConnections = options.maxConnections || 1000; this.cleanupInterval = options.cleanupInterval || 30000; // 30 seconds this.peerTimeout = options.peerTimeout || 300000; // 5 minutes this.corsOrigin = options.corsOrigin || '*'; this.maxMessageSize = options.maxMessageSize || 1048576; // 1MB this.maxPortRetries = options.maxPortRetries || 10; // Try up to 10 ports this.verboseLogging = options.verboseLogging === true; // Default false, set true to enable verbose logs // Hub configuration this.isHub = options.isHub || false; // Whether this server is a hub this.hubMeshNamespace = options.hubMeshNamespace || 'pigeonhub-mesh'; // Reserved namespace for hubs (default: 'pigeonhub-mesh') this.bootstrapHubs = options.bootstrapHubs || []; // URIs of bootstrap hubs to connect to this.autoConnect = options.autoConnect !== false; // Auto-connect to bootstrap hubs (default: true) this.reconnectInterval = options.reconnectInterval || 5000; // Reconnect interval in ms this.maxReconnectAttempts = options.maxReconnectAttempts || 10; // Max reconnection attempts this.hubMeshMaxPeers = options.hubMeshMaxPeers || 3; // Max P2P connections in hub mesh (for partial mesh) this.hubMeshMinPeers = options.hubMeshMinPeers || 2; // Min P2P connections in hub mesh this.meshMigrationDelay = options.meshMigrationDelay || 10000; // Delay before closing WS connections after mesh is ready (10s) // Generate hub peer ID if this is a hub this.hubPeerId = options.hubPeerId || (this.isHub ? this.generatePeerId() : null); this.httpServer = null; this.wss = null; this.connections = new Map(); // peerId -> WebSocket this.peerData = new Map(); // peerId -> peer info this.networkPeers = new Map(); // networkName -> Set of peerIds this.hubs = new Map(); // peerId -> hub info (for peers identified as hubs) this.bootstrapConnections = new Map(); // bootstrapUri -> connection info this.crossHubDiscoveredPeers = new Map(); // networkName -> Map(peerId -> peerData) for peers on other hubs this.recentlyRelayedMessages = new Map(); // messageId -> timestamp (for deduplication) this.relayedMessageTTL = 5000; // Keep relay records for 5 seconds this.isRunning = false; this.cleanupTimer = null; this.startTime = null; // Hub mesh P2P properties this.hubMesh = null; // PeerPigeonMesh instance for hub-to-hub P2P connections this.hubMeshReady = false; // Whether the P2P mesh is established this.hubMeshPeers = new Map(); // hubPeerId -> { p2pConnected: boolean, wsConnection: WebSocket } this.migratedToP2P = new Set(); // Set of hub peer IDs that have migrated from WS to P2P this.migrationTimer = null; // Timer for WS disconnection after mesh establishment } /** * Validate peer ID format */ validatePeerId(peerId) { return typeof peerId === 'string' && /^[a-fA-F0-9]{40}$/.test(peerId); } /** * Generate a random peer ID (40 hex characters) */ generatePeerId() { // Generate random hex string const chars = '0123456789abcdef'; let peerId = ''; for (let i = 0; i < 40; i++) { peerId += chars[Math.floor(Math.random() * 16)]; } return peerId; } /** * Create a simple hash of signal data for deduplication */ hashSignalData(data) { if (!data) return 'null'; const str = JSON.stringify(data); let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return hash.toString(16); } /** * Check if a peer is registered as a hub */ isPeerHub(peerId) { return this.hubs.has(peerId); } /** * Register a peer as a hub */ registerHub(peerId, hubInfo = {}) { this.hubs.set(peerId, { peerId, registeredAt: Date.now(), lastActivity: Date.now(), ...hubInfo }); console.log(`๐Ÿข Registered hub: ${peerId.substring(0, 8)}... (${this.hubs.size} total hubs)`); this.emit('hubRegistered', { peerId, totalHubs: this.hubs.size }); } /** * Unregister a hub */ unregisterHub(peerId) { if (this.hubs.delete(peerId)) { console.log(`๐Ÿข Unregistered hub: ${peerId.substring(0, 8)}... (${this.hubs.size} remaining hubs)`); this.emit('hubUnregistered', { peerId, totalHubs: this.hubs.size }); } } /** * Get all connected hubs */ getConnectedHubs(excludePeerId = null) { const hubList = []; for (const [peerId, hubInfo] of this.hubs) { if (peerId !== excludePeerId && this.connections.has(peerId)) { hubList.push({ peerId, ...hubInfo }); } } return hubList; } /** * Find closest peers using XOR distance */ findClosestPeers(targetPeerId, allPeerIds, maxPeers = 3) { if (!targetPeerId || !allPeerIds || allPeerIds.length === 0) { return []; } // XOR distance calculation (simplified) const distances = allPeerIds.map(peerId => { let distance = 0; const minLength = Math.min(targetPeerId.length, peerId.length); for (let i = 0; i < minLength; i++) { const xor = parseInt(targetPeerId[i], 16) ^ parseInt(peerId[i], 16); distance += xor; } return { peerId, distance }; }); // Sort by distance and return closest peers distances.sort((a, b) => a.distance - b.distance); return distances.slice(0, maxPeers).map(item => item.peerId); } /** * Get active peers in a network */ getActivePeers(excludePeerId = null, networkName = 'global') { const peers = []; const stalePeers = []; // Get peers for the specific network const networkPeerSet = this.networkPeers.get(networkName); if (!networkPeerSet) { return peers; // No peers in this network } for (const peerId of networkPeerSet) { if (peerId !== excludePeerId) { const connection = this.connections.get(peerId); if (connection && connection.readyState === connection.OPEN) { peers.push(peerId); } else { // Mark stale connections for cleanup stalePeers.push(peerId); } } } // Clean up stale connections stalePeers.forEach(peerId => { if (this.verboseLogging) { console.log(`๐Ÿงน Cleaning up stale connection: ${peerId.substring(0, 8)}...`); } this.cleanupPeer(peerId); }); return peers; } /** * Try to start server on a specific port */ async tryPort(port, maxRetries = null) { maxRetries = maxRetries !== null ? maxRetries : this.maxPortRetries; return new Promise((resolve, reject) => { const attemptPort = (currentPort, retriesLeft) => { // Create HTTP server this.httpServer = createServer((req, res) => { // Handle CORS preflight if (req.method === 'OPTIONS') { res.writeHead(200, { 'Access-Control-Allow-Origin': this.corsOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' }); res.end(); return; } // Health check endpoint if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': this.corsOrigin }); res.end(JSON.stringify({ status: 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime(), isHub: this.isHub, connections: this.connections.size, peers: this.peerData.size, hubs: this.hubs.size, networks: this.networkPeers.size, memory: process.memoryUsage() })); return; } // Hubs endpoint - list connected hubs if (req.url === '/hubs') { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': this.corsOrigin }); res.end(JSON.stringify({ timestamp: new Date().toISOString(), totalHubs: this.hubs.size, hubs: this.getConnectedHubs() })); return; } // Default response res.writeHead(200, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': this.corsOrigin }); res.end('PeerPigeon WebSocket Signaling Server'); }); // Create WebSocket server this.wss = new WebSocketServer({ server: this.httpServer, maxPayload: this.maxMessageSize }); this.setupWebSocketHandlers(); // Handle port in use error const errorHandler = (error) => { if (error.code === 'EADDRINUSE') { console.log(`โš ๏ธ Port ${currentPort} is already in use`); // Clean up failed server this.httpServer.removeAllListeners(); if (this.wss) { this.wss.close(); } this.httpServer = null; this.wss = null; if (retriesLeft > 0) { const nextPort = currentPort + 1; console.log(`๐Ÿ”„ Trying port ${nextPort}...`); attemptPort(nextPort, retriesLeft - 1); } else { reject(new Error(`Failed to find available port after ${maxRetries} attempts (tried ${port}-${currentPort})`)); } } else { console.error('โŒ HTTP server error:', error); this.emit('error', error); reject(error); } }; this.httpServer.once('error', errorHandler); // Start HTTP server this.httpServer.listen(currentPort, this.host, () => { // Remove error handler after successful start this.httpServer.removeListener('error', errorHandler); // Update port to the actual port used this.port = currentPort; this.isRunning = true; this.startTime = Date.now(); this.startCleanupTimer(); if (currentPort !== port) { console.log(`โœ… Port ${port} was in use, using port ${currentPort} instead`); } const serverType = this.isHub ? '๐Ÿข PeerPigeon Hub Server' : '๐Ÿš€ PeerPigeon WebSocket Server'; console.log(`${serverType} started on ws://${this.host}:${this.port}`); if (this.isHub) { console.log(`๐ŸŒ Hub mesh namespace: ${this.hubMeshNamespace}`); } console.log(`๐Ÿ“Š Max connections: ${this.maxConnections}`); console.log(`๐Ÿงน Cleanup interval: ${this.cleanupInterval}ms`); console.log(`โฐ Peer timeout: ${this.peerTimeout}ms`); this.emit('started', { host: this.host, port: this.port }); resolve({ host: this.host, port: this.port }); // Add persistent error handler after successful start this.httpServer.on('error', (error) => { console.error('โŒ HTTP server error:', error); this.emit('error', error); }); }); }; attemptPort(port, maxRetries); }); } /** * Start the WebSocket server */ async start() { if (this.isRunning) { throw new Error('Server is already running'); } try { const result = await this.tryPort(this.port); // If this is a hub, initialize hub mesh AFTER server is running, then connect to bootstrap hubs if (this.isHub && this.autoConnect) { // Give the server a moment to be fully ready for connections await new Promise(resolve => setTimeout(resolve, 1000)); await this.initializeHubMesh(); await this.connectToBootstrapHubs(); } return result; } catch (error) { console.error('โŒ Failed to start server:', error); throw error; } } /** * Initialize the hub P2P mesh */ async initializeHubMesh() { if (!this.isHub || this.hubMesh) { return; } console.log(`๐Ÿ”— Initializing hub P2P mesh on namespace: ${this.hubMeshNamespace}`); console.log(` Hub Peer ID: ${this.hubPeerId.substring(0, 8)}...`); console.log(` Max P2P connections: ${this.hubMeshMaxPeers} (partial mesh with XOR routing)`); // Create PeerPigeonMesh instance for hub-to-hub connections this.hubMesh = new PeerPigeonMesh({ peerId: this.hubPeerId, networkName: this.hubMeshNamespace, maxPeers: this.hubMeshMaxPeers, minPeers: this.hubMeshMinPeers, autoConnect: true, autoDiscovery: true, xorRouting: true, // Use XOR routing for partial mesh enableWebDHT: false, // Don't need DHT for hub mesh enableCrypto: false // Hubs don't need encryption between themselves }); // Initialize the mesh (required before connecting) await this.hubMesh.init(); // Set up hub mesh event handlers this.setupHubMeshEventHandlers(); // Connect to local signaling server (ourselves) // Use 127.0.0.1 for localhost to avoid potential DNS issues const host = this.host === '0.0.0.0' ? '127.0.0.1' : this.host; const signalingUrl = `ws://${host}:${this.port}`; console.log(`๐Ÿ”Œ Connecting hub mesh to local signaling: ${signalingUrl}`); await this.hubMesh.connect(signalingUrl); console.log(`โœ… Hub mesh connected to local signaling`); } /** * Set up event handlers for the hub P2P mesh */ setupHubMeshEventHandlers() { if (!this.hubMesh) return; // Track P2P connections to other hubs this.hubMesh.addEventListener('peerConnected', (event) => { const hubPeerId = event.peerId; console.log(`๐Ÿ”— P2P connection established with hub: ${hubPeerId.substring(0, 8)}...`); // Update hub peer tracking if (!this.hubMeshPeers.has(hubPeerId)) { this.hubMeshPeers.set(hubPeerId, { p2pConnected: false, wsConnection: null }); } const hubInfo = this.hubMeshPeers.get(hubPeerId); hubInfo.p2pConnected = true; this.emit('hubP2PConnected', { hubPeerId }); // Check if we should migrate to P2P-only this.checkMeshReadiness(); }); this.hubMesh.addEventListener('peerDisconnected', (event) => { const hubPeerId = event.peerId; console.log(`๐Ÿ”Œ P2P connection lost with hub: ${hubPeerId.substring(0, 8)}...`); const hubInfo = this.hubMeshPeers.get(hubPeerId); if (hubInfo) { hubInfo.p2pConnected = false; } this.migratedToP2P.delete(hubPeerId); this.emit('hubP2PDisconnected', { hubPeerId }); }); // Handle messages from other hubs via P2P this.hubMesh.addEventListener('messageReceived', (event) => { this.handleHubP2PMessage(event.from, event.data); }); // Track mesh status updates this.hubMesh.addEventListener('peersUpdated', () => { const connectedCount = this.hubMesh.connectionManager.getConnectedPeers().length; console.log(`๐Ÿ“Š Hub mesh status: ${connectedCount} P2P connections active`); this.checkMeshReadiness(); }); } /** * Check if the hub mesh is ready and trigger migration from WS to P2P */ checkMeshReadiness() { if (!this.isHub || !this.hubMesh || this.hubMeshReady) { return; } const connectedP2PPeers = this.hubMesh.connectionManager.getConnectedPeers().length; const discoveredHubs = this.hubs.size; // Consider mesh ready if we have P2P connections to at least minPeers hubs // OR if there are fewer than minPeers total hubs available const isReady = connectedP2PPeers >= this.hubMeshMinPeers || (discoveredHubs < this.hubMeshMinPeers && connectedP2PPeers >= discoveredHubs); if (isReady && !this.hubMeshReady) { this.hubMeshReady = true; console.log(`โœ… Hub mesh is READY! ${connectedP2PPeers} P2P connections established`); console.log(` Migrating to P2P-only immediately...`); this.emit('hubMeshReady', { p2pConnections: connectedP2PPeers, totalHubs: discoveredHubs }); // Migrate immediately from WebSocket to P2P-only if (this.migrationTimer) { clearTimeout(this.migrationTimer); this.migrationTimer = null; } // Use setImmediate to allow current processing to complete before migration setImmediate(() => { this.migrateToP2POnly(); }); } } /** * Migrate from WebSocket connections to P2P-only for hub-to-hub communication */ migrateToP2POnly() { if (!this.isHub || !this.hubMesh) { return; } console.log(`๐Ÿ”„ MIGRATING to P2P-only hub mesh...`); const p2pConnectedHubs = this.hubMesh.connectionManager.getConnectedPeers(); let migratedCount = 0; let skippedCount = 0; for (const hubPeerId of p2pConnectedHubs) { // Check if this hub has both WS and P2P connections const hubInfo = this.hubMeshPeers.get(hubPeerId); if (!hubInfo || !hubInfo.p2pConnected) { continue; } // Check if we have a direct WebSocket connection to this hub (incoming) const directWsConnection = this.connections.get(hubPeerId); if (directWsConnection && directWsConnection.readyState === WebSocket.OPEN) { console.log(`๐Ÿ”Œ Closing direct WebSocket to hub ${hubPeerId.substring(0, 8)}... (P2P active)`); try { directWsConnection.close(1000, 'Migrated to P2P mesh'); this.migratedToP2P.add(hubPeerId); migratedCount++; } catch (error) { console.error(`โŒ Error closing WebSocket for hub ${hubPeerId.substring(0, 8)}...:`, error); } } else { skippedCount++; } } // Also close ALL bootstrap WebSocket connections - we're on P2P mesh now console.log(`๐Ÿ”Œ Closing ALL bootstrap WebSocket connections (P2P mesh active)...`); let bootstrapClosed = 0; for (const [uri, connInfo] of this.bootstrapConnections) { if (connInfo.connected && connInfo.ws && connInfo.ws.readyState === WebSocket.OPEN) { console.log(`๐Ÿ”Œ Closing bootstrap WebSocket: ${uri}`); try { connInfo.ws.close(1000, 'Migrated to P2P mesh'); connInfo.connected = false; bootstrapClosed++; } catch (error) { console.error(`โŒ Error closing bootstrap WebSocket ${uri}:`, error); } } } console.log(`โœ… Migration complete:`); console.log(` - ${migratedCount} direct hub WebSocket connections closed`); console.log(` - ${bootstrapClosed} bootstrap WebSocket connections closed`); console.log(` - ${skippedCount} connections already closed or non-existent`); console.log(`๐ŸŒ Hub mesh now operating on P2P (${p2pConnectedHubs.length} connections)`); this.emit('hubMeshMigrated', { migratedCount: migratedCount + bootstrapClosed, p2pConnections: p2pConnectedHubs.length }); } /** * Handle P2P messages from other hubs */ handleHubP2PMessage(fromHubPeerId, message) { console.log(`๐Ÿ“จ P2P message from hub ${fromHubPeerId.substring(0, 8)}...: ${message.type || 'unknown'}`); // Handle signaling relay through P2P mesh if (message.type === 'client-signal-relay') { this.handleClientSignalRelay(fromHubPeerId, message); return; } // Handle peer announcements through P2P mesh if (message.type === 'peer-announce-relay') { this.handlePeerAnnounceRelay(fromHubPeerId, message); return; } // Default: log unknown message types console.log(`โš ๏ธ Unknown P2P message type from hub: ${message.type}`); } /** * Handle client signaling relay through P2P hub mesh */ handleClientSignalRelay(fromHubPeerId, message) { const { targetPeerId, signalData } = message; if (!targetPeerId || !signalData) { console.log(`โš ๏ธ Invalid client signal relay from ${fromHubPeerId.substring(0, 8)}...`); return; } // Deduplicate relay: create hash from targetPeer + signal type + data hash const relayHash = `${targetPeerId}:${signalData.type}:${this.hashSignalData(signalData.data)}`; if (this.recentlyRelayedMessages.has(relayHash)) { console.log(`๐Ÿ” Duplicate P2P signal relay detected for ${targetPeerId.substring(0, 8)}..., ignoring`); return; } this.recentlyRelayedMessages.set(relayHash, Date.now()); // Check if target peer is connected to this hub const targetConnection = this.connections.get(targetPeerId); if (targetConnection && targetConnection.readyState === WebSocket.OPEN) { console.log(`๐Ÿ“ฅ Relaying signal to local client ${targetPeerId.substring(0, 8)}...`); this.sendToConnection(targetConnection, signalData); } else { // Forward to other hubs via P2P if we have more connections console.log(`๐Ÿ”„ Client ${targetPeerId.substring(0, 8)}... not local, forwarding to other hubs`); this.forwardSignalToHubMesh(targetPeerId, signalData, fromHubPeerId); } } /** * Handle peer announcement relay through P2P hub mesh */ handlePeerAnnounceRelay(fromHubPeerId, message) { const { peerId, networkName, peerData } = message; if (!peerId || !networkName) { console.log(`โš ๏ธ Invalid peer announce relay from ${fromHubPeerId.substring(0, 8)}...`); return; } console.log(`๐Ÿ“ฃ Peer ${peerId.substring(0, 8)}... announced via hub ${fromHubPeerId.substring(0, 8)}... on network: ${networkName}`); // Forward to local clients in the same network const localPeers = this.getActivePeers(null, networkName); localPeers.forEach(localPeerId => { const localConnection = this.connections.get(localPeerId); if (localConnection) { this.sendToConnection(localConnection, { type: 'peer-discovered', data: peerData, networkName, fromPeerId: 'system', targetPeerId: localPeerId, timestamp: Date.now() }); } }); } /** * Forward signaling message to hub mesh via P2P */ forwardSignalToHubMesh(targetPeerId, signalData, excludeHubPeerId = null) { if (!this.hubMesh) { return; } const connectedHubConnections = this.hubMesh.connectionManager.getConnectedPeers(); const connectedHubs = connectedHubConnections .map(conn => conn.peerId) .filter(hubId => hubId !== excludeHubPeerId); if (connectedHubs.length === 0) { console.log(`โš ๏ธ No other hubs available in P2P mesh for forwarding`); return; } const relayMessage = { type: 'client-signal-relay', targetPeerId, signalData, timestamp: Date.now() }; // Use XOR routing to find closest hubs const closestHubs = this.findClosestPeers(targetPeerId, connectedHubs, 2); closestHubs.forEach(hubPeerIdObj => { const hubPeerId = typeof hubPeerIdObj === 'string' ? hubPeerIdObj : hubPeerIdObj.peerId; console.log(`๐Ÿ“ค Forwarding signal to hub ${hubPeerId.substring(0, 8)}... via P2P`); // Get the peer connection and send the message const peerConnection = this.hubMesh.connectionManager.peers.get(hubPeerId); if (peerConnection && peerConnection.sendMessage) { peerConnection.sendMessage(relayMessage); console.log(`โœ… Sent signal via P2P to hub ${hubPeerId.substring(0, 8)}...`); } else { console.error(`โŒ No peer connection found for hub ${hubPeerId.substring(0, 8)}...`); } }); } /** * Stop the WebSocket server */ async stop() { if (!this.isRunning) { return; } return new Promise((resolve) => { console.log('๐Ÿ›‘ Stopping PeerPigeon WebSocket server...'); // Stop migration timer if running if (this.migrationTimer) { clearTimeout(this.migrationTimer); this.migrationTimer = null; } // Disconnect hub mesh if this is a hub if (this.isHub && this.hubMesh) { console.log('๐Ÿ”Œ Disconnecting hub P2P mesh...'); this.hubMesh.disconnect(); this.hubMesh = null; this.hubMeshReady = false; this.hubMeshPeers.clear(); this.migratedToP2P.clear(); } // Disconnect from bootstrap hubs if this is a hub if (this.isHub) { this.disconnectFromBootstrapHubs(); } // Stop cleanup timer if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } // Clear relay deduplication cache this.recentlyRelayedMessages.clear(); // Close all WebSocket connections for (const [peerId, ws] of this.connections) { try { ws.close(1000, 'Server shutting down'); } catch (error) { console.error(`Error closing connection for ${peerId}:`, error); } } // Close WebSocket server if (this.wss) { this.wss.close(() => { console.log('โœ… WebSocket server closed'); // Close HTTP server if (this.httpServer) { this.httpServer.close(() => { console.log('โœ… HTTP server closed'); this.isRunning = false; this.emit('stopped'); resolve(); }); } else { this.isRunning = false; this.emit('stopped'); resolve(); } }); } else { this.isRunning = false; this.emit('stopped'); resolve(); } }); } /** * Connect to bootstrap hubs */ async connectToBootstrapHubs() { if (!this.isHub) { console.log('โš ๏ธ Not a hub, skipping bootstrap connections'); return; } // If no bootstrap hubs specified, try default port 3000 let bootstrapUris = this.bootstrapHubs; if (!bootstrapUris || bootstrapUris.length === 0) { // Don't connect to ourselves const defaultPort = 3000; if (this.port !== defaultPort) { bootstrapUris = [`ws://${this.host}:${defaultPort}`]; console.log(`๐Ÿ”— No bootstrap hubs specified, trying default: ws://${this.host}:${defaultPort}`); } else { console.log('โ„น๏ธ No bootstrap hubs to connect to (running on default port 3000)'); return; } } console.log(`๐Ÿ”— Connecting to ${bootstrapUris.length} bootstrap hub(s)...`); for (const uri of bootstrapUris) { try { await this.connectToHub(uri); } catch (error) { console.error(`โŒ Failed to connect to bootstrap hub ${uri}:`, error.message); } } } /** * Connect to a specific hub */ async connectToHub(uri, attemptNumber = 0) { return new Promise((resolve, reject) => { try { // Parse URI to check if it's our own server const url = new URL(uri); const hubPort = parseInt(url.port) || 3000; const hubHost = url.hostname; // Don't connect to ourselves if (hubHost === this.host && hubPort === this.port) { console.log(`โš ๏ธ Skipping self-connection to ${uri}`); resolve(); return; } const wsUrl = `${uri}?peerId=${this.hubPeerId}`; console.log(`๐Ÿ”— Connecting to hub: ${uri} (attempt ${attemptNumber + 1})`); const ws = new WebSocket(wsUrl); const connectionInfo = { uri, ws, connected: false, attemptNumber, lastAttempt: Date.now(), reconnectTimer: null }; this.bootstrapConnections.set(uri, connectionInfo); ws.on('open', () => { console.log(`โœ… Connected to bootstrap hub: ${uri}`); connectionInfo.connected = true; connectionInfo.attemptNumber = 0; // Reset attempt counter on success // Announce this hub on the hub mesh this.announceToHub(ws); this.emit('bootstrapConnected', { uri }); resolve(); }); ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.handleBootstrapMessage(uri, message); } catch (error) { console.error(`โŒ Error handling message from ${uri}:`, error); } }); ws.on('close', (code, reason) => { console.log(`๐Ÿ”Œ Disconnected from bootstrap hub ${uri} (${code}: ${reason})`) connectionInfo.connected = false; this.emit('bootstrapDisconnected', { uri, code, reason }); // Attempt to reconnect if (this.isRunning && attemptNumber < this.maxReconnectAttempts) { connectionInfo.reconnectTimer = setTimeout(() => { console.log(`๐Ÿ”„ Reconnecting to ${uri}...`); this.connectToHub(uri, attemptNumber + 1); }, this.reconnectInterval); } else if (attemptNumber >= this.maxReconnectAttempts) { console.log(`โŒ Max reconnection attempts reached for ${uri}`); this.bootstrapConnections.delete(uri); } }); ws.on('error', (error) => { console.error(`โŒ WebSocket error for ${uri}:`, error.message); // Only reject on first attempt, otherwise we'll retry if (attemptNumber === 0) { this.bootstrapConnections.delete(uri); reject(error); } }); } catch (error) { console.error(`โŒ Failed to create connection to ${uri}:`, error); reject(error); } }); } /** * Announce this hub to a bootstrap hub */ announceToHub(ws) { const announcement = { type: 'announce', networkName: this.hubMeshNamespace, data: { isHub: true, port: this.port, host: this.host, capabilities: ['signaling', 'relay'], timestamp: Date.now() } }; ws.send(JSON.stringify(announcement)); console.log(`๐Ÿ“ข Announced to bootstrap hub on ${this.hubMeshNamespace}`); // Announce all existing local peers to the bootstrap hub this.announceLocalPeersToHub(ws); } /** * Announce all local peers to a bootstrap hub (including hub mesh peers for P2P discovery) */ announceLocalPeersToHub(ws) { let totalAnnounced = 0; console.log(`๐Ÿ“ก Announcing local peers to bootstrap hub (${this.networkPeers.size} networks)`); // Iterate through all networks and announce their peers for (const [networkName, peerSet] of this.networkPeers) { console.log(` Network '${networkName}': ${peerSet.size} peers`); for (const peerId of peerSet) { const peerData = this.peerData.get(peerId); // For hub mesh namespace, only announce our own hub mesh client (not other hubs) if (networkName === this.hubMeshNamespace && peerId !== this.hubPeerId) { console.log(` Skipping hub ${peerId.substring(0, 8)}... (not our mesh client)`); continue; } // For other namespaces, skip hub peers if (networkName !== this.hubMeshNamespace && peerData?.isHub) { console.log(` Skipping hub peer ${peerId.substring(0, 8)}... in network ${networkName}`); continue; } // Skip if not connected or not announced const connection = this.connections.get(peerId); if (!connection || connection.readyState !== WebSocket.OPEN || !peerData?.announced) { console.log(` Skipping ${peerId.substring(0, 8)}... (not connected or not announced)`); continue; } console.log(` โœ… Announcing ${peerId.substring(0, 8)}... from network '${networkName}'`); // Send peer-discovered message to bootstrap hub const peerAnnouncement = { type: 'peer-discovered', data: { peerId, isHub: networkName === this.hubMeshNamespace, ...peerData.data }, networkName: networkName, fromPeerId: 'system', timestamp: Date.now() }; try { ws.send(JSON.stringify(peerAnnouncement)); totalAnnounced++; } catch (error) { console.error(`โŒ Error announcing peer ${peerId.substring(0, 8)}... to bootstrap:`, error.message); } } } if (totalAnnounced > 0) { console.log(`๐Ÿ“ก Announced ${totalAnnounced} local peer(s) to bootstrap hub`); } } /** * Send all local peers to a connected hub (via peerId) */ sendLocalPeersToHub(hubPeerId) { const hubConnection = this.connections.get(hubPeerId); if (!hubConnection || hubConnection.readyState !== WebSocket.OPEN) { console.log(`โš ๏ธ Cannot send peers to hub ${hubPeerId.substring(0, 8)}... - not connected`); return; } let totalSent = 0; // Iterate through all networks and send their peers for (const [networkName, peerSet] of this.networkPeers) { // Skip the hub mesh namespace if (networkName === this.hubMeshNamespace) { continue; } for (const peerId of peerSet) { const peerData = this.peerData.get(peerId); // Skip hubs, only send regular peers if (peerData?.isHub) { continue; } // Skip if not connected or not announced const connection = this.connections.get(peerId); if (!connection || connection.readyState !== WebSocket.OPEN || !peerData?.announced) { continue; } // Send peer-discovered message to the hub const peerAnnouncement = { type: 'peer-discovered', data: { peerId, isHub: false, ...peerData.data }, networkName: networkName, fromPeerId: 'system', targetPeerId: hubPeerId, timestamp: Date.now() }; try { this.sendToConnection(hubConnection, peerAnnouncement); totalSent++; } catch (error) { console.error(`โŒ Error sending peer ${peerId.substring(0, 8)}... to hub:`, error.message); } } } if (totalSent > 0) { console.log(`๐Ÿ“ก Sent ${totalSent} local peer(s) to hub ${hubPeerId.substring(0, 8)}...`); } } /** * Handle messages from bootstrap hubs */ handleBootstrapMessage(uri, message) { const { type, data, fromPeerId, targetPeerId, networkName } = message; switch (type) { case 'connected': console.log(`โœ… Bootstrap hub acknowledged connection: ${uri}`); break; case 'peer-discovered': if (data?.isHub) { // Hub discovered via bootstrap - DON'T forward to prevent loops // Hubs will discover each other via their local mesh namespace connections console.log(`๐Ÿข Hub ${data.peerId?.substring(0, 8)}... seen via bootstrap ${uri} (not forwarding to prevent loop)`); this.emit('hubDiscovered', { peerId: data.peerId, via: uri, data }); } else if (data?.peerId) { // Regular peer discovered on another hub - notify our local peers in same network const discoveredPeerId = data.peerId; const discoveredNetwork = networkName || 'global'; console.log(`๐Ÿ“ฅ Peer ${discoveredPeerId.substring(0, 8)}... discovered on another hub (network: ${discoveredNetwork})`); // Cache for late joiners (so peers announcing later still learn about this peer) if (!this.crossHubDiscoveredPeers.has(discoveredNetwork)) { this.crossHubDiscoveredPeers.set(discoveredNetwork, new Map()); } this.crossHubDiscoveredPeers.get(discoveredNetwork).set(discoveredPeerId, { ...data, cachedAt: Date.now() }); // Forward to all our local peers in the same network const localPeers = this.getActivePeers(null, discoveredNetwork); let forwardCount = 0; localPeers.forEach(localPeerId => { const localConnection = this.connections.get(localPeerId); if (localConnection) { this.sendToConnection(localConnection, { type: 'peer-discovered', data, networkName: discoveredNetwork, fromPeerId: 'system', targetPeerId: localPeerId, timestamp: Date.now() }); forwardCount++; } }); console.log(`๐Ÿ“ค Forwarded to ${forwardCount} local peer(s) in network '${discoveredNetwork}'`); } break; case 'peer-disconnected': // Handle peer disconnection from another hub if (data?.peerId) { const disconnectedPeerId = data.peerId; const peerNetwork = networkName || 'global'; if (data?.isHub) { console.log(`๐Ÿข Hub disconnected: ${disconnectedPeerId.substring(0, 8)}... from network: ${peerNetwork}`); } else { if (this.verboseLogging) { console.log(`๐Ÿ‘‹ Remote peer disconnected: ${disconnectedPeerId.substring(0, 8)}... from network: ${peerNetwork}`); } } // Remove from cross-hub discovered peers cache const cachedRemotePeers = this.crossHubDiscoveredPeers.get(peerNetwork); if (cachedRemotePeers && cachedRemotePeers.has(disconnectedPeerId)) { cachedRemotePeers.delete(disconnectedPeerId); console.log(`๐Ÿงน Cleaned up remote peer ${disconnectedPeerId.substring(0, 8)}... from cross-hub cache (network: ${peerNetwork})`); } // Notify local peers in the same network about the disconnection const activePeers = this.getActivePeers(null, peerNetwork); activePeers.forEach(localPeerId => { this.sendToConnection(this.connections.get(localPeerId), { type: 'peer-disconnected', data: { peerId: disconnectedPeerId, isHub: data?.isHub || false }, networkName: peerNetwork, fromPeerId: 'system', targetPeerId: localPeerId, timestamp: Date.now() }); }); } break; case 'offer': case 'answer': case 'ice-candidate': // Check if target peer is on this hub if (targetPeerId) { const targetConnection = this.connections.get(targetPeerId); if (targetConnection && targetConnection.readyState === WebSocket.OPEN) { // Forward to local peer console.log(`๐Ÿ“ฅ Received ${type} from ${fromPeerId?.substring(0, 8)}... for local peer ${targetPeerId.substring(0, 8)}...`); this.sendToConnection(targetConnection, message); } else { // Not our peer - check if we should relay // CRITICAL: DO NOT relay via bootstrap WebSocket - this causes loops // Clients should signal directly through their hub, and hubs use P2P mesh if (this.verboseLogging) { console.log(`โญ๏ธ Skipping bootstrap relay of ${type} - not relaying client signaling via bootstrap`); } // Completely disable bootstrap relay to prevent loops break; } } else { console.log(`โš ๏ธ Received ${type} without targetPeerId via ${uri}`); } this.emit('bootstrapSignaling', { type, data, fromPeerId, targetPeerId, uri }); break; case 'pong': // Heartbeat response break; default: console.log(`๐Ÿ“จ Received ${type} from bootstrap hub ${uri}`); } } /** * Disconnect from all bootstrap hubs */ disconnectFromBootstrapHubs() { console.log(`๐Ÿ”Œ Disconnecting from ${this.bootstrapConnections.size} bootstrap hub(s)...`); for (const [uri, info] of this.bootstrapConnections) { if (info.reconnectTimer) { clearTimeout(info.reconnectTimer); } if (info.ws && info.ws.readyState === WebSocket.OPEN) { info.ws.close(1000, 'Hub shutting down'); } } this.bootstrapConnections.clear(); } /** * Setup WebSocket connection handlers */ setupWebSocketHandlers() { this.wss.on('connection', (ws, req) => { const url = new URL(req.url, `http://${req.headers.host}`); const peerId = url.searchParams.get('peerId'); // Validate peer ID if (!peerId || !this.validatePeerId(peerId)) { console.log('โŒ Invalid peer ID, closing connection'); ws.close(1008, 'Invalid peerId format'); return; } // Check if peerId is already connected if (this.connections.has(peerId)) { const existingConnection = this.connections.get(peerId); if (existingConnection.readyState === existingConnection.OPEN) { console.log(`โš ๏ธ Peer ${peerId.substring(0, 8)}... already connected, closing duplicate`); ws.close(1008, 'Peer already connected'); return; } else { // Clean up stale connection console.log(`๐Ÿ”„ Replacing stale connection for ${peerId.substring(0, 8)}...`); this.cleanupPeer(peerId); } } // Check connection limit if (this.connections.size >= this.maxConnections) { console.log('โŒ Maximum connections reached, closing connection'); ws.close(1008, 'Maximum connections reached'); return;