// API client + React hooks. The bot's HTTP backend lives at /api/v1/*; // this module exposes a thin fetch wrapper plus useAPI / useSSE hooks // so each page can declare what it needs and get loading/error/refresh // for free. // // Important: useSSE shares ONE underlying EventSource across every // hook instance (managed by an internal singleton). The page used to // open a separate connection per component which made it look like // the bot was spamming HTTP — it wasn't, those were long-lived SSE // streams, but two of them per dashboard render is still wasteful. // Now there's exactly one /api/v1/stream connection per browser tab, // fanning out to every subscriber who filters on `topics` locally. window.API = (() => { // ─── fetch helpers ──────────────────────────────────────────────── async function get(path, params) { let url = path; if (params && Object.keys(params).length) { const usp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === '') continue; usp.set(k, String(v)); } const qs = usp.toString(); if (qs) url += '?' + qs; } const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store' }); if (!r.ok) { const err = new Error('http ' + r.status); err.status = r.status; try { err.body = await r.json(); } catch (_) {} throw err; } return r.json(); } async function post(path, body) { const r = await fetch(path, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }); let j = {}; try { j = await r.json(); } catch (_) {} if (!r.ok) { const err = new Error(j.error || 'http ' + r.status); err.status = r.status; err.body = j; throw err; } return j; } // ─── useAPI — Promise-returning fn → {data, error, loading, refresh, offline} ── function useAPI(fn, deps) { const [state, setState] = React.useState({ data: null, error: null, loading: true, offline: false }); const fnRef = React.useRef(fn); fnRef.current = fn; React.useEffect(() => { let cancelled = false; setState(s => ({ ...s, loading: true, error: null })); Promise.resolve() .then(() => fnRef.current()) .then((data) => { if (!cancelled) setState({ data, error: null, loading: false, offline: false }); }) .catch((error) => { if (cancelled) return; const offline = error.status === undefined; setState({ data: null, error, loading: false, offline }); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); const refresh = React.useCallback(() => { setState(s => ({ ...s, loading: true })); Promise.resolve() .then(() => fnRef.current()) .then((data) => setState({ data, error: null, loading: false, offline: false })) .catch((error) => { const offline = error.status === undefined; setState({ data: null, error, loading: false, offline }); }); }, []); return { ...state, refresh }; } // ─── SSE singleton ──────────────────────────────────────────────── // Lazy-initialised on first useSSE call. Reconnects automatically // on error (browsers do this for EventSource by default but we // also handle explicit close/recreate when the page is visible // again after a long sleep). const sseState = { es: null, connected: false, listeners: new Set(), // (event) => void statusListeners: new Set(), // (connected) => void }; function ensureSSE() { if (sseState.es) return; // No `topics=` filter — we receive every topic and let each hook // filter locally. The hub's drop-on-overflow + topic-based fan-out // already handles backpressure. let es; try { es = new EventSource('/api/v1/stream'); } catch (_) { return; } sseState.es = es; const setStatus = (v) => { if (sseState.connected === v) return; sseState.connected = v; sseState.statusListeners.forEach(fn => { try { fn(v); } catch (_) {} }); }; es.addEventListener('open', () => setStatus(true)); es.addEventListener('error', () => setStatus(false)); const dispatch = (e) => { let data = null; try { data = JSON.parse(e.data); } catch (_) { return; } const ev = { topic: e.type, data, at: Date.now() }; sseState.listeners.forEach(fn => { try { fn(ev); } catch (_) {} }); }; // The server names events by topic; subscribe to every known one. ['ready', 'ticker', 'trade', 'event', 'equity', 'scout', 'message'].forEach(t => { es.addEventListener(t, dispatch); }); } // useSSE — subscribe to the shared SSE stream with optional topic // filter. Returns {events, connected}. `events` is a tail of the // last `keep` matching events (rolling window). function useSSE(topics, keep = 100) { const [connected, setConnected] = React.useState(sseState.connected); const [events, setEvents] = React.useState([]); const topicsKey = topics ? topics.join(',') : ''; React.useEffect(() => { ensureSSE(); setConnected(sseState.connected); const wantedTopics = topicsKey ? new Set(topicsKey.split(',')) : null; const onEvent = (ev) => { if (wantedTopics && !wantedTopics.has(ev.topic)) return; setEvents(prev => { const next = prev.length >= keep ? prev.slice(prev.length - keep + 1) : prev.slice(); next.push(ev); return next; }); }; const onStatus = (v) => setConnected(v); sseState.listeners.add(onEvent); sseState.statusListeners.add(onStatus); return () => { sseState.listeners.delete(onEvent); sseState.statusListeners.delete(onStatus); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [topicsKey, keep]); return { connected, events }; } return { get, post, useAPI, useSSE }; })();