Dashboard Real-Time & SSE Ownership Guide
July 15, 2026 · View on GitHub
This document is the canonical maintainer contract for dashboard/event-stream architecture.
- If this file and other docs differ, treat this file as source-of-truth and update the others.
- Do not add parallel SSE architecture docs; link here instead.
1) Stream inventory and ownership boundaries
Fusion intentionally uses multiple realtime mechanisms. Keep their ownership boundaries explicit.
A. Shared browser SSE bus for /api/events
- Endpoint:
GET /api/events - Browser owner:
packages/dashboard/app/sse-bus.ts - Main task consumer:
packages/dashboard/app/hooks/useTasks.ts - Additional consumers:
packages/dashboard/app/App.tsx(mailbox unread updates + approval banner trigger handling for newawaiting-approvaltransitions)
Contract: one EventSource per URL, fan-out via subscribeSse(...).
B. Dedicated streams that are intentionally separate
These should not be collapsed into /api/events unless product architecture changes:
- Task logs SSE:
GET /api/tasks/:id/logs/streaminpackages/dashboard/src/server.ts - Legacy terminal SSE:
GET /api/terminal/sessions/:id/streaminpackages/dashboard/src/server.ts - Chat session stream manager + SSE route plumbing:
packages/dashboard/src/chat.ts+packages/dashboard/src/routes.ts - Planning/session stream manager + SSE route plumbing:
packages/dashboard/src/planning.ts+packages/dashboard/src/routes.ts - Dev-server logs SSE:
GET /api/dev-server/logs/streaminpackages/dashboard/src/dev-server-routes.ts - Remote node
/api/eventsproxy stream:GET /api/proxy/:nodeId/eventsinpackages/dashboard/src/routes/register-proxy-routes.ts
2) Shared /api/events browser contract (sse-bus.ts)
Why this exists: browsers have limited HTTP/1.1 per-origin connection slots (commonly ~6). Multiple raw EventSource instances cause slot starvation and can stall normal fetch calls.
packages/dashboard/app/sse-bus.ts enforces:
- one channel per exact URL (
Map<string, Channel>) - subscription multiplexing (
subscribeSse) - close on last unsubscribe
- reconnect + heartbeat liveness
- unload/bfcache cleanup
URL identity and scoping
Channel identity is URL-based. These are distinct channels:
/api/events/api/events?projectId=A/api/events?projectId=B/api/proxy/<nodeId>/events?...
This is the first line of project/node isolation in the browser.
clientId and keepalive/disconnect controls (local /api/events only)
For same-origin /api/events, the bus appends session-scoped clientId and uses:
POST /api/events/keepalive?clientId=...&projectId=...POST /api/events/disconnect?clientId=...&projectId=...
clientId storage:
- primary:
sessionStoragekeyfusion:sse-client-id - fallback: in-memory random id
Purpose:
- stale stream reaping on server
- explicit unload release (
sendBeacon/fetch keepalivefallback) - supersede older stream for same
(clientId, projectId)
Reconnect and liveness constants
From sse-bus.ts:
- heartbeat timeout:
45_000ms - reconnect delay:
3_000ms - keepalive interval:
2_000ms
Behavior:
- stream
error→forceReconnect(...) - missing heartbeat/message > timeout → reconnect
onReconnectcallback lets consumers refetch authoritative state- reconnect is suppressed once channel is closed/unsubscribed
Unload / bfcache lifecycle
sse-bus.ts listens to:
pagehidebeforeunloadpageshow(persistedbfcache restore)
This prevents background/leaked connections after refresh/navigation and safely reopens persisted subscriptions.
3) View-aware gating and stale-event protection (useTasks.ts, App.tsx)
useTasks is the reference consumer for /api/events task lifecycle updates.
View-aware gating
App.tsx computes:
taskSseEnabled = taskView === "board" || taskView === "list"
and passes it into useTasks({ sseEnabled }).
This disables board-task SSE in non-task views to reduce unnecessary background connections.
Non-board task creators must ingest created tasks locally
When a feature creates tasks outside board/list surfaces (for example TodoView), it must feed each successful create response back into app task state via the canonical ingest path (ingestCreatedTasks(...) in useTasks, typically wired from App.tsx).
Do not rely on eventual task:created SSE delivery or manual refresh to reveal newly created tasks. Local ingestion keeps board/list views coherent immediately and avoids user-visible stale UI in the gap before SSE fan-out.
Project-switch stale guards
useTasks.ts protects against stale cross-project callbacks via:
projectContextVersionRef- captured request
projectIdchecks inrefreshTasks - stale handler checks before applying SSE updates
This is the primary defense against old-project events mutating current-project state.
4) Server /api/events ownership and store coherence (server.ts, sse.ts, project-store-resolver.ts)
Route ownership
packages/dashboard/src/server.ts owns:
GET /api/eventsPOST /api/events/keepalivePOST /api/events/disconnect
Project-scoped store resolution order
For projectId streams, server prefers engine-owned stores to keep EventEmitter identity coherent with mutation paths:
engineManager.getEngine(projectId)?.getTaskStore()(plus engine message/agent/automation stores)- fallback
getOrCreateProjectStore(projectId)
packages/dashboard/src/project-store-resolver.ts caches per-project TaskStore instances so API routes and SSE listeners share the same in-memory emitter graph.
SSE listener symmetry and teardown
createSSE(...) in packages/dashboard/src/sse.ts subscribes to task/mission/AI-session/plugin/agent/message/chat/automation events with on(...) and removes every one with off(...) in cleanup.
Invariant: any new forwarded event must preserve strict on(...)/off(...) symmetry.
Connection safety controls (sse.ts)
- active connection tracking + high-water stats
- heartbeat event every 30s (
event: heartbeat) - stale client timer (
SSE_CLIENT_STALE_MS = 5_000) refreshed by keepalive endpoint - supersede prior same
(clientId, projectId)connection - backpressure guard (
SSE_MAX_BUFFERED_BYTES) - cleanup paths: request close/aborted, response close, socket close/error, send failure, stale timeout, supersede, backpressure
5) Remote-node SSE proxy contract (register-proxy-routes.ts, useRemoteNodeEvents.ts)
Proxy endpoint:
GET /api/proxy/:nodeId/events
Server behavior (register-proxy-routes.ts):
- forwards query string to upstream
/api/events - forwards bearer auth from node
apiKey - preserves SSE framing
- aborts upstream and destroys stream on downstream client disconnect
Client behavior (useRemoteNodeEvents.ts):
- subscribes via
subscribeSse(...) - inherits shared bus multiplexing/reconnect semantics per proxy URL
6) Known pitfalls to explicitly avoid
- Raw
new EventSource(...)in feature hooks/components- bypasses bus multiplexing, creates duplicate streams, risks HTTP/1.1 slot starvation.
- Missing
off(...)during SSE server cleanup- leaks listeners and cross-session events.
- Project-switch stale callbacks not guarded
- old project events mutate current project state.
- Store instance mismatch for project streams
- sharing the same PostgreSQL database is not enough; EventEmitter instance identity matters for realtime propagation.
- Background SSE when view does not need it
- unnecessary connection pressure and noisy updates.
- Ambiguous stream ownership
- do not route chat/planning/task-logs/dev-server streams through
/api/eventsby default.
- do not route chat/planning/task-logs/dev-server streams through
7) Test suites that protect this contract
Run these whenever changing SSE/event-stream behavior:
packages/dashboard/app/__tests__/sse-bus.test.ts- URL-scoped multiplexing, reconnect, teardown
packages/dashboard/app/hooks/__tests__/useTasks.test.ts- stale project-event guards, reconnect resync, heartbeat behavior
packages/dashboard/src/__tests__/sse.test.ts- server clientId keepalive/disconnect/supersede, listener cleanup, backpressure close
packages/dashboard/src/__tests__/server.events.test.ts/api/eventswiring integration
packages/dashboard/src/__tests__/proxy-routes.test.ts- remote SSE proxy forwarding, timeout/transport failure handling, disconnect cleanup
8) Maintainer checklist for adding/modifying streams
Before merge:
- Choose ownership intentionally: shared
/api/eventsbus vs dedicated stream. - Preserve scoping: URL/query-based project and remote-node isolation.
- Preserve emitter coherence: use engine-owned project stores when available.
- Preserve cleanup symmetry: every
on(...)has matchingoff(...). - Preserve reconnect semantics: consumers refetch on reconnect when consistency matters.
- Preserve view-aware gating: avoid unnecessary background SSE.
- Update this doc if the contract changed.
- Run contract tests listed above.