peerpigeon
Version:
WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server
303 lines (252 loc) • 10.9 kB
JavaScript
import { EventEmitter } from './EventEmitter.js';
import { environmentDetector } from './EnvironmentDetector.js';
import DebugLogger from './DebugLogger.js';
export class PeerDiscovery extends EventEmitter {
constructor(peerId, options = {}) {
super();
this.debug = DebugLogger.create('PeerDiscovery');
this.peerId = peerId;
this.discoveredPeers = new Map();
this.connectionAttempts = new Map();
this.cleanupInterval = null;
this.meshOptimizationTimeout = null;
this.autoDiscovery = options.autoDiscovery ?? true;
this.evictionStrategy = options.evictionStrategy ?? true;
this.xorRouting = options.xorRouting ?? true;
this.minPeers = options.minPeers ?? 0;
this.maxPeers = options.maxPeers ?? 10;
}
start() {
this.startCleanupInterval();
}
stop() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
if (this.meshOptimizationTimeout) {
clearTimeout(this.meshOptimizationTimeout);
this.meshOptimizationTimeout = null;
}
this.discoveredPeers.clear();
this.connectionAttempts.clear();
}
addDiscoveredPeer(peerId) {
// Skip if we already know about this peer (prevent duplicate processing)
if (this.discoveredPeers.has(peerId)) {
// Update timestamp but don't emit events or trigger connections
this.discoveredPeers.set(peerId, Date.now());
return;
}
this.discoveredPeers.set(peerId, Date.now());
this.emit('peerDiscovered', { peerId });
this.debug.log(`Discovered peer ${peerId.substring(0, 8)}...`);
// Only auto-connect if we should initiate to this peer and we're not already trying
if (this.autoDiscovery && this.shouldInitiateConnection(peerId) && !this.connectionAttempts.has(peerId)) {
this.debug.log(`Considering connection to ${peerId.substring(0, 8)}...`);
const canAccept = this.canAcceptMorePeers();
if (canAccept) {
this.debug.log(`Connecting to ${peerId.substring(0, 8)}...`);
this.emit('connectToPeer', { peerId });
}
}
this.scheduleMeshOptimization();
}
// Update connection attempts tracking (no complex isolation logic needed)
onConnectionEstablished() {
this.debug.log('Connection established');
}
removeDiscoveredPeer(peerId) {
this.discoveredPeers.delete(peerId);
this.connectionAttempts.delete(peerId);
}
trackConnectionAttempt(peerId) {
this.connectionAttempts.set(peerId, Date.now());
}
clearConnectionAttempt(peerId) {
this.connectionAttempts.delete(peerId);
}
calculateXorDistance(peerId1, peerId2) {
let distance = 0n;
for (let i = 0; i < Math.min(peerId1.length, peerId2.length); i += 2) {
const byte1 = parseInt(peerId1.substr(i, 2), 16);
const byte2 = parseInt(peerId2.substr(i, 2), 16);
const xor = byte1 ^ byte2;
distance = (distance << 8n) | BigInt(xor);
}
return distance;
}
shouldInitiateConnection(targetPeerId) {
if (this.connectionAttempts.has(targetPeerId)) {
return false;
}
// Check current connection count to handle isolation
this.emit('checkCapacity');
const currentConnectionCount = this._currentConnectionCount || 0;
// Standard rule: Lexicographically larger peer ID initiates
const lexicographicShouldInitiate = this.peerId > targetPeerId;
// CRITICAL FIX: If we have no connections, override lexicographic rule to avoid isolation
if (currentConnectionCount === 0 && this.discoveredPeers.size > 0) {
// For completely isolated peers, be more aggressive about connecting
const discoveredPeers = Array.from(this.discoveredPeers.keys());
const naturalInitiators = discoveredPeers.filter(peerId => this.peerId > peerId);
// First priority: peers where we'd naturally be the initiator
if (naturalInitiators.length > 0 && naturalInitiators.includes(targetPeerId)) {
this.debug.log(`shouldInitiateConnection: Isolation override (natural) - ${this.peerId.substring(0, 8)}... will initiate to ${targetPeerId.substring(0, 8)}...`);
return true;
}
// Second priority: if no natural initiators, try the closest 3 peers by XOR distance
if (naturalInitiators.length === 0) {
const sortedByDistance = discoveredPeers.sort((a, b) => {
const distA = this.calculateXorDistance(this.peerId, a);
const distB = this.calculateXorDistance(this.peerId, b);
return distA < distB ? -1 : 1;
});
// Try to connect to the closest 3 peers (or all if less than 3)
const closestPeers = sortedByDistance.slice(0, Math.min(3, sortedByDistance.length));
if (closestPeers.includes(targetPeerId)) {
const index = closestPeers.indexOf(targetPeerId);
this.debug.log(`shouldInitiateConnection: Isolation override (closest ${index + 1}) - ${this.peerId.substring(0, 8)}... will initiate to ${targetPeerId.substring(0, 8)}...`);
return true;
}
}
// Third priority: if we still have no connections and have attempted several peers,
// be even more aggressive and try ANY peer to break isolation
const attemptedConnections = this.connectionAttempts.size;
if (attemptedConnections >= 2 && discoveredPeers.includes(targetPeerId)) {
this.debug.log(`shouldInitiateConnection: Isolation override (desperate) - ${this.peerId.substring(0, 8)}... will initiate to ${targetPeerId.substring(0, 8)}... (${attemptedConnections} attempts failed)`);
return true;
}
}
this.debug.log(`shouldInitiateConnection: ${this.peerId.substring(0, 8)}... > ${targetPeerId.substring(0, 8)}... = ${lexicographicShouldInitiate}`);
return lexicographicShouldInitiate;
}
isAttemptingConnection(peerId) {
return this.connectionAttempts.has(peerId);
}
shouldEvictForPeer(newPeerId) {
if (!this.evictionStrategy || !this.xorRouting) {
return null;
}
// This would need access to current peers, so we'll emit an event instead
this.emit('checkEviction', { newPeerId });
return this._shouldEvictForPeer ?? null; // Use stored value from mesh, default to null
}
canAcceptMorePeers() {
// This needs to be determined by the mesh
this.emit('checkCapacity');
return this._canAcceptMorePeers ?? true; // Use stored value from mesh, default to true
}
optimizeMeshConnections(currentPeers) {
if (!this.autoDiscovery) return;
this.debug.log('Optimizing mesh connections...');
// Find unconnected peers that we should initiate connections to
const unconnectedPeers = Array.from(this.discoveredPeers.keys())
.filter(peerId => {
const notConnected = !currentPeers.has(peerId);
const notConnecting = !this.connectionAttempts.has(peerId);
const shouldInitiate = this.shouldInitiateConnection(peerId);
return notConnected && notConnecting && shouldInitiate;
});
if (unconnectedPeers.length === 0) {
this.debug.log('No unconnected peers to optimize');
return;
}
// Sort by XOR distance to prioritize closer peers (if XOR routing enabled)
if (this.xorRouting) {
unconnectedPeers.sort((a, b) => {
const distA = this.calculateXorDistance(this.peerId, a);
const distB = this.calculateXorDistance(this.peerId, b);
return distA < distB ? -1 : 1;
});
}
this.emit('optimizeConnections', { unconnectedPeers });
}
scheduleMeshOptimization() {
if (this.meshOptimizationTimeout) {
clearTimeout(this.meshOptimizationTimeout);
}
// Simple scheduling - optimize every 10-15 seconds
const delay = 10000 + Math.random() * 5000;
this.meshOptimizationTimeout = setTimeout(() => {
this.emit('optimizeMesh');
}, delay);
}
startCleanupInterval() {
if (environmentDetector.isBrowser) {
this.cleanupInterval = window.setInterval(() => {
this.cleanupStaleDiscoveredPeers();
}, 30000);
} else {
this.cleanupInterval = setInterval(() => {
this.cleanupStaleDiscoveredPeers();
}, 30000);
}
}
cleanupStaleDiscoveredPeers() {
const now = Date.now();
const staleThreshold = 5 * 60 * 1000;
let removedCount = 0;
this.discoveredPeers.forEach((timestamp, peerId) => {
if (now - timestamp > staleThreshold) {
this.discoveredPeers.delete(peerId);
this.connectionAttempts.delete(peerId);
removedCount++;
this.debug.log('Removed stale peer:', peerId.substring(0, 8));
}
});
if (removedCount > 0) {
this.emit('peersUpdated', { removedCount });
}
}
getDiscoveredPeers() {
return Array.from(this.discoveredPeers.entries()).map(([peerId, timestamp]) => ({
peerId,
timestamp,
distance: this.calculateXorDistance(this.peerId, peerId),
isConnecting: this.connectionAttempts.has(peerId),
isConnected: false // Will be set by the mesh when it has peer info
}));
}
hasPeer(peerId) {
return this.discoveredPeers.has(peerId);
}
setSettings(settings) {
if (settings.autoDiscovery !== undefined) {
this.autoDiscovery = settings.autoDiscovery;
}
if (settings.evictionStrategy !== undefined) {
this.evictionStrategy = settings.evictionStrategy;
}
if (settings.xorRouting !== undefined) {
this.xorRouting = settings.xorRouting;
}
if (settings.minPeers !== undefined) {
this.minPeers = settings.minPeers;
}
if (settings.maxPeers !== undefined) {
this.maxPeers = settings.maxPeers;
}
}
updateDiscoveryTimestamp(peerId) {
if (this.discoveredPeers.has(peerId)) {
this.discoveredPeers.set(peerId, Date.now());
}
}
debugCurrentState() {
const discoveredPeerIds = Array.from(this.discoveredPeers.keys()).map(p => p.substring(0, 8));
const connectionAttempts = Array.from(this.connectionAttempts.keys()).map(p => p.substring(0, 8));
this.debug.log('=== PEER DISCOVERY DEBUG ===');
this.debug.log(`Our peer ID: ${this.peerId.substring(0, 8)}...`);
this.debug.log(`Discovered peers (${discoveredPeerIds.length}): ${discoveredPeerIds.join(', ')}`);
this.debug.log(`Connection attempts (${connectionAttempts.length}): ${connectionAttempts.join(', ')}`);
// Show which peers we should/shouldn't initiate to
discoveredPeerIds.forEach(peerId => {
const fullPeerId = Array.from(this.discoveredPeers.keys()).find(p => p.startsWith(peerId));
const shouldInitiate = this.shouldInitiateConnection(fullPeerId);
const comparison = this.peerId > fullPeerId;
this.debug.log(` ${peerId}...: should initiate = ${shouldInitiate} (${this.peerId.substring(0, 8)}... > ${peerId}... = ${comparison})`);
});
this.debug.log('=== END DEBUG ===');
}
}