// Created by: Claude // Date: 2026-01-03 // Purpose: Hook personnalisé pour la gestion WebSocket avec reconnexion // Refs: client/CLAUDE.md, docs/protocol_events_v_2.md import { useEffect, useRef, useState, useCallback } from 'react' import { useAuthStore } from '../stores/authStore' /** * Événement WebSocket structuré selon le protocole Mesh. */ export interface WebSocketEvent { type: string id: string timestamp: string from: string to: string payload: any } /** * Options pour le hook useWebSocket. */ interface UseWebSocketOptions { url?: string autoConnect?: boolean reconnectDelay?: number maxReconnectAttempts?: number onMessage?: (event: WebSocketEvent) => void onConnect?: () => void onDisconnect?: () => void onError?: (error: Event) => void } /** * État de connexion WebSocket. */ export enum ConnectionStatus { DISCONNECTED = 'disconnected', CONNECTING = 'connecting', CONNECTED = 'connected', RECONNECTING = 'reconnecting', ERROR = 'error', } /** * Hook personnalisé pour gérer la connexion WebSocket. * * Fonctionnalités: * - Connexion automatique avec le token JWT * - Reconnexion automatique en cas de déconnexion * - Gestion des événements structurés * - Envoi d'événements typés */ export const useWebSocket = (options: UseWebSocketOptions = {}) => { const { url = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws', autoConnect = true, reconnectDelay = 3000, maxReconnectAttempts = 5, onMessage, onConnect, onDisconnect, onError, } = options const { token, logout } = useAuthStore() const wsRef = useRef(null) const reconnectTimeoutRef = useRef(null) const reconnectAttemptsRef = useRef(0) const peerId = useRef(null) const [status, setStatus] = useState(ConnectionStatus.DISCONNECTED) const [lastError, setLastError] = useState(null) /** * Nettoyer les timeouts de reconnexion. */ const clearReconnectTimeout = useCallback(() => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null } }, []) /** * Connecter au serveur WebSocket. */ const connect = useCallback(() => { if (!token) { console.warn('Cannot connect to WebSocket: no token available') return } if (wsRef.current?.readyState === WebSocket.OPEN) { console.warn('WebSocket already connected') return } setStatus( reconnectAttemptsRef.current > 0 ? ConnectionStatus.RECONNECTING : ConnectionStatus.CONNECTING ) try { // Construire l'URL avec le token en query parameter const wsUrl = `${url}?token=${token}` const ws = new WebSocket(wsUrl) ws.onopen = () => { console.log('WebSocket connected') setStatus(ConnectionStatus.CONNECTED) setLastError(null) reconnectAttemptsRef.current = 0 // Envoyer system.hello pour s'identifier const helloEvent: Partial = { type: 'system.hello', payload: { client_type: 'web', version: '1.0.0', }, } ws.send(JSON.stringify(helloEvent)) onConnect?.() } ws.onmessage = (event) => { try { const data: WebSocketEvent = JSON.parse(event.data) // Stocker le peer_id depuis system.welcome if (data.type === 'system.welcome') { peerId.current = data.payload.peer_id console.log('Received peer_id:', peerId.current) } onMessage?.(data) } catch (err) { console.error('Error parsing WebSocket message:', err) } } ws.onerror = (error) => { console.error('WebSocket error:', error) setLastError('WebSocket connection error') setStatus(ConnectionStatus.ERROR) onError?.(error) } ws.onclose = (event) => { console.log('WebSocket closed:', event.code, event.reason) wsRef.current = null peerId.current = null if (event.code === 1008) { // Invalid token - déconnecter l'utilisateur console.error('Invalid token, logging out') logout() setStatus(ConnectionStatus.DISCONNECTED) } else if (reconnectAttemptsRef.current < maxReconnectAttempts) { // Tenter une reconnexion setStatus(ConnectionStatus.RECONNECTING) reconnectAttemptsRef.current++ console.log( `Reconnecting... (attempt ${reconnectAttemptsRef.current}/${maxReconnectAttempts})` ) reconnectTimeoutRef.current = setTimeout(() => { connect() }, reconnectDelay) } else { setStatus(ConnectionStatus.DISCONNECTED) setLastError('Max reconnection attempts reached') } onDisconnect?.() } wsRef.current = ws } catch (err) { console.error('Error creating WebSocket:', err) setStatus(ConnectionStatus.ERROR) setLastError('Failed to create WebSocket connection') } }, [token, url, reconnectDelay, maxReconnectAttempts, onConnect, onMessage, onDisconnect, onError, logout]) /** * Déconnecter du serveur WebSocket. */ const disconnect = useCallback(() => { clearReconnectTimeout() reconnectAttemptsRef.current = maxReconnectAttempts // Empêcher la reconnexion auto if (wsRef.current) { wsRef.current.close() wsRef.current = null peerId.current = null } setStatus(ConnectionStatus.DISCONNECTED) }, [clearReconnectTimeout, maxReconnectAttempts]) /** * Envoyer un événement WebSocket. */ const sendEvent = useCallback((event: Partial) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { console.warn('WebSocket not connected, cannot send event') return false } try { // Ajouter les champs par défaut const fullEvent: WebSocketEvent = { id: crypto.randomUUID(), timestamp: new Date().toISOString(), from: peerId.current || 'unknown', to: event.to || 'server', type: event.type || 'unknown', payload: event.payload || {}, } wsRef.current.send(JSON.stringify(fullEvent)) return true } catch (err) { console.error('Error sending WebSocket event:', err) return false } }, []) /** * Connexion automatique au montage. */ useEffect(() => { if (autoConnect && token) { connect() } return () => { clearReconnectTimeout() if (wsRef.current) { wsRef.current.close() } } }, [autoConnect, token, connect, clearReconnectTimeout]) return { status, lastError, peerId: peerId.current, isConnected: status === ConnectionStatus.CONNECTED, connect, disconnect, sendEvent, } }