119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
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: <type>`, 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<typeof setTimeout> | undefined;
|
|
let retryTimer: ReturnType<typeof setTimeout> | 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");
|
|
};
|
|
},
|
|
};
|
|
}
|