"use client"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { toast } from "@/components/primitives"; import { WsConnection } from "./connection"; import type { WsEventHandler, WsEventType, WsStatus } from "./types"; interface WsContextValue { status: WsStatus; connect: () => void; disconnect: () => void; sendText: (text: string) => void; /** Subscribe to a typed WS event. Returns unsubscribe function. */ on: ( eventType: E, handler: WsEventHandler, ) => () => void; } const WsContext = createContext(null); export function WsProvider({ children, url, }: { children: ReactNode; url?: string; }) { const connRef = useRef(null); const [status, setStatus] = useState("disconnected"); // Tracks whether we've ever been connected — used to suppress the // "reconnecting" toast on initial page load. const wasConnected = useRef(false); // Event handler registry — Ref so listeners survive re-renders without reconnect // Using unknown as internal store; typed at the subscribe interface const handlersRef = useRef void>>>({}); const handleJsonEvent = useCallback((json: string) => { try { const parsed = JSON.parse(json); const eventType = parsed.type as string; const data = parsed.data ?? parsed.state ?? parsed; const handlers = handlersRef.current; const eventHandlers = handlers[eventType as WsEventType]; if (eventHandlers && eventHandlers.size > 0) { eventHandlers.forEach((h) => h(data)); } } catch { // ignore parse errors } }, []); useEffect(() => { const conn = new WsConnection(url); connRef.current = conn; const unsubStatus = conn.onStatusChange((s) => { setStatus(s); // User feedback on WS lifecycle transitions. if (s === "connecting") { // Only toast if we were previously connected (i.e. a disconnect, // not the initial connect on page load). if (wasConnected.current) { toast({ title: "Reconnecting…", description: "WebSocket connection lost. Attempting to reconnect.", tone: "neutral", }); } wasConnected.current = false; } else if (s === "connected") { wasConnected.current = true; } else if (s === "error" && !wasConnected.current) { toast({ title: "Connection error", description: "WebSocket failed to connect. Retrying in the background.", tone: "vermilion", }); } }); const unsubEvent = conn.onEvent((event) => { if (event.type === "text") { handleJsonEvent(event.data); } }); conn.connect(); return () => { conn.destroy(); connRef.current = null; unsubStatus(); unsubEvent(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, handleJsonEvent]); const subscribe = useCallback( (_eventType: E, handler: WsEventHandler) => { const eventType = _eventType as string; if (!handlersRef.current[eventType]) { handlersRef.current[eventType] = new Set(); } handlersRef.current[eventType].add(handler as (data: unknown) => void); return () => { handlersRef.current[eventType]?.delete( handler as (data: unknown) => void, ); }; }, [], ); const connect = useCallback(() => connRef.current?.connect(), []); const disconnect = useCallback(() => connRef.current?.disconnect(), []); const sendText = useCallback( (text: string) => connRef.current?.sendText(text), [], ); return ( {children} ); } export function useWebSocket(): WsContextValue { const ctx = useContext(WsContext); if (!ctx) { throw new Error("useWebSocket must be used within a WsProvider"); } return ctx; }