peerpigeon
Version:
WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server
375 lines (324 loc) • 13.3 kB
JavaScript
import { EventEmitter } from './EventEmitter.js';
import { environmentDetector } from './EnvironmentDetector.js';
import DebugLogger from './DebugLogger.js';
export class SignalingClient extends EventEmitter {
constructor(peerId, maxPeers = 10, mesh = null) {
super();
this.debug = DebugLogger.create('SignalingClient');
this.peerId = peerId;
this.maxPeers = maxPeers;
this.mesh = mesh; // Reference to the mesh for peer coordination
this.signalingUrl = null;
this.connected = false;
this.websocket = null;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10; // Increased for better persistence
this.connectionPromise = null;
this.reconnectTimeout = null;
this.isReconnecting = false;
}
setConnectionType(type) {
// WebSocket-only implementation, ignore connection type setting
this.debug.log(`WebSocket-only implementation - connection type setting ignored: ${type}`);
}
createWebSocket(url) {
// Environment-aware WebSocket creation
if (environmentDetector.isNodeJS) {
// Check for globally injected WebSocket first (set by user)
if (typeof global !== 'undefined' && typeof global.WebSocket !== 'undefined') {
return new global.WebSocket(url);
}
if (typeof WebSocket !== 'undefined') {
return new WebSocket(url);
}
// Try to use the 'ws' package if available
try {
if (typeof require !== 'undefined') {
const WebSocket = require('ws');
return new WebSocket(url);
} else {
// In ES modules, dynamic import would be needed
this.debug.warn('WebSocket package detection not available in ES modules. Ensure "ws" is installed.');
throw new Error('WebSocket not available in Node.js ES modules. Install the "ws" package and import it manually.');
}
} catch (error) {
this.debug.warn('ws package not found in Node.js environment. Install with: npm install ws');
throw new Error('WebSocket not available in Node.js. Install the "ws" package.');
}
} else if (environmentDetector.isBrowser || environmentDetector.isWorker || environmentDetector.isNativeScript) {
// In browser, worker, or NativeScript environments, use the native WebSocket
return new WebSocket(url);
} else {
throw new Error('WebSocket not supported in this environment');
}
}
async sendSignalingMessage(message) {
// Check connection status before sending
if (!this.isConnected()) {
this.debug.log('WebSocket not connected, attempting to reconnect...');
if (!this.isReconnecting) {
this.attemptReconnect();
}
throw new Error('WebSocket not connected');
}
const payload = {
type: message.type,
data: message.data,
maxPeers: this.maxPeers,
networkName: this.mesh ? this.mesh.networkName : 'global', // Include network namespace
...(message.targetPeerId && { targetPeerId: message.targetPeerId })
};
try {
this.websocket.send(JSON.stringify(payload));
this.debug.log(`Sent WebSocket message: ${payload.type} (network: ${payload.networkName})`);
return { success: true };
} catch (error) {
this.debug.error('Failed to send WebSocket message:', error);
// Trigger reconnection on send failure
if (!this.isReconnecting) {
this.attemptReconnect();
}
throw error;
}
}
isConnected() {
return this.websocket &&
this.websocket.readyState === WebSocket.OPEN &&
this.connected;
}
async connect(websocketUrl) {
// Validate WebSocket support before attempting connection
if (!environmentDetector.hasWebSocket) {
const error = new Error('WebSocket not supported in this environment');
this.emit('statusChanged', { type: 'error', message: error.message });
throw error;
}
// Prevent multiple simultaneous connection attempts
if (this.connectionPromise) {
this.debug.log('Connection already in progress, waiting for completion...');
return this.connectionPromise;
}
// Convert HTTP/HTTPS URL to WebSocket URL if needed
if (websocketUrl.startsWith('http://')) {
websocketUrl = websocketUrl.replace('http://', 'ws://');
} else if (websocketUrl.startsWith('https://')) {
websocketUrl = websocketUrl.replace('https://', 'wss://');
}
// Ensure WebSocket URL format
if (!websocketUrl.startsWith('ws://') && !websocketUrl.startsWith('wss://')) {
throw new Error('Invalid WebSocket URL format');
}
this.signalingUrl = websocketUrl;
// Add peerId as query parameter
const url = new URL(websocketUrl);
url.searchParams.set('peerId', this.peerId);
this.emit('statusChanged', { type: 'connecting', message: 'Connecting to WebSocket...' });
this.connectionPromise = new Promise((resolve, reject) => {
try {
// Create WebSocket with environment-specific handling
this.websocket = this.createWebSocket(url.toString());
const connectTimeout = setTimeout(() => {
if (this.websocket.readyState === WebSocket.CONNECTING) {
this.websocket.close();
reject(new Error('WebSocket connection timeout'));
}
}, 10000);
this.websocket.onopen = () => {
clearTimeout(connectTimeout);
this.connected = true;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.isReconnecting = false;
this.connectionPromise = null;
this.debug.log('WebSocket connected');
this.emit('statusChanged', { type: 'info', message: 'WebSocket connected' });
// Send announce message
this.sendSignalingMessage({
type: 'announce',
data: { peerId: this.peerId }
}).then(() => {
this.emit('connected');
resolve();
}).catch(error => {
this.debug.error('Failed to send announce message:', error);
this.emit('connected'); // Still emit connected even if announce fails
resolve();
});
};
this.websocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.debug.log(`Received WebSocket message: ${message.type} (network: ${message.networkName || 'unknown'})`);
if (message.type === 'connected') {
// Connection confirmation from server
this.debug.log('WebSocket connection confirmed by server');
} else {
// Filter messages by network namespace
const currentNetwork = this.mesh ? this.mesh.networkName : 'global';
const messageNetwork = message.networkName || 'global';
if (messageNetwork === currentNetwork) {
// Forward signaling message to mesh
this.emit('signalingMessage', message);
} else {
this.debug.log(`Filtered message from different network: ${messageNetwork} (current: ${currentNetwork})`);
}
}
} catch (error) {
this.debug.error('Failed to parse WebSocket message:', error);
}
};
this.websocket.onclose = (event) => {
clearTimeout(connectTimeout);
this.connected = false;
this.connectionPromise = null;
this.debug.log('WebSocket closed:', event.code, event.reason);
if (event.code === 1000) {
// Normal closure
this.emit('disconnected');
} else {
// Abnormal closure - attempt reconnection
this.emit('statusChanged', { type: 'warning', message: 'WebSocket connection lost - reconnecting...' });
if (!this.isReconnecting) {
this.attemptReconnect();
}
}
};
this.websocket.onerror = (error) => {
clearTimeout(connectTimeout);
this.debug.error('WebSocket error:', error);
if (this.websocket.readyState === WebSocket.CONNECTING) {
this.connectionPromise = null;
reject(new Error('WebSocket connection failed'));
} else {
this.emit('statusChanged', { type: 'error', message: 'WebSocket error occurred' });
// Trigger reconnection on error if not already reconnecting
if (!this.isReconnecting) {
this.attemptReconnect();
}
}
};
} catch (error) {
this.connectionPromise = null;
reject(error);
}
});
return this.connectionPromise;
}
attemptReconnect() {
if (this.isReconnecting) {
this.debug.log('Reconnection already in progress');
return;
}
// Check if we have healthy peer connections - if so, be less aggressive with reconnection
const hasHealthyPeers = this.mesh && this.mesh.connectionManager &&
this.mesh.connectionManager.getConnectedPeerCount() > 0;
if (hasHealthyPeers) {
this.debug.log('Have healthy peer connections, reducing reconnection urgency');
}
// Clear any existing reconnect timeout
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.debug.log('Max reconnection attempts reached, using exponential backoff');
// If we have healthy peers, use much longer delays to avoid disrupting the mesh
const baseExtendedDelay = hasHealthyPeers ? 600000 : this.maxReconnectDelay * 2; // 10 min vs 2x normal
const extendedDelay = Math.min(baseExtendedDelay, 600000); // Max 10 minutes
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempts = Math.floor(this.maxReconnectAttempts / 2); // Reset to half max
this.attemptReconnect();
}, extendedDelay);
return;
}
this.isReconnecting = true;
this.reconnectAttempts++;
// Use longer delays if we have healthy peer connections
const baseDelay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
const delayMultiplier = hasHealthyPeers ? 3 : 1; // 3x longer delay if peers are healthy
const delay = Math.min(baseDelay * delayMultiplier, hasHealthyPeers ? 300000 : this.maxReconnectDelay);
this.debug.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}, healthy peers: ${hasHealthyPeers})`);
this.reconnectTimeout = setTimeout(async () => {
if (!this.connected && this.signalingUrl) {
try {
await this.connect(this.signalingUrl);
this.emit('statusChanged', { type: 'info', message: 'WebSocket reconnected successfully' });
} catch (error) {
this.debug.error('Reconnection failed:', error);
this.isReconnecting = false;
this.attemptReconnect();
}
} else {
this.isReconnecting = false;
}
}, delay);
}
disconnect() {
// Clear reconnection state
this.isReconnecting = false;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// The sendGoodbyeMessage is often called from an unload handler,
// so we don't want to make it part of the standard disconnect flow here.
// The CleanupManager handles sending goodbye on unload.
this.connected = false;
this.connectionPromise = null;
if (this.websocket) {
// Clear event handlers to prevent memory leaks
this.websocket.onopen = null;
this.websocket.onmessage = null;
this.websocket.onclose = null;
this.websocket.onerror = null;
this.websocket.close(1000, 'Client disconnect');
this.websocket = null;
}
this.emit('disconnected');
}
sendGoodbyeMessage() {
if (!this.connected) return;
try {
this.debug.log('Sending goodbye message');
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify({
type: 'goodbye',
data: {
peerId: this.peerId,
timestamp: Date.now(),
reason: 'peer_disconnect'
}
}));
}
} catch (error) {
this.debug.error('Failed to send goodbye message:', error);
}
}
async sendCleanupMessage(targetPeerId) {
if (!this.connected) return;
try {
await this.sendSignalingMessage({
type: 'cleanup',
data: {
peerId: this.peerId,
targetPeerId,
timestamp: Date.now(),
reason: 'peer_disconnect'
},
targetPeerId
});
} catch (error) {
this.debug.log(`Cleanup message failed for ${targetPeerId}:`, error.message);
}
}
getConnectionStats() {
return {
connected: this.connected,
isReconnecting: this.isReconnecting,
reconnectAttempts: this.reconnectAttempts,
websocketState: this.websocket ? this.websocket.readyState : 'not created'
};
}
}