import { ReactNode, createContext, useCallback, useContext, useRef, useState } from 'react'; import { useAuthServiceContext } from './AuthService'; import { useHttpService } from './HttpService'; import { environment } from '../environments/environment.ts'; import { usePatientServiceContext } from './PatientService.tsx'; import { HEARTBEAT_SECONDS } from './presenceConstants'; const WS_URL = environment.websocketApi; interface WebSocketContextType { isConnected: boolean; connect: () => void; disconnect: () => void; sendMessage: (payload: { action: string; data: any }) => boolean; subscribe: (channel: string, callback: (data: any) => void) => void; unsubscribe: (channel: string, callback: (data: any) => void) => void; } export const useWebSocketService = () => { const { currentUser, wpOrganization, userRole, USER_ROLES } = useAuthServiceContext(); const { post } = useHttpService(); const { patientState } = usePatientServiceContext(); const ws = useRef(null); const reconnectTimeoutRef = useRef(null); const heartbeatIntervalRef = useRef(null); const shouldReconnect = useRef(false); const [isConnected, setIsConnected] = useState(false); const channels = useRef void)[]>>(new Map()); // One socket at a time. createConnection awaits a handshake before opening, so two overlapping calls // each opened one and the loser stayed open, registered, with nobody reading it. const creation = useRef(0); /** * - ONE PAGE LOAD MINTED TWO HANDSHAKE TICKETS, because this connection was keyed on * OBJECTS rather than on who the connection is for. * * `createConnection` depended on `currentUser` and `patientState` directly, and `DashboardPage`'s * effect depended on `currentUser` as well. Any render that produced a NEW object for the same * person - the record settling after load is the ordinary case - changed `createConnection`'s * identity, which changed `connect`'s, which re-ran the effect: **disconnect, reconnect, a second * `POST /ws-ticket` and a second socket.** Measured in a browser 2026-08-22: two tickets per load. * * THE KEY IS WHO, NOT WHICH OBJECT. Identity is the cognito subject, the FHIR resource and the * organisation - all strings. A re-created object describing the same person leaves this unchanged, * so the socket is left alone; a genuine change of user, subject or org still reconnects, which is * the behaviour that must not be lost. * * `wpOrganization` is INCLUDED here and was missing from the old dependency list entirely - the * connect path refuses without it, so an organisation arriving late left the socket permanently down * with nothing to retrigger it. That is a second, quieter defect the same fix closes. */ const identityKey = [ currentUser?.cognito?.sub ?? '', currentUser?.fhir?.id ?? '', patientState?.id ?? '', wpOrganization ?? '', userRole ?? '', ].join('|'); /** * THE LIVE VALUES, READ AT CONNECT TIME RATHER THAN CAPTURED IN THE DEPENDENCY LIST. Dropping the * objects from the deps without this would close over whatever they were on the render that last * changed the key - a stale-closure bug that shows up as a socket opened for the previous patient. */ const latest = useRef({ currentUser, patientState, wpOrganization, userRole }); latest.current = { currentUser, patientState, wpOrganization, userRole }; const subscribe = useCallback((channel: string, callback: (data: any) => void) => { if (!channels.current.has(channel)) { channels.current.set(channel, []); } const callbacks = channels.current.get(channel); if (callbacks && !callbacks.includes(callback)) { callbacks.push(callback); } }, []); const unsubscribe = useCallback((channel: string, callback: (data: any) => void) => { if (!channels.current.has(channel)) { return; } const callbacks = channels.current.get(channel); if (callbacks) { const index = callbacks.indexOf(callback); if (index !== -1) { callbacks.splice(index, 1); } if (callbacks.length === 0) { channels.current.delete(channel); } } }, []); const clearReconnectTimeout = useCallback(() => { if (reconnectTimeoutRef.current !== null) { window.clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } }, []); const clearHeartbeat = useCallback(() => { if (heartbeatIntervalRef.current !== null) { window.clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } }, []); const closeConnection = useCallback(() => { creation.current += 1; clearHeartbeat(); if (ws.current) { ws.current.onopen = null; ws.current.onclose = null; ws.current.onerror = null; ws.current.onmessage = null; ws.current.close(); ws.current = null; setIsConnected(false); } }, [clearHeartbeat]); const createConnection = useCallback(async () => { closeConnection(); clearReconnectTimeout(); const token = ++creation.current; try { // Read through the ref - see `latest` above. The dependency list carries the identity KEY, // not these objects, so reading them from the closure would risk a stale one. const live = latest.current; const cognitoUser = live.currentUser?.cognito; const fhirResource = live.userRole === USER_ROLES.RELATED_PERSON ? live.patientState : live.currentUser?.fhir; if (!cognitoUser || !fhirResource || !live.wpOrganization) { return; } // Fetch a single-use handshake ticket from the authenticated REST API, then open the socket // with only the opaque ticket in the URL - never the reusable Cognito JWT. The ticket carries // the identity + org the server verified at mint time, so no other query params are needed. let ticket: string | undefined; try { const res = await post<{ ticket: string }>(`${environment.apiUrl}/ws-ticket`, {}); ticket = res?.ticket; } catch (error) { console.error('Failed to obtain WebSocket handshake ticket', error); return; } if (!ticket) { return; } if (token !== creation.current) { return; } ws.current = new WebSocket(`${WS_URL}?ticket=${encodeURIComponent(ticket)}`); ws.current.onopen = () => { console.log('WebSocket connection established'); setIsConnected(true); clearHeartbeat(); heartbeatIntervalRef.current = window.setInterval(() => { if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ action: 'heartbeat', data: {} })); } }, HEARTBEAT_SECONDS * 1000); }; ws.current.onmessage = (event) => { try { const { action, data } = JSON.parse(event.data); if (channels.current.has(action)) { const callbacks = channels.current.get(action); callbacks?.forEach(callback => { try { callback(data); } catch (error) { console.error('Error in WebSocket callback:', error); } }); } } catch (error) { console.error('Error parsing WebSocket message:', error); } }; ws.current.onclose = (event) => { console.log(`WebSocket connection closed: Code ${event.code}`); setIsConnected(false); ws.current = null; clearHeartbeat(); if (shouldReconnect.current) { console.log('Scheduling reconnection attempt in 5 seconds...'); reconnectTimeoutRef.current = window.setTimeout(() => { reconnectTimeoutRef.current = null; if (shouldReconnect.current) { console.log('Attempting to reconnect...'); createConnection(); } }, 5000); } }; ws.current.onerror = (error) => { console.error('WebSocket error:', error); }; } catch (error) { console.error('Error creating WebSocket connection:', error); setIsConnected(false); } // ⛔ `identityKey`, never `currentUser`/`patientState` — see the note on `identityKey`. Adding an // object back here reintroduces the duplicate ticket, and it will look like a harmless tidy-up. }, [closeConnection, clearReconnectTimeout, clearHeartbeat, identityKey, post, USER_ROLES]); const connect = useCallback(() => { shouldReconnect.current = true; createConnection(); }, [createConnection]); const disconnect = useCallback(() => { console.log('Disconnecting WebSocket...'); shouldReconnect.current = false; clearReconnectTimeout(); closeConnection(); }, [clearReconnectTimeout, closeConnection]); const sendMessage = useCallback((payload: { action: string; data: any }): boolean => { if (ws.current && ws.current.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify(payload)); console.log('WebSocket message sent:', payload.action); return true; } else { console.error('Cannot send message: WebSocket is not connected'); return false; } }, []); return { isConnected, connect, disconnect, sendMessage, subscribe, unsubscribe }; }; const WebSocketServiceContext = createContext(undefined); export const WebSocketServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const webSocketService = useWebSocketService(); return ( {children} ); }; export const useWebSocketServiceContext = () => { const context = useContext(WebSocketServiceContext); if (context === undefined) { // - THIS MESSAGE NAMED TWO THINGS THAT DO NOT EXIST: `useWebSocketContext` and // `WebSocketProvider`. Neither is a symbol in this codebase; the hook is `useWebSocketServiceContext` // and the provider is `WebSocketServiceProvider` (mounted in App.tsx). // Found while chasing the crash that bricked the Platform Manager workspace - a page consuming a // context whose provider was never mounted. **That is the exact failure this message is written for**, // and it would have sent whoever hit it looking for a component that has never existed. throw new Error('useWebSocketServiceContext must be used within a WebSocketServiceProvider'); } return context; }; /** The socket when a provider is mounted, else undefined - for a surface that only improves with one. */ export const useOptionalWebSocketServiceContext = () => useContext(WebSocketServiceContext); export default useWebSocketServiceContext;