@mytec: feat: Phase 3.0 Architecture Refactor ✅
Major refactoring of RFCP backend: - Modular propagation models (8 models) - SharedMemoryManager for terrain data - ProcessPoolExecutor parallel processing - WebSocket progress streaming - Building filtering pipeline (351k → 15k) - 82 unit tests Performance: Standard preset 38s → 5s (7.6x speedup) Known issue: Detailed preset timeout (fix in 3.1.0)
This commit is contained in:
156
frontend/src/services/websocket.ts
Normal file
156
frontend/src/services/websocket.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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':
|
||||
pending?.onProgress?.({
|
||||
phase: msg.phase,
|
||||
progress: msg.progress,
|
||||
eta_seconds: msg.eta_seconds,
|
||||
});
|
||||
break;
|
||||
case 'result':
|
||||
pending?.onResult(msg.data);
|
||||
if (calcId) this._pendingCalcs.delete(calcId);
|
||||
break;
|
||||
case 'error':
|
||||
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();
|
||||
Reference in New Issue
Block a user