import type { QueryClient } from "@tanstack/react-query"; import { aoBridge } from "./bridge"; import { getApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; import { setEventsConnectionState } from "./events-connection"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; export type EventTransport = { connect: () => () => void; }; const INVALIDATE_DEBOUNCE_MS = 150; // How long to wait before rebuilding an EventSource the browser gave up on // (readyState CLOSED — e.g. the daemon answered with a non-SSE response). const SSE_RETRY_MS = 5_000; // EventSource.CLOSED, referenced numerically so test stubs without the static // constants still work. const EVENTSOURCE_CLOSED = 2; // CDC event types the daemon pushes over the SSE stream (see // backend/internal/cdc/event.go). The SSE writer tags each frame with // `event: `, so named events bypass EventSource.onmessage and must be // subscribed explicitly. Every one of these can change the project/session list // the sidebar renders, so they all trigger a (debounced) workspace refetch. const CDC_EVENT_TYPES = [ "session_created", "session_updated", "pr_created", "pr_updated", "pr_check_recorded", "pr_session_changed", "pr_review_thread_added", "pr_review_thread_resolved", ] as const; /** * Wires live server state into the TanStack Query cache. Two sources feed it: * - daemon lifecycle over Electron IPC (coming up/down changes session availability) * - the backend CDC stream over SSE (project/session/PR changes) * Both invalidate the ["workspaces"] query so the UI refetches. Invalidations are * debounced because a single user action can emit a burst of CDC events. */ export function createEventTransport(queryClient: QueryClient): EventTransport { return { connect() { let debounce: ReturnType | undefined; let retryTimer: ReturnType | undefined; let source: EventSource | undefined; let sourceBaseUrl: string | undefined; const refreshWorkspaces = () => { if (debounce) clearTimeout(debounce); debounce = setTimeout(() => { void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); }, INVALIDATE_DEBOUNCE_MS); }; const scheduleRetry = () => { if (retryTimer) return; retryTimer = setTimeout(() => { retryTimer = undefined; connectSource(); }, SSE_RETRY_MS); }; const connectSource = () => { // EventSource is unavailable in jsdom (tests) and some preview surfaces; guard it. if (typeof EventSource === "undefined") return; const baseUrl = getApiBaseUrl(); // Keep a still-usable source on the same base URL; replace one the // browser abandoned (CLOSED) or one bound to a stale port. if (source && sourceBaseUrl === baseUrl && source.readyState !== EVENTSOURCE_CLOSED) return; source?.close(); source = undefined; sourceBaseUrl = baseUrl; try { source = new EventSource(`${baseUrl.replace(/\/+$/, "")}/api/v1/events`); source.onopen = () => { setEventsConnectionState("connected"); // Events emitted during the gap were lost; refetch once on (re)open. refreshWorkspaces(); }; source.onerror = () => { // While readyState is CONNECTING the browser retries on its own; // either way the stream is not delivering, so surface it instead // of looping silently against a dead daemon. setEventsConnectionState("disconnected"); if (source?.readyState === EVENTSOURCE_CLOSED) scheduleRetry(); }; source.onmessage = refreshWorkspaces; // unnamed events, if any for (const type of CDC_EVENT_TYPES) { source.addEventListener(type, refreshWorkspaces); } // EventSource auto-reconnects and resumes via Last-Event-ID while // CONNECTING; scheduleRetry only covers the terminal CLOSED state. } catch { source = undefined; } }; const removeDaemonListener = aoBridge.daemon.onStatus(() => { connectSource(); refreshWorkspaces(); }); // Rebind when the daemon comes back on a different port, independent of // status-event ordering. const removeBaseUrlListener = subscribeApiBaseUrl(connectSource); connectSource(); return () => { if (debounce) clearTimeout(debounce); if (retryTimer) clearTimeout(retryTimer); removeDaemonListener(); removeBaseUrlListener(); source?.close(); setEventsConnectionState("idle"); }; }, }; }