'use client' /** * Singleton WebSocket provider. * * The entire app shares ONE connection to /ws/prices. Components subscribe * to specific message types via `useWsSubscribe` — no component owns the socket. * * Why: previously DashboardClient (via usePriceSocket) and SignalMonitor each * opened their own WebSocket, giving the server two connections per page load * and causing each component to receive every broadcast twice. */ import { createContext, useContext, useEffect, useRef, type ReactNode, } from 'react' type Handler = (msg: unknown) => void interface WsContextValue { subscribe: (type: string, fn: Handler) => () => void } const WsContext = createContext(null) const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000' // Exponential backoff: 2s → 4s → 8s → … capped at 60s, plus ±30% jitter // so a server restart doesn't get hit by all clients at the same instant. const WS_RETRY_BASE_MS = 2_000 const WS_RETRY_MAX_MS = 60_000 function retryDelay(attempt: number): number { const exp = Math.min(WS_RETRY_BASE_MS * 2 ** attempt, WS_RETRY_MAX_MS) return exp * (0.7 + Math.random() * 0.6) // ±30% jitter } export function WsProvider({ children }: { children: ReactNode }) { // Stable map: message-type → set of handlers. Never replaced, only mutated. const subs = useRef>>(new Map()) useEffect(() => { let dead = false let retryTimer: ReturnType | null = null let attempt = 0 let socket: WebSocket | null = null function connect() { if (dead) return socket = new WebSocket(`${WS_URL}/ws/prices`) const ws = socket // local alias for handler closures ws.onopen = () => { attempt = 0 // reset backoff on successful connection } ws.onmessage = (e) => { try { const msg = JSON.parse(e.data) as { type?: string } const t = msg.type ?? '__unknown__' subs.current.get(t)?.forEach((fn) => fn(msg)) } catch { // ignore malformed frames } } ws.onclose = () => { if (!dead) { const delay = retryDelay(attempt++) retryTimer = setTimeout(connect, delay) } } ws.onerror = () => ws.close() } // Reconnect immediately when the tab comes back to the foreground // (the socket may have silently died while the tab was hidden). function onVisible() { if (document.visibilityState !== 'visible') return if (socket && socket.readyState === WebSocket.OPEN) return // Cancel any pending retry — we're reconnecting now. if (retryTimer) { clearTimeout(retryTimer); retryTimer = null } attempt = 0 if (socket && socket.readyState < WebSocket.CLOSING) { socket.onclose = null socket.close() } connect() } document.addEventListener('visibilitychange', onVisible) connect() return () => { dead = true document.removeEventListener('visibilitychange', onVisible) if (retryTimer) clearTimeout(retryTimer) // Actively close any live socket so the OS-level connection releases // immediately on unmount instead of waiting for the next server keepalive. if (socket && socket.readyState <= WebSocket.OPEN) { socket.onclose = null // suppress the auto-reconnect we'd otherwise queue socket.close() } socket = null } }, []) function subscribe(type: string, fn: Handler): () => void { if (!subs.current.has(type)) subs.current.set(type, new Set()) subs.current.get(type)!.add(fn) return () => subs.current.get(type)?.delete(fn) } return {children} } /** * Subscribe to a specific WebSocket message type from the shared connection. * * @param type The `msg.type` string to listen for (e.g. 'price', 'new_post', 'funding_signal') * @param handler Called with the full parsed message object on every matching frame. * Always receives the latest handler reference — no stale-closure issues. * * Usage: * useWsSubscribe('price', (msg) => { ... }) */ export function useWsSubscribe(type: string, handler: Handler): void { const ctx = useContext(WsContext) // Keep a ref so the subscription closure never goes stale even if handler // is an inline function that changes every render. const handlerRef = useRef(handler) handlerRef.current = handler useEffect(() => { if (!ctx) return return ctx.subscribe(type, (msg) => handlerRef.current(msg)) }, [ctx, type]) // type is stable in practice; ctx never changes }