UNPKG

@varia-bly/variably-sdk

Version:

Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, LLM experiments with React hooks, and real-time dynamic configurations

433 lines 15.4 kB
/** * WebSocket client for real-time configuration updates */ export class WebSocketClient { constructor(config, logger) { this.ws = null; this.subscriptions = new Map(); this.configCallbacks = new Map(); this.connectionStateCallbacks = new Set(); this.errorCallbacks = new Set(); // Connection management this.connectionState = 'disconnected'; this.reconnectAttempts = 0; this.reconnectTimer = null; this.connectionTimer = null; this.heartbeatTimer = null; this.lastPong = Date.now(); this.config = this.normalizeConfig(config); this.logger = logger; } /** * Connect to WebSocket server */ async connect() { if (this.connectionState === 'connected' || this.connectionState === 'connecting') { return; } return new Promise((resolve, reject) => { try { this.setConnectionState('connecting'); // Create WebSocket URL with JWT token const wsUrl = this.config.baseUrl .replace(/^http/, 'ws') .replace(/\/$/, ''); const url = `${wsUrl}/api/v1/ws`; this.logger.debug('Connecting to WebSocket', { url }); this.ws = new WebSocket(url); // Connection timeout this.connectionTimer = setTimeout(() => { if (this.connectionState === 'connecting') { this.logger.error('WebSocket connection timeout'); this.ws?.close(); reject(new Error('Connection timeout')); } }, this.config.connectionTimeout); this.ws.onopen = () => { this.clearConnectionTimer(); this.reconnectAttempts = 0; this.setConnectionState('connected'); this.startHeartbeat(); this.resubscribeAll(); this.logger.info('WebSocket connected successfully'); resolve(); }; this.ws.onmessage = (event) => { this.handleMessage(event.data); }; this.ws.onclose = (event) => { this.clearConnectionTimer(); this.clearHeartbeat(); this.logger.warn('WebSocket connection closed', { code: event.code, reason: event.reason, wasClean: event.wasClean }); this.setConnectionState('disconnected'); if (this.config.autoReconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) { this.scheduleReconnect(); } }; this.ws.onerror = (event) => { this.clearConnectionTimer(); this.logger.error('WebSocket error', { event }); this.setConnectionState('error'); const error = new Error('WebSocket connection error'); this.notifyError(error); if (this.connectionState === 'connecting') { reject(error); } }; } catch (error) { this.setConnectionState('error'); const wsError = error instanceof Error ? error : new Error('Unknown WebSocket error'); this.logger.error('Failed to create WebSocket connection', { error: wsError.message }); reject(wsError); } }); } /** * Disconnect from WebSocket server */ disconnect() { this.config.autoReconnect = false; this.clearReconnectTimer(); this.clearConnectionTimer(); this.clearHeartbeat(); if (this.ws) { this.ws.close(1000, 'Client initiated disconnect'); this.ws = null; } this.setConnectionState('disconnected'); this.logger.info('WebSocket disconnected'); } /** * Subscribe to configuration updates for a project */ subscribe(projectId, configKey) { const subscriptionId = configKey ? `${projectId}:${configKey}` : projectId; const subscription = { project_id: projectId, config_key: configKey }; this.subscriptions.set(subscriptionId, subscription); if (this.connectionState === 'connected') { this.sendSubscription(subscription); } this.logger.debug('Subscription added', { projectId, configKey }); } /** * Unsubscribe from configuration updates */ unsubscribe(projectId, configKey) { const subscriptionId = configKey ? `${projectId}:${configKey}` : projectId; this.subscriptions.delete(subscriptionId); if (this.connectionState === 'connected') { this.sendMessage({ type: 'unsubscribe', data: configKey ? `config_updates:${projectId}:${configKey}` : `config_updates:${projectId}` }); } this.logger.debug('Subscription removed', { projectId, configKey }); } /** * Add callback for configuration updates */ onConfigUpdate(configKey, callback) { if (!this.configCallbacks.has(configKey)) { this.configCallbacks.set(configKey, new Set()); } this.configCallbacks.get(configKey).add(callback); // Return unsubscribe function return () => { const callbacks = this.configCallbacks.get(configKey); if (callbacks) { callbacks.delete(callback); if (callbacks.size === 0) { this.configCallbacks.delete(configKey); } } }; } /** * Add callback for connection state changes */ onConnectionState(callback) { this.connectionStateCallbacks.add(callback); // Return unsubscribe function return () => { this.connectionStateCallbacks.delete(callback); }; } /** * Add callback for errors */ onError(callback) { this.errorCallbacks.add(callback); // Return unsubscribe function return () => { this.errorCallbacks.delete(callback); }; } /** * Get current connection state */ getConnectionState() { return this.connectionState; } /** * Check if WebSocket is connected */ isConnected() { return this.connectionState === 'connected' && this.ws?.readyState === WebSocket.OPEN; } /** * Handle incoming WebSocket messages */ handleMessage(data) { try { const message = JSON.parse(data); this.logger.debug('WebSocket message received', { type: message.type }); switch (message.type) { case 'config_update': this.handleConfigUpdate(message); break; case 'subscribed': this.logger.debug('Subscription confirmed', { channel: message.channel }); break; case 'unsubscribed': this.logger.debug('Unsubscription confirmed', { channel: message.channel }); break; case 'pong': this.lastPong = Date.now(); this.logger.debug('Pong received'); break; case 'error': this.logger.error('Server error', { data: message.data }); this.notifyError(new Error(message.data?.message || 'Server error')); break; default: this.logger.warn('Unknown message type', { type: message.type }); } } catch (error) { this.logger.error('Failed to parse WebSocket message', { error: error instanceof Error ? error.message : String(error), data }); } } /** * Handle configuration update messages */ handleConfigUpdate(message) { try { const event = message.data; this.logger.info('Configuration update received', { configKey: event.config_key, updateType: event.update_type, version: event.version }); // Notify specific config callbacks const callbacks = this.configCallbacks.get(event.config_key); if (callbacks) { callbacks.forEach(callback => { try { callback(event); } catch (error) { this.logger.error('Config update callback error', { configKey: event.config_key, error: error instanceof Error ? error.message : String(error) }); } }); } // Notify wildcard callbacks (empty string key) const wildcardCallbacks = this.configCallbacks.get('*'); if (wildcardCallbacks) { wildcardCallbacks.forEach(callback => { try { callback(event); } catch (error) { this.logger.error('Wildcard config update callback error', { configKey: event.config_key, error: error instanceof Error ? error.message : String(error) }); } }); } } catch (error) { this.logger.error('Failed to handle config update', { error: error instanceof Error ? error.message : String(error) }); } } /** * Send subscription message */ sendSubscription(subscription) { this.sendMessage({ type: 'subscribe', data: subscription }); } /** * Send message to WebSocket server */ sendMessage(message) { if (!this.isConnected()) { this.logger.warn('Cannot send message: WebSocket not connected', { type: message.type }); return; } try { const fullMessage = { type: message.type || 'unknown', channel: message.channel, data: message.data, timestamp: new Date().toISOString() }; this.ws.send(JSON.stringify(fullMessage)); this.logger.debug('Message sent', { type: fullMessage.type }); } catch (error) { this.logger.error('Failed to send WebSocket message', { error: error instanceof Error ? error.message : String(error), type: message.type }); } } /** * Resubscribe to all active subscriptions after reconnection */ resubscribeAll() { this.subscriptions.forEach(subscription => { this.sendSubscription(subscription); }); this.logger.debug('Resubscribed to all subscriptions', { count: this.subscriptions.size }); } /** * Start heartbeat to keep connection alive */ startHeartbeat() { this.clearHeartbeat(); this.lastPong = Date.now(); this.heartbeatTimer = setInterval(() => { if (this.isConnected()) { // Check if we received a pong recently if (Date.now() - this.lastPong > 60000) { // 60 seconds this.logger.warn('No pong received, connection may be stale'); this.ws?.close(); return; } // Send ping this.sendMessage({ type: 'ping' }); } }, 30000); // Every 30 seconds } /** * Clear heartbeat timer */ clearHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } /** * Schedule reconnection attempt */ scheduleReconnect() { if (this.reconnectTimer) { return; } this.reconnectAttempts++; const delay = Math.min(this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), 30000); this.logger.info(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`, { delay }); this.reconnectTimer = setTimeout(async () => { this.clearReconnectTimer(); try { await this.connect(); } catch (error) { this.logger.error('Reconnection attempt failed', { attempt: this.reconnectAttempts, error: error instanceof Error ? error.message : String(error) }); } }, delay); } /** * Clear reconnection timer */ clearReconnectTimer() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } /** * Clear connection timer */ clearConnectionTimer() { if (this.connectionTimer) { clearTimeout(this.connectionTimer); this.connectionTimer = null; } } /** * Update connection state and notify callbacks */ setConnectionState(state) { if (this.connectionState !== state) { this.connectionState = state; this.logger.debug('Connection state changed', { state }); this.connectionStateCallbacks.forEach(callback => { try { callback(state); } catch (error) { this.logger.error('Connection state callback error', { error: error instanceof Error ? error.message : String(error) }); } }); } } /** * Notify error callbacks */ notifyError(error) { this.errorCallbacks.forEach(callback => { try { callback(error); } catch (callbackError) { this.logger.error('Error callback failed', { error: callbackError instanceof Error ? callbackError.message : String(callbackError) }); } }); } /** * Normalize configuration with defaults */ normalizeConfig(config) { return { baseUrl: config.baseUrl, token: config.token, reconnectInterval: config.reconnectInterval || 5000, maxReconnectAttempts: config.maxReconnectAttempts || 10, connectionTimeout: config.connectionTimeout || 10000, autoReconnect: config.autoReconnect !== false }; } } //# sourceMappingURL=websocket-client.js.map