Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
364 lines
8.9 KiB
TypeScript
364 lines
8.9 KiB
TypeScript
/**
|
|
* 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<typeof setTimeout> | null = null
|
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
let pongTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
|
let reconnectAttempts = 0
|
|
let intentionalClose = false
|
|
|
|
const connected = ref(false)
|
|
const lastMessage = ref<WsMessage | null>(null)
|
|
const error = ref<string | null>(null)
|
|
|
|
/** Message queue: messages sent while disconnected are replayed on reconnect. */
|
|
const messageQueue: string[] = []
|
|
|
|
/** Typed event handlers registry. */
|
|
const eventHandlers: Map<WsEventType, Set<WsEventHandler>> = 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,
|
|
}
|
|
}
|