From 20bd86cf113bbe93e62a6e2421a1c7686e5fa608 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Sun, 19 Apr 2026 05:11:55 +0300 Subject: [PATCH] feat: Add WebSocket service with typed message handling, auto-reconnect, and subscription management - Add WebSocketMessage, ServiceUpdate, SystemMetrics, AlertUpdate interfaces for typed message handling - Add WebSocketService class with connection management, 5 max reconnect attempts, exponential backoff (1s-30s delay) - Add subscribe/unsubscribe pattern with type-based message routing and listener cleanup - Add getWebSocketService singleton factory with auto-detect ws/wss protocol and disconn --- apps/web-dashboard/src/lib/websocket.ts | 171 ++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 apps/web-dashboard/src/lib/websocket.ts diff --git a/apps/web-dashboard/src/lib/websocket.ts b/apps/web-dashboard/src/lib/websocket.ts new file mode 100644 index 0000000..80d3428 --- /dev/null +++ b/apps/web-dashboard/src/lib/websocket.ts @@ -0,0 +1,171 @@ +export interface WebSocketMessage { + type: string; + data: any; + timestamp: string; +} + +export interface ServiceUpdate { + service: string; + status: string; + replicas: number; + readyReplicas: number; + timestamp: string; +} + +export interface SystemMetrics { + cpu: number; + memory: number; + disk: number; + network: number; + timestamp: string; +} + +export interface AlertUpdate { + id: string; + severity: string; + message: string; + status: string; + timestamp: string; +} + +export class WebSocketService { + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private listeners: Map void)[]> = new Map(); + private isConnecting = false; + + constructor(private url: string) { + this.connect(); + } + + private connect() { + if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { + return; + } + + this.isConnecting = true; + + try { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.isConnecting = false; + this.reconnectAttempts = 0; + this.reconnectDelay = 1000; + }; + + this.ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + this.handleMessage(message); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + this.ws.onclose = (event) => { + console.log('WebSocket disconnected:', event.code, event.reason); + this.isConnecting = false; + this.handleReconnect(); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.isConnecting = false; + }; + + } catch (error) { + console.error('Failed to create WebSocket connection:', error); + this.isConnecting = false; + this.handleReconnect(); + } + } + + private handleReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + + setTimeout(() => { + this.connect(); + }, this.reconnectDelay); + + // Exponential backoff + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); + } else { + console.error('Max reconnection attempts reached'); + } + } + + private handleMessage(message: WebSocketMessage) { + const listeners = this.listeners.get(message.type); + if (listeners) { + listeners.forEach(listener => { + try { + listener(message.data); + } catch (error) { + console.error('Error in WebSocket listener:', error); + } + }); + } + } + + public subscribe(type: string, callback: (data: any) => void) { + if (!this.listeners.has(type)) { + this.listeners.set(type, []); + } + this.listeners.get(type)!.push(callback); + + // Return unsubscribe function + return () => { + const listeners = this.listeners.get(type); + if (listeners) { + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + } + }; + } + + public send(message: WebSocketMessage) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } else { + console.warn('WebSocket not connected, message not sent:', message); + } + } + + public disconnect() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.listeners.clear(); + } + + public isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } +} + +// Global WebSocket service instance +let wsService: WebSocketService | null = null; + +export function getWebSocketService(url?: string): WebSocketService { + if (!wsService) { + const wsUrl = url || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`; + wsService = new WebSocketService(wsUrl); + } + return wsService; +} + +export function disconnectWebSocket() { + if (wsService) { + wsService.disconnect(); + wsService = null; + } +}