UNPKG

peerpigeon

Version:

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

542 lines (479 loc) 18.7 kB
import { EventEmitter } from './EventEmitter.js'; import { environmentDetector } from './EnvironmentDetector.js'; import DebugLogger from './DebugLogger.js'; /** * Manages gossip protocol for message propagation across the mesh network * Ensures all peers receive messages even if not directly connected */ export class GossipManager extends EventEmitter { constructor(mesh, connectionManager) { super(); this.mesh = mesh; this.connectionManager = connectionManager; this.debug = DebugLogger.create('GossipManager'); // Track message history to prevent infinite loops this.seenMessages = new Map(); // messageId -> { timestamp, ttl } this.messageHistory = new Map(); // messageId -> message content // Configuration this.maxTTL = 10; // Maximum hops before message expires this.messageExpiryTime = 5 * 60 * 1000; // 5 minutes this.cleanupInterval = 60 * 1000; // 1 minute this.cleanupTimer = null; // Track cleanup timer for proper cleanup this.startCleanupTimer(); } /** * Broadcast a message to all peers in the network using gossip protocol */ broadcastMessage(content, messageType = 'chat') { // Validate content if (content === undefined || content === null) { this.debug.error('Cannot broadcast message with undefined/null content'); return null; } if (messageType === 'chat' && (typeof content !== 'string' || content.trim().length === 0)) { this.debug.error('Cannot broadcast empty chat message'); return null; } if (messageType === 'encrypted' && (typeof content !== 'object' || !content.encrypted)) { this.debug.error('Cannot broadcast invalid encrypted message'); return null; } const messageId = this.generateMessageId(); const message = { id: messageId, type: 'gossip', subtype: messageType, content, from: this.mesh.peerId, timestamp: Date.now(), ttl: this.maxTTL, path: [this.mesh.peerId] // Track propagation path }; this.debug.log(`Broadcasting ${messageType} message: ${messageId.substring(0, 8)}... content: "${content}"`); // Store our own message this.seenMessages.set(messageId, { timestamp: Date.now(), ttl: this.maxTTL }); this.messageHistory.set(messageId, message); // Send to all connected peers this.propagateMessage(message); // Emit locally if it's a chat or encrypted message if (messageType === 'chat' || messageType === 'encrypted') { this.emit('messageReceived', { from: this.mesh.peerId, content, timestamp: message.timestamp, messageId, encrypted: messageType === 'encrypted' }); } return messageId; } /** * Send a direct message to a specific peer using gossip routing (DM) * @param {string} targetPeerId - The destination peer's ID * @param {string|object} content - The message content * @param {string} subtype - Message subtype (default: 'dm') * @returns {string|null} The message ID if sent, or null on error */ sendDirectMessage(targetPeerId, content, subtype = 'dm') { if (!targetPeerId || typeof targetPeerId !== 'string') { this.debug.error('Invalid targetPeerId for direct message'); return null; } // Validate peer ID format (40-character hex string) if (!/^[a-fA-F0-9]{40}$/.test(targetPeerId)) { this.debug.error('Invalid peer ID format for direct message:', targetPeerId); return null; } const messageId = this.generateMessageId(); const message = { id: messageId, type: 'gossip', subtype, content, from: this.mesh.peerId, to: targetPeerId, timestamp: Date.now(), ttl: this.maxTTL, path: [this.mesh.peerId] }; // Store our own message this.seenMessages.set(messageId, { timestamp: Date.now(), ttl: this.maxTTL }); this.messageHistory.set(messageId, message); // Route to closest peer this.propagateMessage(message); return messageId; } /** * Handle incoming gossip message from a peer */ async handleGossipMessage(message, fromPeerId) { this.debug.log(`🔥🔥🔥 GOSSIP MESSAGE RECEIVED! From: ${fromPeerId?.substring(0, 8)}...`); this.debug.log('🔥🔥🔥 Message:', message); const { id: messageId, ttl, from: originPeerId, subtype, content, timestamp, path, to } = message; // Validate message structure if (!messageId || !originPeerId || !subtype || content === undefined) { this.debug.error('Invalid gossip message structure:', message); return; } // Check if we've already seen this message if (this.seenMessages.has(messageId)) { this.debug.log(`Ignoring duplicate message: ${messageId.substring(0, 8)}...`); return; } // Check TTL if (ttl <= 0) { this.debug.log(`Message expired: ${messageId.substring(0, 8)}...`); return; } // Check for loops (our peer ID in path) if (path && path.includes(this.mesh.peerId)) { this.debug.log(`Preventing message loop: ${messageId.substring(0, 8)}...`); return; } this.debug.log(`Received gossip message: ${messageId.substring(0, 8)}... from ${fromPeerId.substring(0, 8)}... (TTL: ${ttl}, content: "${content}")`); // Store message to prevent duplicates this.seenMessages.set(messageId, { timestamp: Date.now(), ttl }); this.messageHistory.set(messageId, message); // Handle crypto-related messages first if (this.mesh.enableCrypto && this.mesh.cryptoManager) { const handled = await this._handleCryptoMessage(message, fromPeerId, originPeerId); if (handled) { return; // Don't propagate crypto messages further } } // Handle encrypted content decryption let processedContent = content; let isEncrypted = false; if (this.mesh.enableCrypto && this.mesh.cryptoManager && content && typeof content === 'object' && content.encrypted) { try { processedContent = await this.mesh.decryptMessage(content); isEncrypted = true; this.debug.log(`🔐 Decrypted message from ${originPeerId.substring(0, 8)}...`); } catch (error) { this.debug.error('Failed to decrypt message:', error); // Continue with original content processedContent = content; } } // Emit the message locally if (subtype === 'chat') { // Validate chat content (handle both encrypted objects and plain strings) if (isEncrypted || (typeof processedContent === 'string' && processedContent.trim().length > 0)) { this.emit('messageReceived', { from: originPeerId, content: processedContent, timestamp, messageId, hops: this.maxTTL - ttl, direct: false, // Flag to indicate this was a gossip message encrypted: isEncrypted }); } else { this.debug.warn('Ignoring gossip chat message with invalid content:', processedContent); return; } } else if (subtype === 'encrypted') { // Handle encrypted broadcast messages this.emit('messageReceived', { from: originPeerId, content: processedContent, timestamp, messageId, hops: this.maxTTL - ttl, direct: false, encrypted: true }); } else if (subtype === 'peer-announcement') { this.handlePeerAnnouncement(content, originPeerId); } else if (subtype === 'mediaEvent') { // Handle media streaming events this.handleMediaEvent(content, originPeerId); } else if (subtype === 'dm') { // Direct message logic if (typeof to === 'string' && typeof this.mesh.peerId === 'string' && to.trim().toLowerCase() === this.mesh.peerId.trim().toLowerCase()) { // This peer is the target, emit to UI this.emit('messageReceived', { from: originPeerId, content: processedContent, timestamp, messageId, hops: this.maxTTL - ttl, direct: true, // Flag to indicate this was a direct message encrypted: isEncrypted }); // Do NOT relay further if we are the recipient return; } else { // Not the target, relay silently (do not emit) } } else if (subtype === 'dht-routing') { // DHT routing message - check if we're the target if (typeof to === 'string' && typeof this.mesh.peerId === 'string' && to.trim().toLowerCase() === this.mesh.peerId.trim().toLowerCase()) { this.debug.log(`DHT: Received routed message for us from ${originPeerId.substring(0, 8)}`); // We are the target, deliver the DHT message locally if (this.mesh.webDHT && content) { // Simulate receiving the message from the original sender this.mesh.webDHT.handleMessage(content, originPeerId); } // Do NOT relay further since we are the recipient return; } else { this.debug.log(`DHT: Routing message for ${to?.substring(0, 8)} (not us)`); // Not the target, continue routing } } // Propagate to other peers with decremented TTL const updatedMessage = { ...message, ttl: ttl - 1, path: [...(path || []), this.mesh.peerId] }; this.propagateMessage(updatedMessage, fromPeerId); } /** * Handle peer announcements received via gossip */ handlePeerAnnouncement(announcementData, originPeerId) { const { peerId: announcedPeerId } = announcementData; this.debug.log(`Gossip peer announcement: ${announcedPeerId.substring(0, 8)}... via ${originPeerId.substring(0, 8)}...`); // Add to discovered peers if we don't know about them if (!this.mesh.peerDiscovery.hasPeer(announcedPeerId) && announcedPeerId !== this.mesh.peerId) { this.mesh.emit('statusChanged', { type: 'info', message: `Discovered peer ${announcedPeerId.substring(0, 8)}... via gossip` }); this.mesh.peerDiscovery.addDiscoveredPeer(announcedPeerId); } } /** * Handle media streaming events received via gossip */ handleMediaEvent(eventData, originPeerId) { const { event, peerId, hasVideo, hasAudio, timestamp } = eventData; this.debug.log(`Media event gossip: ${event} from ${peerId.substring(0, 8)}... via ${originPeerId.substring(0, 8)}...`); // Don't process our own events if (peerId === this.mesh.peerId) { return; } // Emit the media event for the UI to handle if (event === 'streamStarted') { this.mesh.emit('remoteStreamAnnouncement', { peerId, hasVideo, hasAudio, timestamp, event: 'started' }); } else if (event === 'streamStopped') { this.mesh.emit('remoteStreamAnnouncement', { peerId, timestamp, event: 'stopped' }); } } /** * Broadcast peer announcement via gossip when we connect */ announcePeer(peerId = this.mesh.peerId) { const announcementData = { peerId, timestamp: Date.now() }; this.debug.log(`Gossiping peer announcement for: ${peerId.substring(0, 8)}...`); this.broadcastMessage(announcementData, 'peer-announcement'); } /** * Propagate message to ALL peers that can receive messages - ignore connection states! */ propagateMessage(message, excludePeerId = null) { // DM and DHT routing: route to closest peer(s) for messages with specific targets if ((message.subtype === 'dm' || message.subtype === 'dht-routing') && message.to) { const targetId = message.to; const allPeers = Array.from(this.connectionManager.peers.values()); const capablePeers = allPeers.filter(peerConnection => { return peerConnection.dataChannel && peerConnection.dataChannel.readyState === 'open'; }); // Improved XOR distance for 40-char hex peer IDs function xorDistance(a, b) { if (!a || !b) return Number.MAX_SAFE_INTEGER; let dist = 0n; for (let i = 0; i < 40; i += 8) { const aChunk = a.substring(i, i + 8); const bChunk = b.substring(i, i + 8); dist = (dist << 32n) + (BigInt('0x' + aChunk) ^ BigInt('0x' + bChunk)); } return dist; } let minDist = null; let closestPeers = []; capablePeers.forEach(peerConnection => { if (peerConnection.peerId === excludePeerId || message.ttl <= 0) return; const dist = xorDistance(peerConnection.peerId, targetId); if (minDist === null || dist < minDist) { minDist = dist; closestPeers = [peerConnection]; } else if (dist === minDist) { closestPeers.push(peerConnection); } }); if (closestPeers.length > 0) { closestPeers.forEach(peer => { try { peer.sendMessage(message); const routingType = message.subtype === 'dht-routing' ? 'DHT' : 'DM'; this.debug.log(`${routingType} routed to closest peer: ${peer.peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`${message.subtype} routing failed:`, error); } }); } else { // Fallback: relay to all capable peers except sender capablePeers.forEach(peerConnection => { if (peerConnection.peerId === excludePeerId || message.ttl <= 0) return; try { peerConnection.sendMessage(message); const routingType = message.subtype === 'dht-routing' ? 'DHT' : 'DM'; this.debug.log(`${routingType} fallback relay to: ${peerConnection.peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`${message.subtype} fallback relay failed:`, error); } }); } return; } // AGGRESSIVE: Get ALL peers regardless of status - we only care if we can send a message const allPeers = Array.from(this.connectionManager.peers.values()); const capablePeers = allPeers.filter(peerConnection => { // ONLY requirement: does the peer have an open data channel? return peerConnection.dataChannel && peerConnection.dataChannel.readyState === 'open'; }); let propagatedTo = 0; this.debug.log(`🚀 AGGRESSIVE GOSSIP: Found ${capablePeers.length}/${allPeers.length} peers with open data channels`); this.debug.log(`Message: ${message.id.substring(0, 8)}..., TTL: ${message.ttl}, Exclude: ${excludePeerId?.substring(0, 8) || 'none'}`); // Debug: show state of ALL peers allPeers.forEach(peerConnection => { const status = peerConnection.getStatus(); const dataChannelState = peerConnection.dataChannel?.readyState || 'none'; const canSend = peerConnection.dataChannel && peerConnection.dataChannel.readyState === 'open'; const isExcluded = peerConnection.peerId === excludePeerId; this.debug.log(` ${peerConnection.peerId.substring(0, 8)}... - Status: ${status}, DataChannel: ${dataChannelState}, CanSend: ${canSend}, Excluded: ${isExcluded}`); }); capablePeers.forEach(peerConnection => { const peerId = peerConnection.peerId; // Don't send back to sender or if TTL expired if (peerId === excludePeerId || message.ttl <= 0) { return; } try { peerConnection.sendMessage(message); propagatedTo++; this.debug.log(`✅ GOSSIP SUCCESS: Sent to ${peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`❌ GOSSIP FAILED: Could not send to ${peerId.substring(0, 8)}...`, error); } }); this.debug.log(`🎯 GOSSIP RESULT: Propagated to ${propagatedTo}/${capablePeers.length} capable peers`); if (propagatedTo === 0 && allPeers.length > 0) { this.debug.error(`🚨 GOSSIP FAILURE: NO PROPAGATION! ${allPeers.length} total peers, ${capablePeers.length} with data channels`); } } /** * Get message statistics */ getStats() { return { seenMessages: this.seenMessages.size, storedMessages: this.messageHistory.size, maxTTL: this.maxTTL, messageExpiryTime: this.messageExpiryTime }; } /** * Generate unique message ID */ generateMessageId() { const array = new Uint8Array(16); crypto.getRandomValues(array); return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); } /** * Clean up expired messages */ startCleanupTimer() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); } if (environmentDetector.isBrowser) { this.cleanupTimer = window.setInterval(() => { this.cleanupExpiredMessages(); }, this.cleanupInterval); } else { this.cleanupTimer = setInterval(() => { this.cleanupExpiredMessages(); }, this.cleanupInterval); } } /** * Stop cleanup timer */ stopCleanupTimer() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } } cleanupExpiredMessages() { const now = Date.now(); let cleaned = 0; // Clean up seen messages this.seenMessages.forEach((data, messageId) => { if (now - data.timestamp > this.messageExpiryTime) { this.seenMessages.delete(messageId); this.messageHistory.delete(messageId); cleaned++; } }); if (cleaned > 0) { this.debug.log(`Cleaned up ${cleaned} expired gossip messages`); } } /** * Cleanup method */ cleanup() { this.stopCleanupTimer(); this.seenMessages.clear(); this.messageHistory.clear(); } /** * Handle crypto-related messages (key exchange, etc.) * @private */ async _handleCryptoMessage(message, fromPeerId, originPeerId) { const { subtype, content } = message; // Handle key exchange messages if (subtype === 'key_exchange' || subtype === 'key_exchange_response') { if (content && (content.type === 'key_exchange' || content.type === 'key_exchange_response') && content.publicKey) { this.debug.log(`🔐 Received ${content.type} from peer ${originPeerId.substring(0, 8)}...`); // Use the mesh's key exchange handler which properly handles both pub and epub keys this.mesh._handleKeyExchange(content, originPeerId); // Don't propagate key exchange messages further return true; // Indicates message was handled and should not be processed further } } return false; // Message was not handled by crypto processing } }