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