UNPKG

@klastra/ksync

Version:

Real-time sync engine for modern applications with 300k+ ops/sec performance

350 lines 14.4 kB
import { KSyncError } from '../types.js'; export class WebSocketSyncClient { serverUrl; maxReconnectAttempts; reconnectDelay; debug; suppressExpectedErrors; ws; isConnecting = false; reconnectAttempts = 0; reconnectTimeout; pingInterval; healthCheckInterval; // Circuit breaker state isCircuitBreakerOpen = false; circuitBreakerOpenTime; circuitBreakerTimeout = 60000; // 1 minute consecutiveFailures = 0; maxConsecutiveFailures = 3; // Reduced from 5 for better testing messageHandlers = []; connectHandlers = []; disconnectHandlers = []; // Connection health and monitoring healthMetrics = { connectionsAttempted: 0, connectionsSuccessful: 0, disconnections: 0, messagesSent: 0, messagesReceived: 0, averageLatency: 0 }; // Rate limiting rateLimitConfig = { maxMessagesPerSecond: 100, maxBurstSize: 20, penaltyDelayMs: 1000 }; messageTimestamps = []; isRateLimited = false; constructor(serverUrl, maxReconnectAttempts = 10, // Increased from 5 reconnectDelay = 1000, debug = false, rateLimitConfig, suppressExpectedErrors = false // New: suppress expected connection failures ) { this.serverUrl = serverUrl; this.maxReconnectAttempts = maxReconnectAttempts; this.reconnectDelay = reconnectDelay; this.debug = debug; this.suppressExpectedErrors = suppressExpectedErrors; if (rateLimitConfig) { this.rateLimitConfig = { ...this.rateLimitConfig, ...rateLimitConfig }; } } async connect() { // Check circuit breaker if (this.isCircuitBreakerOpen) { if (this.circuitBreakerOpenTime && Date.now() - this.circuitBreakerOpenTime < this.circuitBreakerTimeout) { throw new KSyncError('Circuit breaker is open - too many failures', 'CIRCUIT_BREAKER_OPEN'); } else { // Reset circuit breaker after timeout this.resetCircuitBreaker(); } } if (this.isConnected() || this.isConnecting) { return; } this.isConnecting = true; this.healthMetrics.connectionsAttempted++; return new Promise((resolve, reject) => { // Use exponential backoff for timeout based on attempt number const baseTimeout = 3000; const maxTimeout = 30000; const timeoutMs = Math.min(baseTimeout * Math.pow(2, this.reconnectAttempts), maxTimeout); const connectionTimeout = setTimeout(() => { this.isConnecting = false; if (this.ws) { this.ws.close(); } this.handleConnectionFailure(); reject(new KSyncError(`Connection timeout after ${timeoutMs}ms`, 'CONNECTION_TIMEOUT')); }, timeoutMs); try { this.ws = new WebSocket(this.serverUrl); this.ws.onopen = () => { clearTimeout(connectionTimeout); this.isConnecting = false; this.reconnectAttempts = 0; this.consecutiveFailures = 0; // Reset failure count on success this.healthMetrics.connectionsSuccessful++; this.startHealthMonitoring(); this.log('Connected to server'); this.connectHandlers.forEach(handler => handler()); resolve(); }; this.ws.onmessage = (event) => { this.healthMetrics.messagesReceived++; try { const message = JSON.parse(event.data); // Handle pong for latency measurement if (message.type === 'pong' && this.healthMetrics.lastPingTime) { const latency = Date.now() - this.healthMetrics.lastPingTime; this.healthMetrics.averageLatency = (this.healthMetrics.averageLatency + latency) / 2; this.healthMetrics.lastPongTime = Date.now(); return; } this.messageHandlers.forEach(handler => handler(message)); } catch (error) { this.log(`Failed to parse message: ${error}`); } }; this.ws.onclose = (event) => { clearTimeout(connectionTimeout); this.isConnecting = false; this.stopHealthMonitoring(); this.healthMetrics.disconnections++; this.log(`Disconnected from server: ${event.code} - ${event.reason}`); this.disconnectHandlers.forEach(handler => handler()); // Only schedule reconnect if we've had successful connections before // AND we haven't hit the reconnect limit if (this.healthMetrics.connectionsSuccessful > 0 && this.reconnectAttempts < this.maxReconnectAttempts && !this.isCircuitBreakerOpen) { this.scheduleReconnect(); } }; this.ws.onerror = (error) => { clearTimeout(connectionTimeout); this.isConnecting = false; // Better error logging - works in both browser and Node.js let errorMessage; if (typeof ErrorEvent !== 'undefined' && error instanceof ErrorEvent) { errorMessage = `${error.type}: ${error.message || 'WebSocket connection failed'}`; } else if (error && typeof error === 'object' && 'type' in error) { errorMessage = `WebSocket ${error.type}: Connection failed`; } else { errorMessage = `WebSocket error: Connection failed`; } // Only log if not suppressing expected errors or if debug is enabled if (!this.suppressExpectedErrors || this.debug) { this.log(errorMessage); } this.handleConnectionFailure(); // Always reject on the first connection attempt error if (this.reconnectAttempts === 0) { reject(new KSyncError('Failed to connect', 'CONNECTION_ERROR')); } }; } catch (error) { clearTimeout(connectionTimeout); this.isConnecting = false; this.handleConnectionFailure(); reject(new KSyncError('Failed to create WebSocket', 'CONNECTION_ERROR')); } }); } async disconnect() { this.stopHealthMonitoring(); // Clear all timers and reset state if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = undefined; } // Reset connection state this.isConnecting = false; this.reconnectAttempts = 0; this.resetCircuitBreaker(); if (this.ws) { this.ws.close(1000, 'Client disconnect'); // Normal closure this.ws = undefined; } this.log('Disconnected and cleaned up all resources'); } async send(message) { if (!this.isConnected()) { throw new KSyncError('Not connected', 'NOT_CONNECTED'); } // Apply rate limiting if (this.isRateLimited) { throw new KSyncError('Rate limited', 'RATE_LIMITED'); } if (!this.checkRateLimit()) { this.isRateLimited = true; setTimeout(() => { this.isRateLimited = false; }, this.rateLimitConfig.penaltyDelayMs); throw new KSyncError('Rate limit exceeded', 'RATE_LIMITED'); } try { this.ws.send(JSON.stringify(message)); this.healthMetrics.messagesSent++; this.recordMessageTimestamp(); } catch (error) { throw new KSyncError('Failed to send message', 'SEND_ERROR'); } } isConnected() { return this.ws?.readyState === WebSocket.OPEN; } onMessage(callback) { this.messageHandlers.push(callback); } onConnect(callback) { this.connectHandlers.push(callback); } onDisconnect(callback) { this.disconnectHandlers.push(callback); } // Get connection health metrics getHealthMetrics() { return { ...this.healthMetrics }; } // Get detailed connection status getConnectionStatus() { return { connected: this.isConnected(), connecting: this.isConnecting, reconnectAttempts: this.reconnectAttempts, maxReconnectAttempts: this.maxReconnectAttempts, rateLimited: this.isRateLimited, health: this.getHealthMetrics(), readyState: this.ws?.readyState, url: this.serverUrl }; } scheduleReconnect() { // Check if we should stop reconnecting if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.log('Max reconnect attempts reached'); this.openCircuitBreaker(); return; } // Check if circuit breaker is open if (this.isCircuitBreakerOpen) { this.log('Circuit breaker is open, not scheduling reconnect'); return; } // Clear any existing reconnect timeout if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); } this.reconnectAttempts++; // Exponential backoff with jitter const baseDelay = this.reconnectDelay; const exponentialDelay = baseDelay * Math.pow(2, this.reconnectAttempts - 1); const maxDelay = 30000; // Cap at 30 seconds const jitter = Math.random() * 1000; // Add up to 1 second of jitter const delay = Math.min(exponentialDelay, maxDelay) + jitter; this.log(`Reconnecting in ${delay.toFixed(0)}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); this.reconnectTimeout = setTimeout(async () => { this.reconnectTimeout = undefined; try { await this.connect(); this.log('Reconnect successful'); } catch (error) { this.log(`Reconnect failed: ${error}`); // 🔥 FIXED: No more infinite recursion! // Instead of calling scheduleReconnect() again, we let the onclose handler // decide whether to schedule another attempt based on connection state // If we've exhausted all attempts, open the circuit breaker if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.openCircuitBreaker(); } } }, delay); } handleConnectionFailure() { this.consecutiveFailures++; // Only log if not suppressing expected errors or if debug is enabled if (!this.suppressExpectedErrors || this.debug) { this.log(`Connection failure ${this.consecutiveFailures}/${this.maxConsecutiveFailures}`); } if (this.consecutiveFailures >= this.maxConsecutiveFailures) { this.openCircuitBreaker(); } } openCircuitBreaker() { this.isCircuitBreakerOpen = true; this.circuitBreakerOpenTime = Date.now(); // Only log if not suppressing expected errors or if debug is enabled if (!this.suppressExpectedErrors || this.debug) { this.log(`Circuit breaker opened due to ${this.consecutiveFailures} consecutive failures`); } } resetCircuitBreaker() { this.isCircuitBreakerOpen = false; this.circuitBreakerOpenTime = undefined; this.consecutiveFailures = 0; this.reconnectAttempts = 0; this.log('Circuit breaker reset'); } startHealthMonitoring() { // Start ping/pong for connection health this.pingInterval = setInterval(async () => { if (this.isConnected()) { try { this.healthMetrics.lastPingTime = Date.now(); // Send a ping message through the protocol await this.send({ type: 'ping' }); } catch (error) { this.log(`Ping failed: ${error}`); } } }, 30000); // Ping every 30 seconds // Health check interval this.healthCheckInterval = setInterval(() => { if (this.debug) { this.log(`Health metrics: ${JSON.stringify(this.healthMetrics)}`); } }, 60000); // Log health every minute in debug mode } stopHealthMonitoring() { if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = undefined; } if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = undefined; } } checkRateLimit() { const now = Date.now(); const oneSecondAgo = now - 1000; // Remove old timestamps this.messageTimestamps = this.messageTimestamps.filter(ts => ts > oneSecondAgo); // Check if we're under the rate limit return this.messageTimestamps.length < this.rateLimitConfig.maxMessagesPerSecond; } recordMessageTimestamp() { this.messageTimestamps.push(Date.now()); // Keep only recent timestamps for burst checking if (this.messageTimestamps.length > this.rateLimitConfig.maxBurstSize) { this.messageTimestamps.shift(); } } log(message) { if (this.debug) { console.log(`[WebSocketSync] ${message}`); } } } //# sourceMappingURL=websocket-client.js.map