Files
rfcp/frontend/src/services/websocket.ts
2026-02-02 01:55:09 +02:00

168 lines
4.7 KiB
TypeScript

/**
* 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<typeof setTimeout> | undefined;
private _connected = false;
private _pendingCalcs = new Map<string, PendingCalc>();
private _connectionListeners = new Set<ConnectionCallback>();
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<Record<string, unknown>>,
settings: Record<string, unknown>,
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();