import type { WSEnvelope } from '@/types/wsProtocol'; import { auth } from '@/services/auth'; // ───────────────────────────────────────────────────────────── // Configuration // ───────────────────────────────────────────────────────────── const DEFAULT_WS_URL = 'ws://localhost:5503/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'; export type WsAuthState = 'pending' | 'authenticated' | 'failed'; // ───────────────────────────────────────────────────────────── // Event callback types // ───────────────────────────────────────────────────────────── export type MessageCallback = (envelope: WSEnvelope) => void; export type StatusChangeCallback = (status: WsConnectionStatus) => void; export type AuthenticatedCallback = () => void; // ───────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────── const AUTH_TIMEOUT_MS = 5_000; // ───────────────────────────────────────────────────────────── // WebSocket Client // ───────────────────────────────────────────────────────────── class WsClient { private ws: WebSocket | null = null; private status: WsConnectionStatus = 'disconnected'; private onMessageCallback: MessageCallback | null = null; private onStatusChangeCallback: StatusChangeCallback | null = null; private onAuthenticatedCallback: AuthenticatedCallback | null = null; private reconnectAttempts = 0; private reconnectTimer: ReturnType | null = null; private authTimer: ReturnType | null = null; private authState: WsAuthState = 'pending'; private destroyFlag = false; // ── Connection ─────────────────────────────────────────── /** * Initiate (or re-initiate) the WebSocket connection. * If already connected, it will close and reconnect. */ connect(): void { // Guard: skip if already connected or connecting (prevents double-connect in StrictMode) if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } // Check auth token before attempting connection const token = auth.getToken(); if (!token) { console.warn('[WS] No auth token — skipping connection'); return; } this.destroyFlag = false; this.authState = 'pending'; // Paso 0: Connect WITHOUT token in URL — clean WebSocket URL try { this.ws = new WebSocket(WS_URL); } catch (err) { this.setStatus('disconnected'); this.scheduleReconnect(); return; } this.ws.onopen = () => { this.reconnectAttempts = 0; this.setStatus('connected'); // Send In-Band Auth as first message const authMessage = { action: 'auth', token: token, }; this.ws?.send(JSON.stringify(authMessage)); // Start auth timeout: 5s to receive { status: "authenticated" } this.authTimer = setTimeout(() => { if (this.authState !== 'authenticated') { console.warn('[WS] Auth timeout — no auth response within 5s'); this.authState = 'failed'; this.ws?.close(1008, 'Auth timeout'); } }, AUTH_TIMEOUT_MS); }; this.ws.onmessage = (event: MessageEvent) => { try { let data: Record = JSON.parse(event.data as string); // Handle auth response first if (data.status === 'authenticated') { this.authState = 'authenticated'; if (this.authTimer) { clearTimeout(this.authTimer); this.authTimer = null; } // Notify listeners that auth is complete this.onAuthenticatedCallback?.(); return; } // If not yet authenticated, drop business messages if (this.authState !== 'authenticated') { console.warn('[WS] Dropping message — auth not yet complete'); return; } // Normalizar doble envoltura (nested envelope del backend) // El backend envía: { type, eventId, payload: { type, eventId, payload: { datos } } } // Debemos aplanar a: { type, eventId, payload: { datos } } if ( data.payload && typeof data.payload === 'object' && !Array.isArray(data.payload) && !data.status && // NUNCA para mensajes de control como {"status":"authenticated"} (data.payload as Record).type && (data.payload as Record).payload ) { // Conservar el type y eventId externos, usar el payload interno data = { ...data, payload: (data.payload as Record).payload, }; } // Delegate business events to registered callback if (this.onMessageCallback) { const envelope: WSEnvelope = data; this.onMessageCallback(envelope); } } catch { // Malformed message — silently ignore } }; this.ws.onclose = (event: CloseEvent) => { // Clean up auth timer if (this.authTimer) { clearTimeout(this.authTimer); this.authTimer = null; } // Code 1008 = auth failure — transition to failed if (event.code === 1008) { this.authState = 'failed'; this.setStatus('disconnected'); this.scheduleReconnect(); return; } // 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.authTimer !== null) { clearTimeout(this.authTimer); this.authTimer = 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): 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; } // ── Auth ────────────────────────────────────────────────── /** * Register a callback for when In-Band Auth completes successfully. */ set onAuthenticated(cb: AuthenticatedCallback | null) { this.onAuthenticatedCallback = cb; } get onAuthenticated(): AuthenticatedCallback | null { return this.onAuthenticatedCallback; } /** * Get the current auth state. */ getAuthState(): WsAuthState { return this.authState; } // ── 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();