260 lines
6.9 KiB
TypeScript
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,
|
|
}
|
|
}
|