/** * Singleton WebSocket service for real-time coverage calculation. * * Manages a persistent WebSocket connection and provides calculate/cancel * methods usable from Zustand stores (not just React components). * * Usage: * import { wsService } from '@/services/websocket'; * wsService.connect(); * wsService.calculate(sites, settings, onResult, onError, onProgress); */ import { getApiBaseUrl } from '@/lib/desktop.ts'; import type { CoverageResponse } from '@/services/api.ts'; export interface WSProgress { phase: string; progress: number; eta_seconds?: number; } type ProgressCallback = (progress: WSProgress) => void; type ResultCallback = (data: CoverageResponse) => void; type ErrorCallback = (error: string) => void; type ConnectionCallback = (connected: boolean) => void; interface PendingCalc { onProgress?: ProgressCallback; onResult: ResultCallback; onError: ErrorCallback; } class WebSocketService { private ws: WebSocket | null = null; private reconnectTimer: ReturnType | undefined; private _connected = false; private _pendingCalcs = new Map(); private _connectionListeners = new Set(); get connected(): boolean { return this._connected; } /** Register a listener for connection state changes. Returns unsubscribe fn. */ onConnectionChange(cb: ConnectionCallback): () => void { this._connectionListeners.add(cb); return () => this._connectionListeners.delete(cb); } private _setConnected(val: boolean): void { this._connected = val; for (const cb of this._connectionListeners) { try { cb(val); } catch { /* ignore */ } } } connect(): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) return; if (this.ws && this.ws.readyState === WebSocket.CONNECTING) return; const apiBase = getApiBaseUrl(); const url = apiBase.replace(/\/api\/?$/, '').replace(/^http/, 'ws') + '/ws'; try { this.ws = new WebSocket(url); } catch { this.reconnectTimer = setTimeout(() => this.connect(), 3000); return; } this.ws.onopen = () => { this._setConnected(true); }; this.ws.onclose = () => { this._setConnected(false); this.reconnectTimer = setTimeout(() => this.connect(), 2000); }; this.ws.onerror = () => { // onclose will handle reconnect }; this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); const calcId: string | undefined = msg.calculation_id; const pending = calcId ? this._pendingCalcs.get(calcId) : undefined; switch (msg.type) { case 'progress': if (pending?.onProgress) { pending.onProgress({ phase: msg.phase, progress: msg.progress, eta_seconds: msg.eta_seconds, }); } else { console.warn('[WS] progress msg but no pending calc:', calcId, msg.phase, msg.progress); } break; case 'result': if (pending) { pending.onResult(msg.data); } else { console.warn('[WS] result msg but no pending calc:', calcId); } if (calcId) this._pendingCalcs.delete(calcId); break; case 'error': console.error('[WS] error:', msg.message); if (pending) { pending.onError(msg.message); } if (calcId) this._pendingCalcs.delete(calcId); break; } } catch { // Ignore parse errors } }; } disconnect(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.ws?.close(); this.ws = null; this._setConnected(false); } /** * Send a coverage calculation request via WebSocket. * Returns the calculation ID, or undefined if WS is not connected. */ calculate( sites: Array>, settings: Record, onResult: ResultCallback, onError: ErrorCallback, onProgress?: ProgressCallback, ): string | undefined { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return undefined; } const calcId = crypto.randomUUID(); this._pendingCalcs.set(calcId, { onProgress, onResult, onError }); this.ws.send(JSON.stringify({ type: 'calculate', id: calcId, sites, settings, })); return calcId; } /** Cancel a running calculation by ID. */ cancel(calcId: string): void { this.ws?.send(JSON.stringify({ type: 'cancel', id: calcId })); this._pendingCalcs.delete(calcId); } } /** Singleton WebSocket service. */ export const wsService = new WebSocketService();