/** * Composable for WebSocket connectivity to receive live updates. * * Connects to the backend WS endpoint and allows subscribing to * individual vote session channels for real-time tally updates. * * Production-grade features: * - Bearer token authentication via query param * - Heartbeat (ping every 25s, pong expected within 10s) * - Exponential backoff on reconnect (1s, 2s, 4s, 8s, max 30s) * - Max reconnect attempts: 10 * - Typed event handlers for domain events * - Message queue during disconnection with replay on reconnect */ /** WebSocket event types from the backend. */ export type WsEventType = | 'vote_submitted' | 'decision_advanced' | 'mandate_updated' | 'document_changed' | 'sanctuary_archived' | 'pong' | 'error' /** Typed WebSocket message from the backend. */ export interface WsMessage { event: WsEventType data: any timestamp?: string } /** Event handler callback type. */ type WsEventHandler = (data: any) => void /** Maximum reconnect attempts before giving up. */ const MAX_RECONNECT_ATTEMPTS = 10 /** Base delay for reconnection backoff (ms). */ const RECONNECT_BASE_MS = 1_000 /** Maximum delay between reconnection attempts (ms). */ const RECONNECT_MAX_MS = 30_000 /** Interval between heartbeat pings (ms). */ const HEARTBEAT_INTERVAL_MS = 25_000 /** Maximum time to wait for a pong response (ms). */ const PONG_TIMEOUT_MS = 10_000 export function useWebSocket() { const config = useRuntimeConfig() const auth = useAuthStore() let ws: WebSocket | null = null let reconnectTimer: ReturnType | null = null let heartbeatTimer: ReturnType | null = null let pongTimeoutTimer: ReturnType | null = null let reconnectAttempts = 0 let intentionalClose = false const connected = ref(false) const lastMessage = ref(null) const error = ref(null) /** Message queue: messages sent while disconnected are replayed on reconnect. */ const messageQueue: string[] = [] /** Typed event handlers registry. */ const eventHandlers: Map> = new Map() /** * Open a WebSocket connection to the backend live endpoint. */ function connect() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { return } // Reset state error.value = null intentionalClose = false // Build WS URL with authentication token let wsUrl = config.public.apiBase .replace(/^http/, 'ws') .replace(/\/api\/v1$/, '/api/v1/ws/live') if (auth.token) { wsUrl += `?token=${encodeURIComponent(auth.token)}` } ws = new WebSocket(wsUrl) ws.onopen = () => { connected.value = true reconnectAttempts = 0 error.value = null // Start heartbeat _startHeartbeat() // Replay queued messages _flushMessageQueue() } ws.onclose = (event: CloseEvent) => { connected.value = false _stopHeartbeat() if (!intentionalClose) { _scheduleReconnect() } } ws.onerror = () => { connected.value = false } ws.onmessage = (event: MessageEvent) => { try { const message: WsMessage = JSON.parse(event.data) lastMessage.value = message // Handle pong for heartbeat if (message.event === 'pong') { _onPongReceived() return } // Dispatch to typed event handlers _dispatchEvent(message) } catch { // Non-JSON message, store as-is lastMessage.value = { event: 'error', data: event.data } } } } /** * Subscribe to real-time updates for a vote session. */ function subscribe(sessionId: string) { _send(JSON.stringify({ action: 'subscribe', session_id: sessionId })) } /** * Unsubscribe from a vote session's updates. */ function unsubscribe(sessionId: string) { _send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId })) } /** * Gracefully close the WebSocket connection. */ function disconnect() { intentionalClose = true if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } _stopHeartbeat() if (ws) { ws.onclose = null ws.onerror = null ws.onmessage = null ws.close() ws = null } connected.value = false reconnectAttempts = 0 error.value = null } // ---- Typed event handler registration ---- /** * Register a handler for when a vote is submitted. */ function onVoteSubmitted(handler: WsEventHandler): () => void { return _addEventHandler('vote_submitted', handler) } /** * Register a handler for when a decision advances to the next step. */ function onDecisionAdvanced(handler: WsEventHandler): () => void { return _addEventHandler('decision_advanced', handler) } /** * Register a handler for when a mandate is updated. */ function onMandateUpdated(handler: WsEventHandler): () => void { return _addEventHandler('mandate_updated', handler) } /** * Register a handler for when a document is changed. */ function onDocumentChanged(handler: WsEventHandler): () => void { return _addEventHandler('document_changed', handler) } /** * Register a handler for when a document is archived to the sanctuary. */ function onSanctuaryArchived(handler: WsEventHandler): () => void { return _addEventHandler('sanctuary_archived', handler) } // ---- Internal helpers ---- /** * Send a message via WebSocket, or queue it if disconnected. */ function _send(message: string) { if (ws?.readyState === WebSocket.OPEN) { ws.send(message) } else { messageQueue.push(message) } } /** * Replay all queued messages after reconnection. */ function _flushMessageQueue() { while (messageQueue.length > 0) { const message = messageQueue.shift()! if (ws?.readyState === WebSocket.OPEN) { ws.send(message) } else { // Put it back if connection dropped again messageQueue.unshift(message) break } } } /** * Register an event handler and return an unsubscribe function. */ function _addEventHandler(event: WsEventType, handler: WsEventHandler): () => void { if (!eventHandlers.has(event)) { eventHandlers.set(event, new Set()) } eventHandlers.get(event)!.add(handler) // Return unsubscribe function return () => { eventHandlers.get(event)?.delete(handler) } } /** * Dispatch a received message to all registered handlers for its event type. */ function _dispatchEvent(message: WsMessage) { const handlers = eventHandlers.get(message.event) if (handlers) { for (const handler of handlers) { try { handler(message.data) } catch (err) { console.error(`[WS] Erreur dans le handler pour "${message.event}":`, err) } } } } /** * Start the heartbeat: send ping every 25s and expect pong within 10s. */ function _startHeartbeat() { _stopHeartbeat() heartbeatTimer = setInterval(() => { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'ping' })) // Start pong timeout pongTimeoutTimer = setTimeout(() => { console.warn('[WS] Pong non recu dans le delai imparti, reconnexion...') // Force close and reconnect if (ws) { ws.close() } }, PONG_TIMEOUT_MS) } }, HEARTBEAT_INTERVAL_MS) } /** * Stop heartbeat timers. */ function _stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer) heartbeatTimer = null } if (pongTimeoutTimer) { clearTimeout(pongTimeoutTimer) pongTimeoutTimer = null } } /** * Handle pong response: cancel the pong timeout. */ function _onPongReceived() { if (pongTimeoutTimer) { clearTimeout(pongTimeoutTimer) pongTimeoutTimer = null } } /** * Schedule a reconnection attempt with exponential backoff. */ function _scheduleReconnect() { if (reconnectTimer) return if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { error.value = 'Connexion au serveur perdue. Veuillez rafraichir la page.' console.error(`[WS] Nombre maximum de tentatives de reconnexion atteint (${MAX_RECONNECT_ATTEMPTS})`) return } const backoffMs = Math.min( RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS, ) reconnectAttempts++ console.info(`[WS] Tentative de reconnexion ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} dans ${backoffMs}ms`) reconnectTimer = setTimeout(() => { reconnectTimer = null connect() }, backoffMs) } return { connected, lastMessage, error, connect, subscribe, unsubscribe, disconnect, onVoteSubmitted, onDecisionAdvanced, onMandateUpdated, onDocumentChanged, onSanctuaryArchived, } }