UNPKG

peerpigeon

Version:

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

392 lines (328 loc) โ€ข 12.2 kB
import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import { URL } from 'url'; /** * Local WebSocket Server for PeerPigeon Development * * This server provides WebSocket signaling functionality * for local development and testing of the PeerPigeon mesh network. */ const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || 'localhost'; // In-memory storage for connections and peer data const connections = new Map(); // peerId -> WebSocket connection const peerData = new Map(); // peerId -> { peerId, timestamp, data } // Utility functions function validatePeerId(peerId) { return typeof peerId === 'string' && /^[a-fA-F0-9]{40}$/.test(peerId); } function 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); } function getActivePeers(excludePeerId = null) { const peers = []; const stalePeers = []; for (const [peerId, connection] of connections) { if (peerId !== excludePeerId) { if (connection.readyState === WebSocket.OPEN && isConnectionAlive(connection)) { peers.push(peerId); } else { // Mark stale connections for cleanup stalePeers.push(peerId); } } } // Clean up stale connections stalePeers.forEach(peerId => { console.log(`๐Ÿงน Cleaning up stale connection: ${peerId.substring(0, 8)}...`); connections.delete(peerId); peerData.delete(peerId); }); return peers; } function isConnectionAlive(connection) { if (!connection || connection.readyState !== WebSocket.OPEN) { return false; } // Simple alive check - if connection is open, it's considered alive // Detailed health monitoring is handled by the peer mesh itself return true; } function sendToConnection(peerId, data) { const connection = connections.get(peerId); if (connection && connection.readyState === WebSocket.OPEN) { try { connection.send(JSON.stringify(data)); return true; } catch (error) { console.error(`Error sending to ${peerId}:`, error); // Clean up failed connection cleanupPeer(peerId); return false; } } else if (connection && (connection.readyState === WebSocket.CLOSED || connection.readyState === WebSocket.CLOSING)) { // Clean up closed connection cleanupPeer(peerId); } return false; } function cleanupPeer(peerId) { const wasConnected = connections.has(peerId); connections.delete(peerId); peerData.delete(peerId); if (wasConnected) { console.log(`๐Ÿงน Cleaned up peer: ${peerId.substring(0, 8)}...`); // Notify other peers about disconnection const activePeers = getActivePeers(); activePeers.forEach(otherPeerId => { sendToConnection(otherPeerId, { type: 'peer-disconnected', data: { peerId }, fromPeerId: 'system', targetPeerId: otherPeerId, timestamp: Date.now() }); }); } } function broadcastToClosestPeers(fromPeerId, message, maxPeers = 5) { const activePeers = getActivePeers(fromPeerId); const closestPeers = findClosestPeers(fromPeerId, activePeers, maxPeers); console.log(`Broadcasting from ${fromPeerId} to ${closestPeers.length} closest peers`); closestPeers.forEach(peerId => { sendToConnection(peerId, message); }); } function sendToSpecificPeer(targetPeerId, message) { return sendToConnection(targetPeerId, message); } // Create HTTP server const server = createServer(); // Create WebSocket server const wss = new WebSocketServer({ server }); console.log('๐Ÿš€ Starting PeerPigeon WebSocket server...'); // Periodic cleanup of stale connections setInterval(() => { const totalConnections = connections.size; getActivePeers(); // This will clean up stale connections const cleanedUp = totalConnections - connections.size; if (cleanedUp > 0) { console.log(`๐Ÿงน Periodic cleanup: removed ${cleanedUp} stale connections, ${connections.size} active`); } }, 30000); // Clean up every 30 seconds // Note: Signaling servers should NOT initiate pings to peers // Health monitoring is the responsibility of the peer mesh network itself wss.on('connection', (ws, req) => { let peerId = null; // Extract peerId from query parameters const url = new URL(req.url, `http://${req.headers.host}`); const queryPeerId = url.searchParams.get('peerId'); if (!queryPeerId || !validatePeerId(queryPeerId)) { console.log(`โŒ Invalid peerId: ${queryPeerId}`); ws.close(1008, 'Invalid peerId'); return; } peerId = queryPeerId; // Check if peerId is already connected if (connections.has(peerId)) { const existingConnection = connections.get(peerId); if (existingConnection.readyState === WebSocket.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)}...`); cleanupPeer(peerId); } } // Store connection connections.set(peerId, ws); peerData.set(peerId, { peerId, timestamp: Date.now(), connected: true }); // Set up connection metadata ws.connectedAt = Date.now(); console.log(`โœ… Peer ${peerId.substring(0, 8)}... connected (${connections.size} total)`); // Send connection confirmation ws.send(JSON.stringify({ type: 'connected', peerId, timestamp: Date.now() })); // Handle incoming messages ws.on('message', (data) => { try { const message = JSON.parse(data); const { type, data: messageData, targetPeerId } = message; console.log(`๐Ÿ“จ Received ${type} from ${peerId.substring(0, 8)}...`); const responseMessage = { type, data: messageData, fromPeerId: peerId, targetPeerId, timestamp: Date.now() }; // Handle different message types - SIGNALING ONLY switch (type) { case 'announce': { // Handle peer announcement peerData.set(peerId, { peerId, timestamp: Date.now(), data: messageData, connected: true }); // Get active peers with immediate validation const activePeers = getActivePeers(peerId); // Double-check each active peer with a quick ping test const validatedPeers = []; for (const otherPeerId of activePeers) { const connection = connections.get(otherPeerId); if (connection && isConnectionAlive(connection)) { validatedPeers.push(otherPeerId); } else { console.log(`๐Ÿงน Found dead connection during announce: ${otherPeerId.substring(0, 8)}...`); cleanupPeer(otherPeerId); } } console.log(`๐Ÿ“ข Announcing ${peerId.substring(0, 8)}... to ${validatedPeers.length} validated peers`); // Send peer-discovered messages to validated peers only validatedPeers.forEach(otherPeerId => { sendToConnection(otherPeerId, { type: 'peer-discovered', data: { peerId, ...messageData }, fromPeerId: 'system', targetPeerId: otherPeerId, timestamp: Date.now() }); }); // Send existing validated peers to the new peer validatedPeers.forEach(existingPeerId => { const existingPeerData = peerData.get(existingPeerId); ws.send(JSON.stringify({ type: 'peer-discovered', data: { peerId: existingPeerId, ...existingPeerData?.data }, fromPeerId: 'system', targetPeerId: peerId, timestamp: Date.now() })); }); break; } case 'goodbye': { // Handle peer disconnect peerData.delete(peerId); broadcastToClosestPeers(peerId, responseMessage); break; } case 'offer': case 'answer': case 'ice-candidate': { // CRITICAL DEBUG: Log answer routing details if (type === 'answer') { console.log('๐Ÿ” WEBSOCKET DEBUG: Received answer message:', { type, fromPeerId: peerId?.substring(0, 8) + '...', targetPeerId: targetPeerId?.substring(0, 8) + '...', hasTargetPeerId: !!targetPeerId, hasData: !!messageData }); } // Handle WebRTC signaling - this is the server's primary purpose if (targetPeerId) { const success = sendToSpecificPeer(targetPeerId, responseMessage); if (!success) { console.log(`โš ๏ธ Failed to send ${type} to ${targetPeerId.substring(0, 8)}... (peer not found)`); } else if (type === 'answer') { console.log(`โœ… WEBSOCKET DEBUG: Answer successfully routed to ${targetPeerId.substring(0, 8)}...`); } } else { console.log(`โš ๏ธ ${type} message missing targetPeerId`); } break; } default: // Signaling server should NOT route regular peer messages // Peers handle their own message routing through WebRTC data channels console.log(`โš ๏ธ Ignoring non-signaling message type '${type}' - peers should route their own messages`); ws.send(JSON.stringify({ type: 'error', error: `Signaling server does not route '${type}' messages. Use WebRTC data channels for peer-to-peer communication.`, timestamp: Date.now() })); break; } } catch (error) { console.error(`โŒ Error handling message from ${peerId?.substring(0, 8)}...:`, error); ws.send(JSON.stringify({ type: 'error', error: 'Invalid message format', timestamp: Date.now() })); } }); // Handle connection close ws.on('close', (code, reason) => { console.log(`๐Ÿ”Œ Peer ${peerId?.substring(0, 8)}... disconnected (${code}: ${reason})`); if (peerId) { cleanupPeer(peerId); } console.log(`๐Ÿ“Š Active connections: ${connections.size}`); }); // Handle connection errors ws.on('error', (error) => { console.error(`โŒ WebSocket error for ${peerId?.substring(0, 8)}...:`, error); // Clean up errored connection if (peerId && (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING)) { cleanupPeer(peerId); } }); }); // Start server server.listen(PORT, HOST, () => { console.log(`๐ŸŒ PeerPigeon WebSocket server running on ws://${HOST}:${PORT}`); console.log('๐Ÿ“ Usage: Connect with ?peerId=<40-char-hex-id>'); console.log('๐Ÿ“Š Ready to handle peer connections...'); }); // Handle graceful shutdown process.on('SIGINT', () => { console.log('\n๐Ÿ›‘ Shutting down WebSocket server...'); // Close all connections for (const [, connection] of connections) { connection.close(1001, 'Server shutting down'); } // Close server server.close(() => { console.log('โœ… WebSocket server closed'); process.exit(0); }); }); // Error handling process.on('uncaughtException', (error) => { console.error('โŒ Uncaught Exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('โŒ Unhandled Rejection at:', promise, 'reason:', reason); process.exit(1); }); // Export for programmatic use export { server, wss, connections, peerData };