/** * WebSocket Client Wrapper * * Provides a typed Socket.IO client with: * - Auto-reconnection with exponential backoff * - JWT authentication support * - Connection state management * - Type-safe event emission and listening */ import { io, Socket, ManagerOptions, SocketOptions } from 'socket.io-client' import type { WebSocketClientConfig } from './types' export interface WebSocketState { connected: boolean connecting: boolean error: Error | null } export class WebSocketClient { private socket: Socket | null = null private config: Required private reconnectAttempt = 0 private reconnectTimer: NodeJS.Timeout | null = null private connectionError: Error | null = null private isConnecting = false constructor(config: WebSocketClientConfig) { this.config = { url: config.url, token: config.token || '', reconnection: config.reconnection !== false, reconnectionAttempts: config.reconnectionAttempts || Infinity, reconnectionDelay: config.reconnectionDelay || 1000, reconnectionDelayMax: config.reconnectionDelayMax || 5000, autoConnect: config.autoConnect !== false, } if (this.config.autoConnect) { this.connect() } } /** * Connect to WebSocket server */ connect(): Socket { if (this.socket?.connected) { console.warn('[WebSocketClient] Already connected') return this.socket } this.isConnecting = true this.connectionError = null // Build connection options const socketOptions: Partial = { reconnection: false, // We handle reconnection manually with exponential backoff transports: ['websocket', 'polling'], } // Add authentication token if provided if (this.config.token) { socketOptions.auth = { token: this.config.token } // Also support query param for compatibility socketOptions.query = { token: this.config.token } } // Create socket instance this.socket = io(this.config.url, socketOptions) // Setup connection event handlers this.setupConnectionHandlers() return this.socket } /** * Disconnect from WebSocket server */ disconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } if (this.socket) { this.socket.removeAllListeners() this.socket.disconnect() this.socket = null } this.reconnectAttempt = 0 } /** * Get the underlying Socket.IO socket instance */ getSocket(): Socket | null { return this.socket } /** * Check if currently connected */ isConnected(): boolean { return this.socket?.connected || false } /** * Get current connection state */ getState(): WebSocketState { return { connected: this.socket?.connected || false, connecting: this.isConnecting, error: this.connectionError, } } /** * Setup connection event handlers with auto-reconnect */ private setupConnectionHandlers(): void { if (!this.socket) {return} this.socket.on('connect', () => { console.log('[WebSocketClient] Connected') this.reconnectAttempt = 0 // Reset on successful connection this.isConnecting = false this.connectionError = null }) this.socket.on('disconnect', (reason) => { console.log('[WebSocketClient] Disconnected:', reason) this.isConnecting = false // Attempt reconnection if enabled and not manually disconnected if ( this.config.reconnection && reason !== 'io client disconnect' && this.reconnectAttempt < this.config.reconnectionAttempts ) { this.scheduleReconnect() } }) this.socket.on('connect_error', (error) => { console.error('[WebSocketClient] Connection error:', error.message) this.isConnecting = false this.connectionError = error // Attempt reconnection with exponential backoff if ( this.config.reconnection && this.reconnectAttempt < this.config.reconnectionAttempts ) { this.scheduleReconnect() } }) this.socket.on('error', (error) => { console.error('[WebSocketClient] Socket error:', error) }) } /** * Schedule reconnection with exponential backoff */ private scheduleReconnect(): void { if (this.reconnectTimer) { return // Already scheduled } this.reconnectAttempt++ // Calculate delay with exponential backoff: min(delay * 2^attempt, maxDelay) const delay = Math.min( this.config.reconnectionDelay * Math.pow(2, this.reconnectAttempt - 1), this.config.reconnectionDelayMax, ) console.log( `[WebSocketClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt}/${this.config.reconnectionAttempts})`, ) this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null if (this.socket) { this.socket.connect() } else { this.connect() } }, delay) } /** * Emit an event to the server */ emit( event: string, data?: TData, callback?: (response: TResponse) => void, ): void { if (!this.socket?.connected) { console.warn('[WebSocketClient] Cannot emit - not connected') return } if (callback) { this.socket.emit(event, data, callback) } else { this.socket.emit(event, data) } } /** * Listen for an event from the server */ on(event: string, handler: (data: T) => void): () => void { if (!this.socket) { console.warn('[WebSocketClient] Cannot listen - socket not initialized') return () => {} } this.socket.on(event, handler) // Return cleanup function return () => { if (this.socket) { this.socket.off(event, handler) } } } /** * Listen for an event once */ once(event: string, handler: (data: T) => void): () => void { if (!this.socket) { console.warn('[WebSocketClient] Cannot listen - socket not initialized') return () => {} } this.socket.once(event, handler) // Return cleanup function return () => { if (this.socket) { this.socket.off(event, handler) } } } /** * Remove event listener */ off(event: string, handler?: (...args: unknown[]) => void): void { if (this.socket) { this.socket.off(event, handler) } } }