feat: migrar dashboard a React 19 + TypeScript + Vite + Tailwind v4
- Módulo HITL (/cases): 6 patrones de formularios dinámicos para 53 tipos de caso con validación Zod - Módulo Monitor (/monitor): streaming token-a-token en tiempo real, auto-scroll y notas internas vía WebSocket - Arquitectura híbrida: REST (canal autoritativo) + WebSocket (difusión/streaming) - MSW para desarrollo sin backend, hooks de notificaciones/sonido/título preservados - Backend legacy movido a legacy/, archivos residuales eliminados de raíz
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import type { WSEnvelope } from '@/types/wsProtocol';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Configuration
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_WS_URL = 'ws://localhost:3000/ws/dashboard';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL;
|
||||
|
||||
const INITIAL_BACKOFF_MS = 1_000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BACKOFF_FACTOR = 2;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Connection status
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type WsConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Event callback types
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type MessageCallback = (envelope: WSEnvelope) => void;
|
||||
export type StatusChangeCallback = (status: WsConnectionStatus) => void;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// WebSocket Client
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private status: WsConnectionStatus = 'disconnected';
|
||||
private onMessageCallback: MessageCallback | null = null;
|
||||
private onStatusChangeCallback: StatusChangeCallback | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private destroyFlag = false;
|
||||
|
||||
// ── Connection ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initiate (or re-initiate) the WebSocket connection.
|
||||
* If already connected, it will close and reconnect.
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
return; // already connected
|
||||
}
|
||||
|
||||
this.destroyFlag = false;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
} catch (err) {
|
||||
this.setStatus('disconnected');
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.setStatus('connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
if (!this.onMessageCallback) return;
|
||||
|
||||
try {
|
||||
const envelope: WSEnvelope = JSON.parse(event.data as string);
|
||||
this.onMessageCallback(envelope);
|
||||
} catch {
|
||||
// Malformed message — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
// Only transition to reconnecting if we didn't intentionally close
|
||||
if (!this.destroyFlag) {
|
||||
this.setStatus('reconnecting');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onerror will be followed by onclose, so we let onclose handle it
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close the WebSocket connection.
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.destroyFlag = true;
|
||||
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // prevent reconnect trigger
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
// ── Send ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a typed event through the WebSocket connection.
|
||||
* Automatically wraps the payload in the standard WSEnvelope.
|
||||
*/
|
||||
send(type: string, payload: Record<string, unknown>): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(
|
||||
'[WS] Cannot send — socket is not open. Status:',
|
||||
this.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const envelope: WSEnvelope = {
|
||||
type,
|
||||
eventId: crypto.randomUUID(),
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload,
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
// ── Callbacks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a callback for incoming messages.
|
||||
*/
|
||||
set onMessage(cb: MessageCallback | null) {
|
||||
this.onMessageCallback = cb;
|
||||
}
|
||||
|
||||
get onMessage(): MessageCallback | null {
|
||||
return this.onMessageCallback;
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current connection status.
|
||||
*/
|
||||
getStatus(): WsConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for connection status changes.
|
||||
*/
|
||||
set onStatusChange(cb: StatusChangeCallback | null) {
|
||||
this.onStatusChangeCallback = cb;
|
||||
}
|
||||
|
||||
get onStatusChange(): StatusChangeCallback | null {
|
||||
return this.onStatusChangeCallback;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────
|
||||
|
||||
private setStatus(status: WsConnectionStatus): void {
|
||||
this.status = status;
|
||||
this.onStatusChangeCallback?.(status);
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.destroyFlag) return;
|
||||
|
||||
const delay = Math.min(
|
||||
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_FACTOR, this.reconnectAttempts),
|
||||
MAX_BACKOFF_MS,
|
||||
);
|
||||
|
||||
this.reconnectAttempts += 1;
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (!this.destroyFlag) {
|
||||
this.setStatus('reconnecting');
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Singleton export
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const wsClient = new WsClient();
|
||||
Reference in New Issue
Block a user