Files
mesh/client/src/hooks/useWebSocket.ts
T
Gilles Soulier 1d177e96a6 first
2026-01-05 13:20:54 +01:00

260 lines
6.9 KiB
TypeScript

// 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<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef(0)
const peerId = useRef<string | null>(null)
const [status, setStatus] = useState<ConnectionStatus>(ConnectionStatus.DISCONNECTED)
const [lastError, setLastError] = useState<string | null>(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<WebSocketEvent> = {
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<WebSocketEvent>) => {
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,
}
}