Files
Claro-cases/src/services/wsClient.ts
T
bryan_garcia 9f86dc0d0d Ran command: git status --short
Ran command: `git diff --cached`
Ran command: `git diff --cached SPECIFICATION.md src/components/layout/AppShell.tsx`

feat(ws): handle nested WS payload envelope and handle assigned conversation events

* Automatically unwrap double-nested WS payloads (`payload.payload`) in `WsClient`.
* Handle `conversation_assigned` WS event in `AppShell` by fetching details via REST.
* Deduplicate `upsertConversation` calls and update conversation handling logic.
* Add comprehensive test suites for WS unwrapping and store deduplication.
2026-07-31 14:13:57 -05:00

317 lines
11 KiB
TypeScript

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<typeof setTimeout> | null = null;
private authTimer: ReturnType<typeof setTimeout> | 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<string, unknown> = 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<string, unknown>).type &&
(data.payload as Record<string, unknown>).payload
) {
// Conservar el type y eventId externos, usar el payload interno
data = {
...data,
payload: (data.payload as Record<string, unknown>).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<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;
}
// ── 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();