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.
317 lines
11 KiB
TypeScript
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();
|