/** * WebSocket hook for real-time bidirectional communication */ import { useState, useEffect, useCallback, useRef } from 'react'; import { StorageService } from '../../services/api/utils/storage.service'; export interface WebSocketMessage { id?: string; type: string; data: any; timestamp: number; } interface WebSocketState { isConnected: boolean; isConnecting: boolean; error: string | null; messages: WebSocketMessage[]; readyState: number; } interface WebSocketOptions { protocols?: string | string[]; reconnectInterval?: number; maxReconnectAttempts?: number; bufferSize?: number; autoConnect?: boolean; heartbeatInterval?: number; messageFilters?: string[]; } interface WebSocketActions { connect: (url: string) => void; disconnect: () => void; reconnect: () => void; send: (data: any, type?: string) => boolean; sendMessage: (message: WebSocketMessage) => boolean; clearMessages: () => void; clearError: () => void; addEventListener: (messageType: string, handler: (data: any) => void) => () => void; } const DEFAULT_OPTIONS: Required = { protocols: [], reconnectInterval: 3000, maxReconnectAttempts: 10, bufferSize: 100, autoConnect: true, heartbeatInterval: 30000, messageFilters: [], }; export const useWebSocket = ( initialUrl?: string, options: WebSocketOptions = {} ): WebSocketState & WebSocketActions => { const [state, setState] = useState({ isConnected: false, isConnecting: false, error: null, messages: [], readyState: WebSocket.CLOSED, }); const webSocketRef = useRef(null); const urlRef = useRef(initialUrl || null); const reconnectTimeoutRef = useRef(null); const heartbeatTimeoutRef = useRef(null); const reconnectAttemptsRef = useRef(0); const messageHandlersRef = useRef void>>>(new Map()); const messageQueueRef = useRef([]); const config = { ...DEFAULT_OPTIONS, ...options }; const storageService = new StorageService(); // Helper function to get auth token const getAuthToken = useCallback(() => { const authData = storageService.getAuthData(); return authData?.access_token || null; }, [storageService]); // Add event listener for specific message types const addEventListener = useCallback((messageType: string, handler: (data: any) => void) => { if (!messageHandlersRef.current.has(messageType)) { messageHandlersRef.current.set(messageType, new Set()); } messageHandlersRef.current.get(messageType)!.add(handler); // Return cleanup function return () => { const handlers = messageHandlersRef.current.get(messageType); if (handlers) { handlers.delete(handler); if (handlers.size === 0) { messageHandlersRef.current.delete(messageType); } } }; }, []); // Process incoming message const processMessage = useCallback((event: MessageEvent) => { try { let messageData: WebSocketMessage; try { const parsed = JSON.parse(event.data); messageData = { id: parsed.id, type: parsed.type || 'message', data: parsed.data || parsed, timestamp: parsed.timestamp || Date.now(), }; } catch { messageData = { type: 'message', data: event.data, timestamp: Date.now(), }; } // Filter messages if messageFilters is specified if (config.messageFilters.length > 0 && !config.messageFilters.includes(messageData.type)) { return; } setState(prev => ({ ...prev, messages: [...prev.messages.slice(-(config.bufferSize - 1)), messageData], })); // Call registered message handlers const handlers = messageHandlersRef.current.get(messageData.type); if (handlers) { handlers.forEach(handler => { try { handler(messageData.data); } catch (error) { console.error('Error in WebSocket message handler:', error); } }); } // Call generic message handlers const messageHandlers = messageHandlersRef.current.get('message'); if (messageHandlers && messageData.type !== 'message') { messageHandlers.forEach(handler => { try { handler(messageData); } catch (error) { console.error('Error in WebSocket message handler:', error); } }); } } catch (error) { console.error('Error processing WebSocket message:', error); } }, [config.messageFilters, config.bufferSize]); // Send heartbeat/ping message const sendHeartbeat = useCallback(() => { if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { const heartbeatMessage: WebSocketMessage = { type: 'ping', data: { timestamp: Date.now() }, timestamp: Date.now(), }; webSocketRef.current.send(JSON.stringify(heartbeatMessage)); } }, []); // Setup heartbeat interval const setupHeartbeat = useCallback(() => { if (config.heartbeatInterval > 0) { heartbeatTimeoutRef.current = window.setInterval(sendHeartbeat, config.heartbeatInterval); } }, [config.heartbeatInterval, sendHeartbeat]); // Clear heartbeat interval const clearHeartbeat = useCallback(() => { if (heartbeatTimeoutRef.current) { window.clearInterval(heartbeatTimeoutRef.current); heartbeatTimeoutRef.current = null; } }, []); // Process queued messages const processMessageQueue = useCallback(() => { while (messageQueueRef.current.length > 0) { const message = messageQueueRef.current.shift(); if (message && webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { webSocketRef.current.send(JSON.stringify(message)); } } }, []); // Connect to WebSocket endpoint const connect = useCallback((url: string) => { if (webSocketRef.current) { disconnect(); } urlRef.current = url; setState(prev => ({ ...prev, isConnecting: true, error: null })); try { // Build URL with auth token if available const wsUrl = new URL(url); const authToken = getAuthToken(); if (authToken) { wsUrl.searchParams.set('token', authToken); } const webSocket = new WebSocket( wsUrl.toString(), Array.isArray(config.protocols) ? config.protocols : [config.protocols].filter(Boolean) ); webSocketRef.current = webSocket; webSocket.onopen = () => { setState(prev => ({ ...prev, isConnected: true, isConnecting: false, error: null, readyState: WebSocket.OPEN, })); reconnectAttemptsRef.current = 0; // Clear reconnect timeout if it exists if (reconnectTimeoutRef.current) { window.clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } // Setup heartbeat setupHeartbeat(); // Process any queued messages processMessageQueue(); }; webSocket.onmessage = processMessage; webSocket.onclose = (event) => { setState(prev => ({ ...prev, isConnected: false, isConnecting: false, readyState: WebSocket.CLOSED, })); clearHeartbeat(); // Attempt reconnection if not a clean close and within limits if (!event.wasClean && reconnectAttemptsRef.current < config.maxReconnectAttempts) { reconnectAttemptsRef.current += 1; setState(prev => ({ ...prev, error: `Conexión perdida. Reintentando... (${reconnectAttemptsRef.current}/${config.maxReconnectAttempts})`, })); reconnectTimeoutRef.current = window.setTimeout(() => { if (urlRef.current) { connect(urlRef.current); } }, config.reconnectInterval); } else if (reconnectAttemptsRef.current >= config.maxReconnectAttempts) { setState(prev => ({ ...prev, error: `Máximo número de intentos de reconexión alcanzado (${config.maxReconnectAttempts})`, })); } }; webSocket.onerror = (error) => { console.error('WebSocket error:', error); setState(prev => ({ ...prev, error: 'Error de conexión WebSocket', readyState: webSocket.readyState, })); }; // Update ready state periodically const stateInterval = setInterval(() => { if (webSocketRef.current) { setState(prev => ({ ...prev, readyState: webSocketRef.current!.readyState, })); } else { clearInterval(stateInterval); } }, 1000); } catch (error) { setState(prev => ({ ...prev, isConnecting: false, error: 'Error al establecer conexión WebSocket', readyState: WebSocket.CLOSED, })); } }, [getAuthToken, config.protocols, config.maxReconnectAttempts, config.reconnectInterval, processMessage, setupHeartbeat, clearHeartbeat, processMessageQueue]); // Disconnect from WebSocket const disconnect = useCallback(() => { if (webSocketRef.current) { webSocketRef.current.close(1000, 'Manual disconnect'); webSocketRef.current = null; } if (reconnectTimeoutRef.current) { window.clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } clearHeartbeat(); setState(prev => ({ ...prev, isConnected: false, isConnecting: false, readyState: WebSocket.CLOSED, })); reconnectAttemptsRef.current = 0; }, [clearHeartbeat]); // Reconnect to WebSocket const reconnect = useCallback(() => { if (urlRef.current) { disconnect(); setTimeout(() => { connect(urlRef.current!); }, 100); } }, [connect, disconnect]); // Send raw data const send = useCallback((data: any, type: string = 'message'): boolean => { const message: WebSocketMessage = { type, data, timestamp: Date.now(), }; return sendMessage(message); }, []); // Send structured message const sendMessage = useCallback((message: WebSocketMessage): boolean => { if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { try { webSocketRef.current.send(JSON.stringify(message)); return true; } catch (error) { console.error('Error sending WebSocket message:', error); return false; } } else { // Queue message for later if not connected messageQueueRef.current.push(message); return false; } }, []); // Clear messages buffer const clearMessages = useCallback(() => { setState(prev => ({ ...prev, messages: [] })); }, []); // Clear error const clearError = useCallback(() => { setState(prev => ({ ...prev, error: null })); }, []); // Auto-connect on mount if URL provided useEffect(() => { if (initialUrl && config.autoConnect) { connect(initialUrl); } return () => { disconnect(); }; }, [initialUrl, config.autoConnect]); // Cleanup on unmount useEffect(() => { return () => { disconnect(); messageHandlersRef.current.clear(); messageQueueRef.current = []; }; }, [disconnect]); // Auto-reconnect when auth token changes useEffect(() => { if (state.isConnected && urlRef.current) { reconnect(); } }, [getAuthToken()]); return { ...state, connect, disconnect, reconnect, send, sendMessage, clearMessages, clearError, addEventListener, }; };