Files
fusion/packages/dashboard/app/api/client/event-source.ts
gsxdsm cb57093d03 refactor: domain folder layout (types, API, core, engine) (#2398)
## Summary

Wave 17 organizes Fusion into **domain folders** (stacks on #2397).

### Layout
- **core/types/** — board, task, agents, settings, merge, workflow,
mesh, …
- **core/src/** — agents, ai, async-stores, workflows, tasks, config,
db, …
- **dashboard/app/api/** — client, tasks, agents, git, missions,
planning, …
- **engine/src/** — agents, auth, execution, merge, missions, overseer,
worktree, …

Root keepers retained for large entrypoints (`store.ts`, `executor.ts`,
`merger.ts`, …).

Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy)
stay stable.

## Test plan
- [x] `@fusion/core` typecheck
- [x] `@fusion/engine` typecheck (pre-existing playwright-core noise
only)
- [ ] CI merge gate

**Stack:** #2394 → #2397 → **this PR**
2026-08-03 00:20:53 -07:00

177 lines
5.9 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-07-19-12:00:
* Resilient EventSource client peeled from legacy.ts for shared SSE reconnect semantics.
*/
import { appendTokenQuery } from "../../auth";
export type StreamConnectionState = "connected" | "reconnecting";
/*
* Track every live createResilientEventSource instance so we can close their
* underlying EventSource sockets on page unload. Without this, Chrome holds
* the HTTP/1.1 sockets open in its keep-alive pool across refreshes, exhausts
* its 6-per-origin limit after ~3 refreshes, and every new fetch stalls —
* leaving the dashboard frozen on "Initializing...". sse-bus.ts has its own
* handler; this one covers the parallel EventSource path in api.ts.
*
* FNXC:DashboardSSE 2026-07-26-10:44:
* `beforeunload` was REMOVED here for the same reason as in sse-bus.ts and must not be re-added:
* a registered beforeunload handler makes the page ineligible for Safari's page cache / bfcache,
* which is exactly the cheap restore path the mobile dashboard needs after the OS backgrounds it.
* `pagehide` fires in every case beforeunload does, plus on bfcache freeze, so nothing is lost.
*
* FNXC:DashboardSSE 2026-07-26-10:47:
* These streams are DELIBERATELY NOT hidden-suspended, unlike the sse-bus channels. They carry
* in-flight AI generation (chat, planning, ai-text) and long-running scheduling runs; closing the
* EventSource mid-generation drops the server's only consumer and the user loses the response they
* are waiting on. Resumption is best-effort (`lastEventId` replay depends on the route keeping a
* ring buffer), so suspending here would trade a possible tab discard for a certain data loss.
* They are also short-lived and idle-free between messages, so they are a far weaker discard signal
* than the always-on board channels. If suspend is ever wanted here, gate it on a stream that has
* signalled completion.
*/
const activeResilientEventSources = new Set<{ close: () => void }>();
if (typeof window !== "undefined") {
const closeAll = () => {
for (const handle of Array.from(activeResilientEventSources)) {
try { handle.close(); } catch { /* best effort */ }
}
};
window.addEventListener("pagehide", closeAll);
}
export interface ResilientEventSourceOptions {
maxReconnectAttempts?: number;
onConnectionStateChange?: (state: StreamConnectionState) => void;
onFatalError?: (message: string) => void;
}
export interface ResilientEventHandlers {
onOpen?: () => void;
onMessage?: (event: MessageEvent) => void;
events?: Record<string, (event: MessageEvent) => void>;
}
function appendLastEventId(url: string, lastEventId: number | null): string {
if (lastEventId === null || lastEventId <= 0) {
return url;
}
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}lastEventId=${encodeURIComponent(String(lastEventId))}`;
}
export function createResilientEventSource(
url: string,
handlers: ResilientEventHandlers,
options: ResilientEventSourceOptions = {},
): { close: () => void; isConnected: () => boolean } {
const maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
let eventSource: EventSource | null = null;
let closedByUser = false;
let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let lastSeenEventId: number | null = null;
let reconnectingNotified = false;
const shouldDispatch = (event: MessageEvent): boolean => {
const rawId = event.lastEventId;
if (!rawId) {
return true;
}
const parsedId = Number.parseInt(rawId, 10);
if (!Number.isFinite(parsedId)) {
return true;
}
if (lastSeenEventId !== null && parsedId <= lastSeenEventId) {
return false;
}
lastSeenEventId = parsedId;
return true;
};
const connect = (): void => {
if (closedByUser) return;
const nextUrl = appendLastEventId(url, lastSeenEventId);
// EventSource can't set headers — carry the bearer token via `fn_token=`.
const source = new EventSource(appendTokenQuery(nextUrl));
eventSource = source;
source.onopen = () => {
reconnectAttempts = 0;
reconnectingNotified = false;
options.onConnectionStateChange?.("connected");
handlers.onOpen?.();
};
source.onmessage = (event) => {
const messageEvent = event as MessageEvent;
if (!shouldDispatch(messageEvent)) return;
handlers.onMessage?.(messageEvent);
};
for (const [eventName, handler] of Object.entries(handlers.events ?? {})) {
source.addEventListener(eventName, (event: Event) => {
const messageEvent = event as MessageEvent;
if (!shouldDispatch(messageEvent)) return;
handler(messageEvent);
});
}
source.onerror = () => {
if (closedByUser || eventSource !== source) return;
const readyState = source.readyState;
if (readyState === EventSource.CONNECTING) {
if (!reconnectingNotified) {
reconnectingNotified = true;
options.onConnectionStateChange?.("reconnecting");
}
return;
}
source.close();
if (eventSource === source) {
eventSource = null;
}
if (reconnectAttempts >= maxReconnectAttempts) {
options.onFatalError?.("Connection lost");
return;
}
reconnectingNotified = true;
options.onConnectionStateChange?.("reconnecting");
reconnectAttempts += 1;
const delayMs = Math.min(1000 * 2 ** (reconnectAttempts - 1), 30000);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delayMs);
};
};
connect();
const handle = {
close: () => {
closedByUser = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
eventSource?.close();
activeResilientEventSources.delete(handle);
},
isConnected: () => !closedByUser && eventSource?.readyState === EventSource.OPEN,
};
activeResilientEventSources.add(handle);
return handle;
}