peerpigeon
Version:
WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server
1,357 lines (1,142 loc) • 87.3 kB
JavaScript
import { EventEmitter } from './EventEmitter.js';
import { SignalingClient } from './SignalingClient.js';
import { PeerDiscovery } from './PeerDiscovery.js';
import { ConnectionManager } from './ConnectionManager.js';
import { SignalingHandler } from './SignalingHandler.js';
import { EvictionManager } from './EvictionManager.js';
import { MeshOptimizer } from './MeshOptimizer.js';
import { CleanupManager } from './CleanupManager.js';
import { StorageManager } from './StorageManager.js';
import { GossipManager } from './GossipManager.js';
import { MediaManager } from './MediaManager.js';
import { WebDHT } from './WebDHT.js';
import { CryptoManager } from './CryptoManager.js';
import { DistributedStorageManager } from './DistributedStorageManager.js';
import { environmentDetector } from './EnvironmentDetector.js';
import DebugLogger from './DebugLogger.js';
export class PeerPigeonMesh extends EventEmitter {
constructor(options = {}) {
super();
this.debug = DebugLogger.create('PeerPigeonMesh');
// Validate environment capabilities
this.environmentReport = this.validateEnvironment(options);
this.peerId = null;
this.providedPeerId = options.peerId || null;
this.signalingClient = null;
this.peerDiscovery = null;
// Network namespace configuration
this.networkName = options.networkName || 'global';
this.allowGlobalFallback = options.allowGlobalFallback !== false; // Default to true
this.isInFallbackMode = false;
this.originalNetworkName = this.networkName;
// Configuration - Optional features enabled by default (opt-out)
this.maxPeers = options.maxPeers !== undefined ? options.maxPeers : 3;
this.minPeers = options.minPeers !== undefined ? options.minPeers : 2;
// Connectivity floor: ensure each peer obtains at least this many connections for robust gossip
// Default to 3 across browsers (capped by maxPeers) for consistent propagation
const defaultFloor = Math.min(3, this.maxPeers ?? 3);
this.connectivityFloor = options.connectivityFloor !== undefined ? options.connectivityFloor : defaultFloor;
this.autoConnect = options.autoConnect !== false; // Default to true, can be disabled by setting to false
this.autoDiscovery = options.autoDiscovery !== false;
this.evictionStrategy = options.evictionStrategy !== false;
this.xorRouting = options.xorRouting !== false;
this.enableWebDHT = options.enableWebDHT !== false; // Default to true, can be disabled by setting to false
this.enableCrypto = options.enableCrypto !== false; // Default to true, can be disabled by setting to false
// State
this.connected = false;
this.polling = false; // Only WebSocket is supported
this.signalingUrl = null;
this.discoveredPeers = new Map();
// Track ongoing key exchange attempts to prevent duplicates across all channels
this.ongoingKeyExchanges = new Set();
// Track peers for which we've already emitted peerKeyAdded events to prevent UI spam
this.emittedPeerKeyEvents = new Set();
// Gossip stream tracking
this.gossipStreams = new Map(); // streamId -> { chunks: Map, metadata, controller, totalChunks }
// Initialize managers
this.storageManager = new StorageManager(this);
this.mediaManager = new MediaManager();
this.connectionManager = new ConnectionManager(this);
this.evictionManager = new EvictionManager(this, this.connectionManager);
this.meshOptimizer = new MeshOptimizer(this, this.connectionManager, this.evictionManager);
this.cleanupManager = new CleanupManager(this);
this.signalingHandler = new SignalingHandler(this, this.connectionManager);
this.gossipManager = new GossipManager(this, this.connectionManager);
this.webDHT = null; // Will be initialized after peerId is set
this.distributedStorage = null; // Will be initialized after WebDHT is set
// Initialize crypto manager if enabled
this.cryptoManager = null;
if (this.enableCrypto) {
this.cryptoManager = new CryptoManager();
}
// Set up inter-module event forwarding
this.setupManagerEventHandlers();
// Set up unload handlers
this.cleanupManager.setupUnloadHandlers();
// Load saved signaling URL immediately
this.storageManager.loadSignalingUrlFromStorage();
// Connectivity enforcement timer handle
this._connectivityEnforcementTimer = null;
}
setupManagerEventHandlers() {
// Forward events from managers to main mesh
this.connectionManager.addEventListener('peersUpdated', () => {
this.emit('peersUpdated');
});
// Handle peer disconnections
this.addEventListener('peerDisconnected', (data) => {
this.debug.log(`Peer ${data.peerId.substring(0, 8)}... disconnected: ${data.reason}`);
// Clear tracking for this peer to allow fresh key exchange if they reconnect
this.emittedPeerKeyEvents.delete(data.peerId);
this.ongoingKeyExchanges.delete(data.peerId);
});
// Handle gossip messages and intercept mesh signaling
this.gossipManager.addEventListener('messageReceived', (data) => {
// Check if this is a mesh signaling message
if (data.message && data.message.type === 'mesh_signaling') {
this._handleMeshSignalingMessage(data.message, data.from);
return; // Don't emit as regular message
}
// Handle crypto key exchange messages
if (data.message && (data.message.type === 'key_exchange' || data.message.type === 'key_exchange_response')) {
this._handleKeyExchange(data.message, data.from).catch(err => {
this.debug.error('Key exchange handling failed:', err);
});
return; // Don't emit as regular message
}
// Handle DHT messages - route to WebDHT
if (data.message && data.message.type === 'dht' && this.webDHT) {
this.webDHT.handleMessage(data.message, data.from);
return; // Don't emit as regular message
}
// Handle gossip stream chunks
if (data.message && data.message.type === 'stream-chunk') {
this._handleGossipStreamChunk(data.message, data.from);
return; // Don't emit as regular message
}
// Handle gossip stream control messages
if (data.message && data.message.type === 'stream-control') {
this._handleGossipStreamControl(data.message, data.from);
return; // Don't emit as regular message
}
// Additional safety filter for message types that should not be emitted to UI
// These should already be filtered at the GossipManager level, but this provides defense in depth
if (data.content && typeof data.content === 'string') {
try {
const parsedContent = JSON.parse(data.content);
const filteredTypes = ['signaling-relay', 'peer-announce-relay', 'bootstrap-keepalive', 'client-peer-announcement', 'cross-bootstrap-signaling'];
if (filteredTypes.includes(parsedContent.type)) {
console.debug(`🔇 MESH FILTER: Blocked filtered message type '${parsedContent.type}' from UI emission`);
return; // Don't emit to UI
}
} catch (e) {
// Not JSON, continue normally
}
}
this.emit('messageReceived', data);
});
// CRITICAL: Handle remote stream announcements from gossip
// When we hear about a stream from an indirectly connected peer,
// establish a direct connection to receive the media
this.addEventListener('remoteStreamAnnouncement', (data) => {
this._handleRemoteStreamAnnouncement(data);
});
// Forward media events
this.mediaManager.addEventListener('localStreamStarted', (data) => {
this.emit('localStreamStarted', data);
// Gossip stream start announcement to all peers in the mesh
this.gossipManager.broadcastMessage({
event: 'streamStarted',
peerId: this.peerId,
hasVideo: data.hasVideo,
hasAudio: data.hasAudio,
timestamp: Date.now()
}, 'mediaEvent').catch(err => {
this.debug.error('Failed to broadcast stream started event:', err);
});
});
this.mediaManager.addEventListener('localStreamStopped', () => {
this.emit('localStreamStopped');
// Gossip stream stop announcement to all peers in the mesh
this.gossipManager.broadcastMessage({
event: 'streamStopped',
peerId: this.peerId,
timestamp: Date.now()
}, 'mediaEvent').catch(err => {
this.debug.error('Failed to broadcast stream stopped event:', err);
});
});
this.mediaManager.addEventListener('error', (data) => {
this.emit('mediaError', data);
});
// Forward remote stream events from ConnectionManager
this.connectionManager.addEventListener('remoteStream', (data) => {
this.emit('remoteStream', data);
});
// Handle crypto events if crypto is enabled
if (this.cryptoManager) {
this.cryptoManager.addEventListener('cryptoReady', (data) => {
this.emit('cryptoReady', data);
});
this.cryptoManager.addEventListener('cryptoError', (data) => {
this.emit('cryptoError', data);
});
this.cryptoManager.addEventListener('peerKeyAdded', (data) => {
// Only emit peerKeyAdded event once per peer to prevent UI spam from duplicate key exchanges
if (!this.emittedPeerKeyEvents.has(data.peerId)) {
this.emittedPeerKeyEvents.add(data.peerId);
this.emit('peerKeyAdded', data);
}
});
this.cryptoManager.addEventListener('userAuthenticated', (data) => {
this.emit('userAuthenticated', data);
});
}
}
validateEnvironment(options = {}) {
const report = environmentDetector.getEnvironmentReport();
const warnings = [];
const errors = [];
// Log environment info
this.debug.log('🔍 PeerPigeon Environment Detection:', {
runtime: `${report.runtime.isBrowser ? 'Browser' : ''}${report.runtime.isNodeJS ? 'Node.js' : ''}${report.runtime.isWorker ? 'Worker' : ''}${report.runtime.isNativeScript ? 'NativeScript' : ''}`,
webrtc: report.capabilities.webrtc,
websocket: report.capabilities.webSocket,
browser: report.browser?.name || 'N/A',
nativescript: report.nativescript?.platform || 'N/A'
});
// Check WebRTC support (required for peer connections)
if (!report.capabilities.webrtc) {
if (report.runtime.isBrowser) {
errors.push('WebRTC is not supported in this browser. PeerPigeon requires WebRTC for peer-to-peer connections.');
} else if (report.runtime.isNodeJS) {
warnings.push('WebRTC support not detected in Node.js environment. PeerPigeon includes @koush/wrtc for automatic WebRTC support - ensure it is properly installed.');
} else if (report.runtime.isNativeScript) {
warnings.push('WebRTC support not detected in NativeScript environment. Consider using a native WebRTC plugin.');
}
}
// Check WebSocket support (required for signaling)
if (!report.capabilities.webSocket) {
if (report.runtime.isBrowser) {
errors.push('WebSocket is not supported in this browser. PeerPigeon requires WebSocket for signaling.');
} else if (report.runtime.isNodeJS) {
warnings.push('WebSocket support not detected. Install the "ws" package for WebSocket support in Node.js.');
} else if (report.runtime.isNativeScript) {
warnings.push('WebSocket support not detected. Consider using a native WebSocket plugin or polyfill.');
}
}
// Check storage capabilities for persistent peer ID
if ((report.runtime.isBrowser || report.runtime.isNativeScript) && !report.capabilities.localStorage && !report.capabilities.sessionStorage) {
warnings.push('No storage mechanism available. Peer ID will not persist between sessions.');
}
// Check crypto support for secure peer ID generation
if (!report.capabilities.randomValues) {
warnings.push('Crypto random values not available. Peer ID generation may be less secure.');
}
// Network connectivity checks
if (report.runtime.isBrowser && !report.network.online) {
warnings.push('Browser reports offline status. Mesh networking may not function properly.');
}
// Environment-specific warnings
if (report.runtime.isBrowser) {
// Browser-specific checks
const browser = report.browser;
if (browser && browser.name === 'ie') {
errors.push('Internet Explorer is not supported. Please use a modern browser.');
}
// Check for secure context in production
if (typeof location !== 'undefined' && location.protocol === 'http:' && location.hostname !== 'localhost') {
warnings.push('Running on HTTP in production. Some WebRTC features may be limited. Consider using HTTPS.');
}
}
if (report.runtime.isNativeScript) {
// NativeScript-specific checks
const nativeScript = report.nativescript;
if (nativeScript && nativeScript.platform) {
this.debug.log(`🔮 Running on NativeScript ${nativeScript.platform} platform`);
// Platform-specific considerations
if (nativeScript.platform === 'android') {
warnings.push('Android WebRTC may require network permissions and appropriate security configurations.');
} else if (nativeScript.platform === 'ios' || nativeScript.platform === 'visionos') {
warnings.push('iOS/visionOS WebRTC may require camera/microphone permissions for media features.');
}
}
}
// Handle errors and warnings
if (errors.length > 0) {
const errorMessage = 'PeerPigeon environment validation failed:\n' + errors.join('\n');
this.debug.error(errorMessage);
if (!options.ignoreEnvironmentErrors) {
throw new Error(errorMessage);
}
}
if (warnings.length > 0) {
this.debug.warn('PeerPigeon environment warnings:\n' + warnings.join('\n'));
}
// Store capabilities for runtime checks
this.capabilities = report.capabilities;
this.runtimeInfo = report.runtime;
return report;
}
async init() {
try {
// Initialize PigeonRTC for cross-platform WebRTC support
try {
const webrtcInitialized = await environmentDetector.initWebRTCAsync();
if (webrtcInitialized) {
const adapterName = environmentDetector.getPigeonRTC()?.getAdapterName();
this.debug.log(`🌐 PigeonRTC initialized successfully (${adapterName})`);
}
} catch (error) {
this.debug.warn('PigeonRTC initialization failed:', error.message);
}
// Request media permissions for localhost WebRTC to work unless explicitly disabled
// Added DISABLE_LOCALHOST_MEDIA_REQUEST override for automated/headless test environments
if (environmentDetector.isBrowser) {
try {
const disableLocalhostMedia = typeof window !== 'undefined' && window.DISABLE_LOCALHOST_MEDIA_REQUEST;
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '';
if (!disableLocalhostMedia && isLocalhost && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
this.debug.log('🎤 Requesting media permissions for localhost WebRTC (not disabled)...');
// Request minimal audio permission (no video to reduce intrusiveness)
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
}).catch(err => {
this.debug.warn('Media permission denied - connections may fail on localhost:', err.message);
return null;
});
// Immediately stop all tracks - we only needed the permission
if (stream) {
stream.getTracks().forEach(track => track.stop());
this.debug.log('✅ Media permissions granted - localhost connections will work');
}
} else if (disableLocalhostMedia) {
this.debug.log('🚫 Skipping localhost media permission request (DISABLE_LOCALHOST_MEDIA_REQUEST flag set)');
}
} catch (error) {
this.debug.warn('Could not request media permissions:', error.message);
// Continue anyway - non-localhost environments don't need this
}
}
// Use provided peer ID if valid, otherwise generate one
if (this.providedPeerId) {
if (PeerPigeonMesh.validatePeerId(this.providedPeerId)) {
this.peerId = this.providedPeerId;
this.debug.log(`Using provided peer ID: ${this.peerId}`);
} else {
this.debug.warn(`Invalid peer ID provided: ${this.providedPeerId}. Must be 40-character SHA-1 hex string. Generating new one.`);
this.peerId = await PeerPigeonMesh.generatePeerId();
}
} else {
this.peerId = await PeerPigeonMesh.generatePeerId();
}
// Initialize WebDHT now that we have a peerId (if enabled)
if (this.enableWebDHT) {
// Initialize WebDHT - Low-level distributed hash table for raw key-value storage
this.webDHT = new WebDHT(this);
this.debug.log('WebDHT (low-level DHT) initialized and enabled');
// Setup WebDHT event handlers now that it's initialized
this.setupWebDHTEventHandlers();
// Initialize DistributedStorageManager - High-level storage with encryption/access control
// Note: This uses WebDHT as its storage backend but provides a separate high-level API
this.distributedStorage = new DistributedStorageManager(this);
this.debug.log('DistributedStorageManager (high-level encrypted storage) initialized');
// Setup DistributedStorageManager event handlers
this.setupDistributedStorageEventHandlers();
} else {
this.debug.log('WebDHT disabled by configuration');
}
// Load signaling URL from query params or storage
const savedUrl = this.storageManager.loadSignalingUrlFromQuery();
if (savedUrl) {
this.signalingUrl = savedUrl;
}
this.signalingClient = new SignalingClient(this.peerId, this.maxPeers, this);
this.setupSignalingHandlers();
this.peerDiscovery = new PeerDiscovery(this.peerId, {
autoDiscovery: this.autoDiscovery,
evictionStrategy: this.evictionStrategy,
xorRouting: this.xorRouting,
minPeers: this.minPeers,
maxPeers: this.maxPeers
});
this.setupDiscoveryHandlers();
// Start connectivity enforcement loop once discovery is ready
this.startConnectivityEnforcement();
// Initialize crypto manager if enabled
if (this.cryptoManager) {
try {
this.debug.log('🔐 Initializing crypto manager with automatic key persistence...');
// Add timeout to prevent hanging
const cryptoInitPromise = this.cryptoManager.initWithPeerId(this.peerId);
const timeoutPromise = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Crypto initialization timeout')), 10000);
});
await Promise.race([cryptoInitPromise, timeoutPromise]);
this.debug.log('🔐 Crypto manager initialized successfully with persistent keys');
} catch (error) {
this.debug.error('Failed to initialize crypto manager:', error);
// Continue without crypto - don't fail the entire init
this.enableCrypto = false;
this.cryptoManager = null;
}
}
this.emit('statusChanged', { type: 'initialized', peerId: this.peerId });
} catch (error) {
this.debug.error('Failed to initialize mesh:', error);
this.emit('statusChanged', { type: 'error', message: `Initialization failed: ${error.message}` });
throw error;
}
}
setupSignalingHandlers() {
this.signalingClient.addEventListener('connected', () => {
this.connected = true;
this.polling = false;
this.peerDiscovery.start();
this.emit('statusChanged', { type: 'connected' });
});
// IMPORTANT: When the WebSocket signaling connection is lost, we do NOT disconnect peers!
// WebRTC connections are peer-to-peer and remain active even without the signaling server.
// Peers can continue to communicate directly through their existing WebRTC data channels.
// The signaling server is only needed for:
// 1. Initial peer discovery and connection negotiation
// 2. Discovering new peers that join the network
// Once peers are connected via WebRTC, they are independent of the signaling server.
this.signalingClient.addEventListener('disconnected', () => {
this.connected = false;
this.polling = false;
this.peerDiscovery.stop();
// Don't disconnect peers - WebRTC connections are peer-to-peer and persist without signaling server
this.emit('statusChanged', { type: 'disconnected' });
});
this.signalingClient.addEventListener('signalingMessage', (message) => {
this.signalingHandler.handleSignalingMessage(message);
});
this.signalingClient.addEventListener('statusChanged', (data) => {
this.emit('statusChanged', data);
});
}
setupDiscoveryHandlers() {
this.peerDiscovery.addEventListener('peerDiscovered', (data) => {
this.emit('peerDiscovered', data);
// Check if we should return from global fallback to original network
if (this.isInFallbackMode && this.originalNetworkName !== 'global') {
this._tryReturnToOriginalNetwork();
}
});
this.peerDiscovery.addEventListener('connectToPeer', (data) => {
this.debug.log(`PeerDiscovery requested connection to: ${data.peerId.substring(0, 8)}...`);
this.connectionManager.connectToPeer(data.peerId);
});
this.peerDiscovery.addEventListener('evictPeer', (data) => {
this.evictionManager.evictPeer(data.peerId, data.reason);
});
this.peerDiscovery.addEventListener('optimizeMesh', () => {
this.peerDiscovery.optimizeMeshConnections(this.connectionManager.peers);
});
this.peerDiscovery.addEventListener('optimizeConnections', (data) => {
this.meshOptimizer.handleOptimizeConnections(data.unconnectedPeers);
});
// Monitor network health and activate fallback if needed
this.addEventListener('peersUpdated', () => {
this._checkNetworkHealth();
});
this.peerDiscovery.addEventListener('peersUpdated', (data) => {
this.emit('statusChanged', { type: 'info', message: `Cleaned up ${data.removedCount} stale peer(s)` });
this.emit('peersUpdated');
});
// Handle capacity checks
this.peerDiscovery.addEventListener('checkCapacity', () => {
const canAccept = this.connectionManager.canAcceptMorePeers();
const currentConnectionCount = this.connectionManager.getConnectedPeerCount();
this.debug.log(`Capacity check: ${canAccept} (${currentConnectionCount}/${this.maxPeers} peers)`);
this.peerDiscovery._canAcceptMorePeers = canAccept;
this.peerDiscovery._currentConnectionCount = currentConnectionCount;
});
// Handle eviction checks
this.peerDiscovery.addEventListener('checkEviction', (data) => {
const evictPeerId = this.evictionManager.shouldEvictForPeer(data.newPeerId);
this.debug.log(`Eviction check for ${data.newPeerId.substring(0, 8)}...: ${evictPeerId ? evictPeerId.substring(0, 8) + '...' : 'none'}`);
this.peerDiscovery._shouldEvictForPeer = evictPeerId;
});
}
async connect(signalingUrl) {
this.signalingUrl = signalingUrl;
this.storageManager.saveSignalingUrlToStorage(signalingUrl);
this.polling = false; // Only WebSocket is supported
// Don't emit connecting here - SignalingClient will handle it with more detail
try {
await this.signalingClient.connect(signalingUrl);
} catch (error) {
this.debug.error('Connection failed:', error);
this.polling = false;
this.emit('statusChanged', { type: 'error', message: `Connection failed: ${error.message}` });
throw error;
}
}
disconnect() {
if (this.connected) {
this.cleanupManager.sendGoodbyeMessage();
}
this.connected = false;
this.polling = false;
if (this.signalingClient) {
this.signalingClient.disconnect();
}
if (this.peerDiscovery) {
this.peerDiscovery.cleanup(); // Use cleanup() instead of stop() for full cleanup
}
if (this._connectivityEnforcementTimer) {
clearInterval(this._connectivityEnforcementTimer);
this._connectivityEnforcementTimer = null;
}
this.connectionManager.disconnectAllPeers();
this.connectionManager.cleanup();
// WebDHT persists in the mesh - no cleanup needed on disconnect
this.evictionManager.cleanup();
this.cleanupManager.cleanup();
this.gossipManager.cleanup();
this.emit('statusChanged', { type: 'disconnected' });
}
/**
* Periodically attempt extra connections for under-connected peers until reaching connectivityFloor.
*/
startConnectivityEnforcement() {
if (this._connectivityEnforcementTimer) return;
const intervalMs = 4000; // Slower enforcement to avoid connection storms
this._connectivityEnforcementTimer = setInterval(() => {
if (!this.connected || !this.peerDiscovery) return;
const connectedCount = this.connectionManager.getConnectedPeerCount();
if (connectedCount >= this.connectivityFloor) return; // met floor
// Gather candidate peer IDs
const discovered = this.peerDiscovery.getDiscoveredPeers().map(p => p.peerId);
const notConnected = discovered.filter(pid =>
!this.connectionManager.hasPeer(pid) &&
!this.peerDiscovery.isAttemptingConnection(pid)
);
if (notConnected.length === 0) return;
// Prioritize by XOR distance for diversity and stability
const prioritized = notConnected.sort((a, b) => {
const distA = this.peerDiscovery.calculateXorDistance(this.peerId, a);
const distB = this.peerDiscovery.calculateXorDistance(this.peerId, b);
return distA < distB ? -1 : 1;
});
// Conservative: attempt exactly what's needed, one at a time
const needed = this.connectivityFloor - connectedCount;
const attemptLimit = Math.min(prioritized.length, Math.max(1, needed));
const batch = prioritized.slice(0, attemptLimit);
batch.forEach(pid => {
this.debug.log(`🔧 CONNECTIVITY FLOOR (${connectedCount}/${this.connectivityFloor}) attempting extra connection to ${pid.substring(0,8)}...`);
// Use normal connect, but with initiator override below floor
this.connectionManager.connectToPeer(pid);
});
}, intervalMs);
}
// Configuration methods
setMaxPeers(maxPeers) {
this.maxPeers = Math.max(1, Math.min(50, maxPeers));
if (this.connectionManager.peers.size > this.maxPeers) {
this.evictionManager.disconnectExcessPeers();
}
return this.maxPeers;
}
setMinPeers(minPeers) {
this.minPeers = Math.max(0, Math.min(49, minPeers));
// If we're below minimum and auto-discovery is enabled, trigger optimization
if (this.connectionManager.getConnectedPeerCount() < this.minPeers && this.autoDiscovery && this.connected) {
this.peerDiscovery.optimizeMeshConnections(this.connectionManager.peers);
}
return this.minPeers;
}
setXorRouting(enabled) {
this.xorRouting = enabled;
this.emit('statusChanged', { type: 'setting', setting: 'xorRouting', value: enabled });
// If XOR routing is disabled, we might need to adjust our connection strategy
if (!enabled && this.evictionStrategy) {
this.emit('statusChanged', { type: 'warning', message: 'XOR routing disabled - eviction strategy effectiveness reduced' });
}
}
setAutoDiscovery(enabled) {
this.autoDiscovery = enabled;
this.emit('statusChanged', { type: 'setting', setting: 'autoDiscovery', value: enabled });
}
setAutoConnect(enabled) {
this.autoConnect = enabled;
this.emit('statusChanged', { type: 'setting', setting: 'autoConnect', value: enabled });
}
setEvictionStrategy(enabled) {
this.evictionStrategy = enabled;
this.emit('statusChanged', { type: 'setting', setting: 'evictionStrategy', value: enabled });
}
// Network namespace management methods
setNetworkName(networkName) {
if (this.connected) {
throw new Error('Cannot change network name while connected. Disconnect first.');
}
this.networkName = networkName || 'global';
this.originalNetworkName = this.networkName;
this.isInFallbackMode = false;
this.emit('statusChanged', {
type: 'setting',
setting: 'networkName',
value: this.networkName
});
return this.networkName;
}
getNetworkName() {
return this.networkName;
}
getOriginalNetworkName() {
return this.originalNetworkName;
}
isUsingGlobalFallback() {
return this.isInFallbackMode;
}
setAllowGlobalFallback(allow) {
this.allowGlobalFallback = allow;
this.emit('statusChanged', {
type: 'setting',
setting: 'allowGlobalFallback',
value: allow
});
// If we're currently in fallback mode and fallback is disabled, try to return to original network
if (!allow && this.isInFallbackMode) {
this._tryReturnToOriginalNetwork();
}
return this.allowGlobalFallback;
}
async _tryReturnToOriginalNetwork() {
if (!this.isInFallbackMode || this.originalNetworkName === 'global') {
return;
}
// Check if there are now peers in the original network
const originalNetworkPeerCount = await this._getNetworkPeerCount(this.originalNetworkName);
if (originalNetworkPeerCount > 0) {
this.debug.log(`Returning from global fallback to original network: ${this.originalNetworkName}`);
this.networkName = this.originalNetworkName;
this.isInFallbackMode = false;
this.emit('statusChanged', {
type: 'network',
message: `Returned to network: ${this.networkName}`,
networkName: this.networkName,
fallbackMode: false
});
// Trigger reconnection to rebuild mesh in correct network
if (this.connected) {
this.disconnect();
setTimeout(() => {
if (this.signalingUrl) {
this.connect(this.signalingUrl);
}
}, 1000);
}
}
}
async _activateGlobalFallback() {
if (this.originalNetworkName === 'global' || this.isInFallbackMode || !this.allowGlobalFallback) {
return false;
}
this.debug.log(`Activating global fallback from network: ${this.originalNetworkName}`);
this.networkName = 'global';
this.isInFallbackMode = true;
this.emit('statusChanged', {
type: 'network',
message: `Fallback to global network from: ${this.originalNetworkName}`,
networkName: this.networkName,
originalNetwork: this.originalNetworkName,
fallbackMode: true
});
return true;
}
async _getNetworkPeerCount(networkName) {
// This would need to be implemented with signaling server support
// For now, return 0 to indicate we can't determine peer count
return 0;
}
_checkNetworkHealth() {
// TEMPORARILY DISABLED - aggressive fallback for debugging
return;
if (this.originalNetworkName === 'global' || !this.allowGlobalFallback) {
return;
}
const connectedCount = this.connectionManager.getConnectedPeerCount();
const discoveredCount = this.discoveredPeers.size;
// If we're in the original network but have insufficient peers, activate fallback
if (!this.isInFallbackMode && this.networkName === this.originalNetworkName) {
if (connectedCount === 0 && discoveredCount === 0) {
this.debug.log(`Network ${this.originalNetworkName} appears empty, activating global fallback`);
this._activateGlobalFallback().then(activated => {
if (activated && this.connected && this.signalingUrl) {
// Reconnect to signaling server with global network
this.disconnect();
setTimeout(() => {
this.connect(this.signalingUrl);
}, 1000);
}
});
}
}
}
// Status and information methods
getStatus() {
const connectedCount = this.connectionManager.getConnectedPeerCount();
const totalCount = this.connectionManager.peers.size;
return {
peerId: this.peerId,
connected: this.connected,
polling: false, // Only WebSocket is supported
signalingUrl: this.signalingUrl,
networkName: this.networkName,
originalNetworkName: this.originalNetworkName,
isInFallbackMode: this.isInFallbackMode,
allowGlobalFallback: this.allowGlobalFallback,
connectedCount,
totalPeerCount: totalCount, // Include total count for debugging
minPeers: this.minPeers,
maxPeers: this.maxPeers,
discoveredCount: this.discoveredPeers.size,
autoConnect: this.autoConnect,
autoDiscovery: this.autoDiscovery,
evictionStrategy: this.evictionStrategy,
xorRouting: this.xorRouting
};
}
getPeers() {
return this.connectionManager.getPeers();
}
getPeerStatus(peerConnection) {
return peerConnection.getStatus();
}
getDiscoveredPeers() {
if (!this.peerDiscovery) {
return [];
}
const discoveredPeers = this.peerDiscovery.getDiscoveredPeers();
// Enrich with connection state from the actual peer connections
return discoveredPeers.map(peer => {
const peerConnection = this.connectionManager.getPeer(peer.peerId);
let isConnected = false;
if (peerConnection) {
const status = peerConnection.getStatus();
// Consider peer connected if WebRTC connection is established
isConnected = status === 'connected' || status === 'channel-connecting';
}
return {
...peer,
isConnected
};
});
}
/**
* Send a direct message to a specific peer via gossip routing
* @param {string} targetPeerId - The destination peer's ID
* @param {string|object} content - The message content
* @returns {string|null} The message ID if sent, or null on error
*/
async sendDirectMessage(targetPeerId, content) {
if (!targetPeerId || typeof targetPeerId !== 'string') {
this.debug.error('Invalid targetPeerId for direct message');
return null;
}
return await this.gossipManager.sendDirectMessage(targetPeerId, content);
}
/**
* Send a broadcast (gossip) message to all peers
* @param {string|object} content - The message content
* @returns {string|null} The message ID if sent, or null on error
*/
async sendMessage(content) {
// For clarity, this is a broadcast/gossip message
return await this.gossipManager.broadcastMessage(content, 'chat');
}
/**
* Send binary data to a specific peer
* @param {string} targetPeerId - The destination peer's ID
* @param {Uint8Array|ArrayBuffer} binaryData - The binary data to send
* @returns {boolean} True if sent successfully
*/
async sendBinaryData(targetPeerId, binaryData) {
if (!targetPeerId || typeof targetPeerId !== 'string') {
this.debug.error('Invalid targetPeerId for binary message');
return false;
}
const peerConnection = this.connectionManager.peers.get(targetPeerId);
if (!peerConnection) {
this.debug.error(`No connection to peer ${targetPeerId.substring(0, 8)}...`);
return false;
}
return peerConnection.sendMessage(binaryData);
}
/**
* Broadcast binary data to all connected peers
* @param {Uint8Array|ArrayBuffer} binaryData - The binary data to broadcast
* @returns {number} Number of peers the data was sent to
*/
async broadcastBinaryData(binaryData) {
const peers = this.getConnectedPeers();
let sentCount = 0;
for (const peer of peers) {
if (await this.sendBinaryData(peer.peerId, binaryData)) {
sentCount++;
}
}
this.debug.log(`📦 Binary data broadcasted to ${sentCount}/${peers.length} peers`);
return sentCount;
}
/**
* Create a writable stream to send data to a specific peer
* @param {string} targetPeerId - The destination peer's ID
* @param {object} options - Stream options (filename, mimeType, totalSize, etc.)
* @returns {WritableStream} A writable stream
*/
createStreamToPeer(targetPeerId, options = {}) {
if (!targetPeerId || typeof targetPeerId !== 'string') {
throw new Error('Invalid targetPeerId for stream');
}
const peerConnection = this.connectionManager.peers.get(targetPeerId);
if (!peerConnection) {
throw new Error(`No connection to peer ${targetPeerId.substring(0, 8)}...`);
}
return peerConnection.createWritableStream(options);
}
/**
* Send a ReadableStream to a peer
* @param {string} targetPeerId - The destination peer's ID
* @param {ReadableStream} readableStream - The stream to send
* @param {object} options - Stream options (filename, mimeType, etc.)
* @returns {Promise<void>}
*/
async sendStream(targetPeerId, readableStream, options = {}) {
const writableStream = this.createStreamToPeer(targetPeerId, options);
try {
await readableStream.pipeTo(writableStream);
this.debug.log(`✅ Stream sent successfully to ${targetPeerId.substring(0, 8)}...`);
} catch (error) {
this.debug.error(`❌ Failed to send stream to ${targetPeerId.substring(0, 8)}...:`, error);
throw error;
}
}
/**
* Send a File to a peer using streams
* @param {string} targetPeerId - The destination peer's ID
* @param {File} file - The file to send
* @returns {Promise<void>}
*/
async sendFile(targetPeerId, file) {
this.debug.log(`📁 Sending file "${file.name}" (${file.size} bytes) to ${targetPeerId.substring(0, 8)}...`);
const options = {
filename: file.name,
mimeType: file.type,
totalSize: file.size,
type: 'file'
};
const stream = file.stream();
await this.sendStream(targetPeerId, stream, options);
}
/**
* Send a Blob to a peer using streams
* @param {string} targetPeerId - The destination peer's ID
* @param {Blob} blob - The blob to send
* @param {object} options - Additional options
* @returns {Promise<void>}
*/
async sendBlob(targetPeerId, blob, options = {}) {
this.debug.log(`📦 Sending blob (${blob.size} bytes) to ${targetPeerId.substring(0, 8)}...`);
const streamOptions = {
...options,
mimeType: blob.type,
totalSize: blob.size,
type: 'blob'
};
const stream = blob.stream();
await this.sendStream(targetPeerId, stream, streamOptions);
}
/**
* Create a writable stream that broadcasts data to all peers in the mesh using gossip
* @param {object} options - Stream options (filename, mimeType, totalSize, etc.)
* @returns {WritableStream} A writable stream that broadcasts via gossip protocol
*/
createBroadcastStream(options = {}) {
const streamId = options.streamId || this._generateStreamId();
const metadata = {
streamId,
type: options.type || 'broadcast',
filename: options.filename,
mimeType: options.mimeType,
totalSize: options.totalSize,
timestamp: Date.now(),
...options
};
// Store chunks locally for gossip distribution
const chunks = [];
let chunkIndex = 0;
let totalBytesWritten = 0;
const self = this;
this.debug.log(`📡 Creating gossip broadcast stream: ${streamId}`);
return new WritableStream({
async write(chunk) {
const chunkData = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
const chunkId = chunkIndex++;
// Store chunk
chunks.push({
index: chunkId,
data: chunkData
});
totalBytesWritten += chunkData.length;
// Broadcast chunk via gossip protocol
try {
await self.gossipManager.broadcastMessage({
streamId,
chunkIndex: chunkId,
data: Array.from(chunkData), // Convert to regular array for JSON
totalSize: metadata.totalSize,
metadata: chunkId === 0 ? metadata : undefined // Include metadata with first chunk
}, 'stream-chunk');
self.debug.log(`📡 Gossiped chunk ${chunkId} (${chunkData.length} bytes) for stream ${streamId.substring(0, 8)}...`);
} catch (error) {
self.debug.error(`Failed to gossip chunk ${chunkId}:`, error);
throw error;
}
},
async close() {
// Broadcast stream end via gossip
try {
await self.gossipManager.broadcastMessage({
streamId,
action: 'end',
totalChunks: chunkIndex,
totalBytes: totalBytesWritten,
metadata
}, 'stream-control');
self.debug.log(`📡 Broadcast stream completed via gossip: ${totalBytesWritten} bytes in ${chunkIndex} chunks`);
// Emit completion event
self.emit('broadcastStreamComplete', {
streamId,
totalBytes: totalBytesWritten,
totalChunks: chunkIndex,
metadata
});
} catch (error) {
self.debug.error('Failed to broadcast stream end:', error);
throw error;
}
},
async abort(reason) {
// Broadcast stream abort via gossip
try {
await self.gossipManager.broadcastMessage({
streamId,
action: 'abort',
reason: reason?.message || String(reason),
metadata
}, 'stream-control');
self.debug.log(`❌ Broadcast stream aborted via gossip: ${reason}`);
// Emit abort event
self.emit('broadcastStreamAborted', {
streamId,
reason: reason?.message || String(reason),
metadata
});
} catch (error) {
self.debug.error('Failed to broadcast stream abort:', error);
}
}
});
}
/**
* Broadcast a ReadableStream to all connected peers
* @param {ReadableStream} readableStream - The stream to broadcast
* @param {object} options - Stream options (filename, mimeType, etc.)
* @returns {Promise<void>}
*/
async broadcastStream(readableStream, options = {}) {
const writableStream = this.createBroadcastStream(options);
try {
await readableStream.pipeTo(writableStream);
this.debug.log(`✅ Stream broadcasted successfully to all peers`);
} catch (error) {
this.debug.error(`❌ Failed to broadcast stream:`, error);
throw error;
}
}
/**
* Broadcast a File to all connected peers using streams
* @param {File} file - The file to broadcast
* @returns {Promise<void>}
*/
async broadcastFile(file) {
this.debug.log(`📁 Broadcasting file "${file.name}" (${file.size} bytes) to all peers`);
const options = {
filename: file.name,
mimeType: file.type,
totalSize: file.size,
type: 'file'
};
const stream = file.stream();
await this.broadcastStream(stream, options);
}
/**
* Broadcast a Blob to all connected peers using streams
* @param {Blob} blob - The blob to broadcast
* @param {object} options - Additional options
* @returns {Promise<void>}
*/
async broadcastBlob(blob, options = {}) {
this.debug.log(`📦 Broadcasting blob (${blob.size} bytes) to all peers`);
const streamOptions = {
...options,
mimeType: blob.type,
totalSize: blob.size,
type: 'blob'
};
const stream = blob.stream();
await this.broadcastStream(stream, streamOptions);
}
/**
* Generate a unique stream ID
* @private
*/
_generateStreamId() {
return `stream_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
// Helper methods for backward compatibility
canAcceptMorePeers() {
return this.connectionManager.canAcceptMorePeers();
}
getConnectedPeerCount() {
return this.connectionManager.getConnectedPeerCount();
}
// Expose peers Map for backward compatibility
get peers() {
return this.connectionManager.peers;
}
// Get peer status method for UI compatibility
getPeerUIStatus(peer) {
if (!peer) return 'unknown';
return peer.getStatus ? peer.getStatus() : 'unknown';
}
// Get connected peer IDs as array for UI compatibility
getConnectedPeerIds() {
return this.connectionManager.getPeers()
.filter(peer => peer.status === 'connected')
.map(peer => peer.peerId);
}
// Advanced methods
async cleanupStaleSignalingData() {
return this.cleanupManager.cleanupStaleSignalingData();
}
forceConnectToAllPeers() {
return this.meshOptimizer.forceConnectToAllPeers();
}
// Debugging and maintenance methods
forceCleanupInvalidPeers() {
this.debug.log('Force cleaning up peers not in connected state...');
return this.connectionManager.forceCleanupInvalidPeers();
}
cleanupStalePeers() {
this.debug.log('Manually cleaning up stale peers...');
return this.connectionManager.cleanupStalePeers();
}
getPeerStateSummary() {
return this.connectionManager.getPeerStateSummary();
}
debugConnectivity() {
return this.meshOptimizer.debugConnectivity();
}
// Media management methods
async initializeMedia() {
return await this.mediaManager.init();
}
async startMedia(options = {}) {
const { video = false, audio = false, deviceIds = {} } = options;
try {
const stream = await this.mediaManager.startLocalStream({ video, audio, deviceIds });
// Update all existing peer connections with the new stream - but only if crypto allows it
const connections = this.connectionManager.getAllConnections();
this.debug.log(`📡 MEDIA START: Applying stream to ${connections.length} connections (with crypto verification)`);
for (const connection of connections) {
// SECURITY: Only share media if crypto keys are established or crypto is disabled
let shouldShareMedia = true;
if (this.enableCrypto && this.cryptoManager) {
const hasKeys = this.cryptoManager.peerKeys && this.cryptoManager.peerKeys.has(connection.peerId);
if (!hasKeys) {
this.debug.log(`� MEDIA START: Skipping media share with ${connection.peerId.substring(0, 8)}... - no crypto keys established`);
shouldShareMedia = false;
} else {
this.debug.log(`🔒 MEDIA START: Crypto keys verified for ${connection.peerId.substring(0, 8)}... - sharing media`);
}
}
if (shouldShareMedia) {
this.debug.log(`📡 MEDIA START: Setting stream for peer ${connection.peerId.substring(0, 8)}...`);
await connection.setLocalStream(stream);
this.debug.log(`✅ MEDIA START: Stream applied to ${connection.peerId.substring(0, 8)}...`);
// REMOVED: No forced immediate renegotiation - let natural renegotiation handle it
// This prevents cascade renegotiation conflicts when multiple peers join
} // End of shouldShareMedia block
} // End of connections loop
return stream;
} catch (error) {
this.debug.error('Failed to start media:', error);
throw error;
}
}
async stopMedia() {
this.mediaManager.stopLocalStream();
// Update all existing peer connections to remove the stream
const connections = this.connectionManager.getAllConnections();
for (const connection of connections) {
await connection.setLocalStream(null);
}
}
toggleVideo() {
return this.mediaManager.toggleVideo();
}
toggleAudio() {
return this.mediaManager.toggleAudio();
}
getMediaState() {
return this.mediaManager.getMediaState();
}
getMediaDevices() {
return this.mediaManager.devices;
}
async enumerateMediaDevices() {
return await this.mediaManager.enumerateDevices();
}
getLocalStream() {
return this.mediaManager.localStream;
}
// Get remote streams from all connected peers
getRemoteStreams() {
const streams = new Map();
const connections = this.connectionManager.getAllConnections();
for (const connection of connections) {
const remoteStream = connection.getRemoteStream();
if (remoteStream) {
streams.set(connection.peerId, remoteStream);
}
}
return streams;
}
// === SELECTIVE STREAMING CONTROL METHODS ===
/**
* Enable streaming to specific peers only (1:1 or 1:many patterns)
* @param {string|string[]} peerIds - Single peer ID or array of peer IDs to stream to
* @param {Object} options - Stream options (video, audio, deviceIds)
* @returns {Promise<MediaStream>} The local stream
*/
async startSelectiveStream(peerIds, options = {}) {
// Normalize to array
const targetPeerIds = Array.isArray(peerIds) ? peerIds : [peerIds];
this.debug.log(`🎯 SELECTIVE STREAM: Starting stream to ${targetPeerIds.length} specific peer(s)`);
// Start local media stream
const stream = await this.mediaManager.startLocalStream(options);
// Get all connections
const connections = this.connectionManager.getAllConnections();
// Apply stream only to target peers
for (const connection of connections) {
if (targetPeerIds.includes(connection.peerId)) {
this.debug.log(`📡 SELECTIVE STREAM: Enabling stream for target pe