/** * WebSocket Service for real-time notifications * * Events: * - connection_expired: Connection expired * - connection_deleted: Connection deleted * - connection_will_expire: Connection will expire soon (in 5 min) * - connection_extended: Connection extended * - jwt_will_expire: JWT will expire soon (in 5 min) * - jwt_expired: JWT expired */ import { log } from '../utils/logger'; import { API_CONFIG } from '../config/api'; export type WebSocketEventType = | 'connection_expired' | 'connection_deleted' | 'connection_will_expire' | 'connection_extended' | 'jwt_will_expire' | 'jwt_expired' | 'connected' | 'ping' | 'pong'; export interface WebSocketEvent { type: WebSocketEventType; timestamp: string; data?: any; } export interface WebSocketMessage { type: string; token?: string; timestamp?: string; } export interface ConnectionExpiredEvent { connection_id: string; hostname: string; protocol: string; reason: string; } export interface ConnectionWillExpireEvent { connection_id: string; hostname: string; protocol: string; minutes_remaining: number; } export interface ConnectionExtendedEvent { connection_id: string; hostname: string; new_expires_at: string; additional_minutes: number; } type EventHandler = (event: WebSocketEvent) => void; const WEBSOCKET_CONFIG = { MAX_RECONNECT_ATTEMPTS: 10, INITIAL_RECONNECT_DELAY: 1000, // 1 second MAX_RECONNECT_DELAY: 30000, // 30 seconds PING_INTERVAL: 25000, // 25 seconds CONNECTION_TIMEOUT: 5000, // 5 seconds EXPONENTIAL_BACKOFF_BASE: 2, } as const; const WEBSOCKET_PATH = '/ws/notifications'; const ALL_EVENTS_KEY = '*'; const PROTOCOL_REPLACEMENT = { HTTPS: 'wss://', HTTP: 'ws://', } as const; class WebSocketNotificationService { private ws: WebSocket | null = null; private reconnectTimer: NodeJS.Timeout | null = null; private reconnectAttempts = 0; private readonly maxReconnectAttempts = WEBSOCKET_CONFIG.MAX_RECONNECT_ATTEMPTS; private readonly reconnectDelay = WEBSOCKET_CONFIG.INITIAL_RECONNECT_DELAY; private readonly maxReconnectDelay = WEBSOCKET_CONFIG.MAX_RECONNECT_DELAY; private isIntentionallyDisconnected = false; private pingTimer: NodeJS.Timeout | null = null; private readonly pingInterval = WEBSOCKET_CONFIG.PING_INTERVAL; private eventHandlers: Map> = new Map(); /** * Connect to WebSocket server */ connect(token: string): Promise { return new Promise((resolve, reject) => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { log.info('websocket', 'Already connected'); resolve(); return; } this.isIntentionallyDisconnected = false; // Determine WebSocket URL const wsUrl = API_CONFIG.BASE_URL .replace('https://', PROTOCOL_REPLACEMENT.HTTPS) .replace('http://', PROTOCOL_REPLACEMENT.HTTP); const fullUrl = `${wsUrl}${WEBSOCKET_PATH}`; log.info('websocket', 'Connecting to WebSocket', { url: fullUrl }); try { this.ws = new WebSocket(fullUrl); this.ws.onopen = () => { log.info('websocket', 'WebSocket connected'); // Send authentication if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'auth', token })); log.info('websocket', 'Sent authentication'); // Wait for connected confirmation const connectTimeout = setTimeout(() => { log.error('websocket', 'Connection confirmation timeout'); this.disconnect(); reject(new Error('Connection confirmation timeout')); }, WEBSOCKET_CONFIG.CONNECTION_TIMEOUT); // Temporary handler for connected const tempOnMessage = (event: MessageEvent) => { try { const message: WebSocketEvent = JSON.parse(event.data); if (message.type === 'connected') { clearTimeout(connectTimeout); log.info('websocket', 'Authentication confirmed', message.data); // Reset reconnect attempts this.reconnectAttempts = 0; // Start ping timer this.startPingTimer(); // Set main message handler if (this.ws) { this.ws.onmessage = (e) => this.handleMessage(e); } resolve(); } } catch (error) { log.error('websocket', 'Failed to parse auth response', { error }); } }; this.ws.onmessage = tempOnMessage; } }; this.ws.onerror = (error) => { log.error('websocket', 'WebSocket error', { error }); reject(error); }; this.ws.onclose = (event) => { log.info('websocket', 'WebSocket closed', { code: event.code, reason: event.reason, wasClean: event.wasClean }); this.stopPingTimer(); // Auto-reconnect if not intentionally disconnected if (!this.isIntentionallyDisconnected && this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect(token); } }; } catch (error) { log.error('websocket', 'Failed to create WebSocket', { error }); reject(error); } }); } /** * Disconnect from WebSocket server */ disconnect(): void { this.isIntentionallyDisconnected = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.stopPingTimer(); if (this.ws) { this.ws.close(); this.ws = null; } log.info('websocket', 'Disconnected'); } /** * Schedule reconnection with exponential backoff */ private scheduleReconnect(token: string): void { if (this.reconnectTimer) { return; // Already scheduled } this.reconnectAttempts++; // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... const delay = Math.min( this.reconnectDelay * Math.pow(WEBSOCKET_CONFIG.EXPONENTIAL_BACKOFF_BASE, this.reconnectAttempts - 1), this.maxReconnectDelay ); log.info('websocket', 'Scheduling reconnect', { attempt: this.reconnectAttempts, delay: `${delay}ms` }); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(token).catch((error) => { log.error('websocket', 'Reconnect failed', { error }); }); }, delay); } /** * Start ping timer for keep-alive */ private startPingTimer(): void { this.stopPingTimer(); this.pingTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'ping', timestamp: new Date().toISOString() })); log.debug('websocket', 'Sent ping'); } }, this.pingInterval); } /** * Stop ping timer */ private stopPingTimer(): void{ if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } /** * Handle incoming message */ private handleMessage(event: MessageEvent): void { try { const message: WebSocketEvent = JSON.parse(event.data); if (message.type === 'pong') { log.debug('websocket', 'Received pong'); return; } if (message.type === 'ping') { // Respond with pong if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'pong', timestamp: new Date().toISOString() })); } return; } log.info('websocket', 'Received event', { type: message.type, timestamp: message.timestamp }); // Call handlers for this event type const handlers = this.eventHandlers.get(message.type); if (handlers) { handlers.forEach(handler => { try { handler(message); } catch (error) { log.error('websocket', 'Event handler error', { type: message.type, error }); } }); } // Also call handlers for '*' (all events) const allHandlers = this.eventHandlers.get(ALL_EVENTS_KEY); if (allHandlers) { allHandlers.forEach(handler => { try { handler(message); } catch (error) { log.error('websocket', 'Global event handler error', { type: message.type, error }); } }); } } catch (error) { log.error('websocket', 'Failed to parse message', { error }); } } /** * Subscribe to events */ on(eventType: string, handler: EventHandler): () => void { if (!this.eventHandlers.has(eventType)) { this.eventHandlers.set(eventType, new Set()); } this.eventHandlers.get(eventType)!.add(handler); log.info('websocket', 'Registered event handler', { eventType }); // Return unsubscribe function return () => { const handlers = this.eventHandlers.get(eventType); if (handlers) { handlers.delete(handler); if (handlers.size === 0) { this.eventHandlers.delete(eventType); } } }; } /** * Unsubscribe from all events */ removeAllListeners(): void { this.eventHandlers.clear(); log.info('websocket', 'Removed all event handlers'); } /** * Check connection status */ isConnected(): boolean { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } /** * Get number of reconnect attempts */ getReconnectAttempts(): number { return this.reconnectAttempts; } } // Singleton instance export const websocketNotificationService = new WebSocketNotificationService();