diff --git a/frontend/src/hooks/useDebateWS.ts b/frontend/src/hooks/useDebateWS.ts index 64657fb9..6990c341 100644 --- a/frontend/src/hooks/useDebateWS.ts +++ b/frontend/src/hooks/useDebateWS.ts @@ -10,10 +10,13 @@ import { presenceAtom, spectatorHashAtom, lastEventIdAtom, + wsErrorAtom, // <-- NEW PollInfo, } from '../atoms/debateAtoms'; import ReconnectingWebSocket from 'reconnecting-websocket'; +const ERROR_THROTTLE_MS = 5000; + interface Event { type: string; payload: any; @@ -30,7 +33,10 @@ export const useDebateWS = (debateId: string | null) => { const [, setPresence] = useAtom(presenceAtom); const [, setLastEventId] = useAtom(lastEventIdAtom); const [spectatorHash] = useAtom(spectatorHashAtom); + const [, setWsError] = useAtom(wsErrorAtom); + const wsRef = useRef(null); + const lastErrorTime = useRef(0); useEffect(() => { if (!debateId) return; @@ -62,11 +68,12 @@ export const useDebateWS = (debateId: string | null) => { } setWsStatus('connecting'); + setWsError(null); // clear old errors when reconnecting - // Get WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const apiUrl = import.meta.env.VITE_API_URL; let host = window.location.host; + if (apiUrl) { try { host = new URL(apiUrl).host; @@ -87,7 +94,7 @@ export const useDebateWS = (debateId: string | null) => { const rws = new ReconnectingWebSocket(wsUrl, [], { connectionTimeout: 4000, - maxRetries: Infinity, + maxRetries: 3, maxReconnectionDelay: 10000, minReconnectionDelay: 1000, reconnectionDelayGrowFactor: 1.3, @@ -97,23 +104,48 @@ export const useDebateWS = (debateId: string | null) => { setWs(rws as unknown as WebSocket); let ownsConnection = true; + const handleError = (error: unknown, context = 'WebSocket error') => { + const now = Date.now(); + if (now - lastErrorTime.current > ERROR_THROTTLE_MS) { // 5 seconds throttle + const errorMessage = error instanceof Error + ? `${context}: ${error.message}` + : `${context}: An unknown error occurred`; + + console.error(errorMessage, error); + setWsError(errorMessage); + setWsStatus('error'); // Move inside throttle guard + lastErrorTime.current = now; + } + }; + rws.onopen = () => { + // Reset retry counter on successful connection setWsStatus('connected'); + setWsError(null); + + const spectatorHashValue = spectatorHash || localStorage.getItem('spectatorHash') || ''; - const spectatorHashValue = - spectatorHash || localStorage.getItem('spectatorHash') || ''; - const joinMessage = { - type: 'join', - payload: { - spectatorHash: spectatorHashValue, - }, - }; - rws.send(JSON.stringify(joinMessage)); + try { + const joinMessage = { + type: 'join', + payload: { spectatorHash: spectatorHashValue }, + }; + rws.send(JSON.stringify(joinMessage)); + } catch (error) { + handleError(error, 'Failed to send join message'); + } }; rws.onmessage = (event) => { try { + if (!event.data) { + throw new Error('Received empty message'); + } + const eventData: Event = JSON.parse(event.data); + + // Clear error state on successful message + setWsError(null); if (eventData.type !== 'poll_snapshot' && eventData.timestamp) { setLastEventId(String(eventData.timestamp)); @@ -123,36 +155,45 @@ export const useDebateWS = (debateId: string | null) => { case 'poll_snapshot': { const payload = eventData.payload || {}; const pollsPayload = payload.polls; + if (Array.isArray(pollsPayload)) { const nextState: Record = {}; + pollsPayload.forEach((poll) => { if (!poll) return; + const pollId = typeof poll.pollId === 'string' ? poll.pollId : String(poll.pollId ?? ''); + if (!pollId) return; + const countsRaw = poll.counts || {}; const counts: Record = {}; + if (countsRaw && typeof countsRaw === 'object') { Object.entries(countsRaw).forEach(([option, value]) => { - const numericValue = + counts[option] = typeof value === 'number' ? value : Number(value ?? 0) || 0; - counts[option] = numericValue; }); } + let options: string[] = []; + if (Array.isArray(poll.options)) { options = poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0); } + if (options.length === 0) { options = Object.keys(counts); } - const info: PollInfo = { + + nextState[pollId] = { pollId, question: typeof poll.question === 'string' ? poll.question : '', @@ -163,18 +204,20 @@ export const useDebateWS = (debateId: string | null) => { ? poll.voters : Number(poll.voters ?? 0) || 0, }; - nextState[pollId] = info; }); + setPollState(nextState); } else if (payload.pollState) { - // Backwards compatibility: convert legacy structure const legacyState = payload.pollState as Record< string, Record >; + const legacyResult: Record = {}; + Object.entries(legacyState).forEach(([pollId, counts]) => { const options = Object.keys(counts || {}); + legacyResult[pollId] = { pollId, question: '', @@ -186,6 +229,7 @@ export const useDebateWS = (debateId: string | null) => { : 0, }; }); + setPollState(legacyResult); } break; @@ -195,11 +239,14 @@ export const useDebateWS = (debateId: string | null) => { setPollState((prev) => { const pollId = eventData.payload?.pollId; const option = eventData.payload?.option; + if (typeof pollId !== 'string' || typeof option !== 'string') { return prev; } + const nextState = { ...prev }; const existing = nextState[pollId]; + if (!existing) { nextState[pollId] = { pollId, @@ -210,44 +257,54 @@ export const useDebateWS = (debateId: string | null) => { }; return nextState; } + const nextCounts = { ...existing.counts }; nextCounts[option] = (nextCounts[option] || 0) + 1; + const nextOptions = existing.options.includes(option) ? existing.options : [...existing.options, option]; + nextState[pollId] = { ...existing, options: nextOptions, counts: nextCounts, }; + return nextState; }); break; case 'poll_created': { const poll = eventData.payload; + if (poll && poll.pollId) { setPollState((prev) => { const pollId = String(poll.pollId); const countsRaw = poll.counts || {}; const counts: Record = {}; + Object.entries(countsRaw).forEach(([option, value]) => { counts[option] = typeof value === 'number' ? value : Number(value ?? 0) || 0; }); + const options = Array.isArray(poll.options) ? poll.options .map((opt: unknown) => String(opt ?? '').trim()) .filter((opt: string) => opt.length > 0) : Object.keys(counts); + return { ...prev, [pollId]: { pollId, question: - typeof poll.question === 'string' ? poll.question : '', + typeof poll.question === 'string' + ? poll.question + : '', options, counts, voters: @@ -293,29 +350,49 @@ export const useDebateWS = (debateId: string | null) => { default: } } catch (error) { + handleError(error, 'Error processing WebSocket message'); } }; - rws.onerror = () => { - setWsStatus('error'); + rws.onerror = (error) => { + handleError(error, 'WebSocket connection error'); }; - rws.onclose = () => { - setWsStatus('disconnected'); + rws.onclose = (event) => { + // Don't treat normal closure as error + if (event.code !== 1000) { // 1000 is normal closure + handleError(new Error(`Connection closed with code ${event.code}: ${event.reason || 'Unknown reason'}`)); + } else { + setWsStatus('disconnected'); + setWsError(null); + } + setWs(null); + if (wsRef.current === rws) { wsRef.current = null; } }; + // Cleanup function return () => { if (ownsConnection) { - rws.close(); - if (wsRef.current === rws) { - wsRef.current = null; + try { + if (rws.readyState === WebSocket.OPEN) { + rws.close(1000, 'Component unmounting'); + } else { + rws.close(); + } + } catch (error) { + console.error('Error during WebSocket cleanup:', error); + } finally { + if (wsRef.current === rws) { + wsRef.current = null; + } + setWs(null); + setWsStatus('disconnected'); + setWsError(null); } - setWs(null); - setWsStatus('disconnected'); } ownsConnection = false; }; @@ -331,6 +408,7 @@ export const useDebateWS = (debateId: string | null) => { setWsStatus, setLastEventId, setPresence, + setWsError, ]); const sendMessage = (type: string, payload: any) => { @@ -345,4 +423,3 @@ export const useDebateWS = (debateId: string | null) => { ws: wsRef.current, }; }; -