mara on #448: "firefox disconnects bc of too many tabs. needs bg service worker". picked SharedWorker over full Service Worker: smaller change, addresses the actual problem (shared connection across tabs), no offline-cache scope creep. architecture per-tab `new EventSource('/dashboard/stream')` replaced with a SharedWorker-backed facade. one SharedWorker instance per origin holds ONE upstream EventSource and fans every server-sent event out to every connected tab via MessagePort. N hyperhive tabs now share ONE backend connection, immune to Firefox's per-tab SSE throttling under many-open-tabs pressure. wire protocol (port.postMessage): tab → worker { kind: 'subscribe', url: '/dashboard/stream' } { kind: 'unsubscribe', url: '/dashboard/stream' } worker → tab { kind: 'open', url } { kind: 'message', url, data: '<raw SSE data>' } { kind: 'error', url } subscription tracking is per (port, url). a late subscriber that joins after the upstream is already OPEN gets a synthetic 'open' event so its onStreamOpen handler still runs (triggers the snapshot re-sync that recovers events lost during the join gap). unsubscribing the last port for a URL closes the upstream EventSource so we don't leak idle streams. files - frontend/packages/dashboard/src/stream-worker.js: new — the worker. multi-URL multiplexing via Map<url, {es, ports}>. - frontend/packages/dashboard/src/common.js: new exported helper openStream(url) — returns an EventSource-shaped facade backed by the SharedWorker. graceful fallback to direct EventSource when SharedWorker is unavailable. - frontend/packages/dashboard/src/app.js: replaces the inline new EventSource('/dashboard/stream') with openStream. - frontend/packages/dashboard/src/flow.js: passes streamFactory: openStream to termCreate so the broker terminal's SSE goes through the worker too. - frontend/packages/shared/src/terminal.js: accepts an optional streamFactory(url) option. default unchanged — non-dashboard consumers (per-agent UI) keep using direct EventSource. - frontend/packages/dashboard/build.mjs: new esbuild entry for stream-worker.js → dist/static/stream-worker.js (separate bundle because SharedWorker scripts run in a different global scope and can't be inlined into app.js). scope kept tight - per-agent UI's /events/stream stays on direct EventSource. the agent UI's tab count per agent is typically 1; SharedWorker helps when you have N tabs hitting the SAME stream and the per-agent stream URLs differ. if mara wants the agent UI to share its workers too it's a separate small PR. - no offline-cache, no push notifications — those need full Service Worker; explicit non-goal here per the design Q. validation - npm run build --workspace=@hive/dashboard clean. - stream-worker.js bundle: 1.8 kb. - app.js: 154 kb → 158 kb. flow.js: 29.9 kb → 32 kb. - browser smoke test isn't possible from inside iris's container; the EventSource-shaped facade preserves the exact onmessage / onopen / onerror surface the existing IIFE consumers use.
110 lines
4.3 KiB
JavaScript
110 lines
4.3 KiB
JavaScript
// SharedWorker that holds ONE EventSource per stream URL and fans
|
|
// every server-sent event out to every connected tab via MessagePort.
|
|
//
|
|
// Problem this solves (#448 — mara: "firefox disconnects bc of too
|
|
// many tabs"): every dashboard / agent tab opens its own
|
|
// `EventSource('/dashboard/stream')`. Browsers cap concurrent
|
|
// connections per host (~6), and Firefox throttles / disconnects
|
|
// background tabs when many are open. The result: tabs silently
|
|
// drop the SSE, fall behind, and only catch up on focus.
|
|
//
|
|
// Centralising the connection in a SharedWorker means N tabs share
|
|
// ONE backend EventSource regardless of focus state — way under the
|
|
// per-host cap, immune to per-tab throttling, and the worker survives
|
|
// any individual tab being suspended.
|
|
//
|
|
// Wire protocol (port.postMessage payloads):
|
|
//
|
|
// tab → worker
|
|
// { kind: 'subscribe', url: '/dashboard/stream' }
|
|
// { kind: 'unsubscribe', url: '/dashboard/stream' }
|
|
//
|
|
// worker → tab
|
|
// { kind: 'open', url: '...' } relayed from EventSource.onopen,
|
|
// plus a synthetic open fired to
|
|
// a brand-new subscriber when the
|
|
// upstream is already OPEN — so
|
|
// the page's onStreamOpen still
|
|
// runs and triggers a snapshot
|
|
// re-sync after a reconnect gap.
|
|
// { kind: 'message', url: '...', data: '<raw SSE data string>' }
|
|
// { kind: 'error', url: '...' } relayed from EventSource.onerror.
|
|
//
|
|
// Subscriptions are tracked per (port, url): a single port can
|
|
// subscribe to multiple URLs (today only one is in use but the shape
|
|
// stays open for the per-agent /events/stream multiplexing follow-up).
|
|
// Unsubscribing the last port for a URL closes the EventSource so we
|
|
// don't keep idle streams open.
|
|
|
|
const streams = new Map();
|
|
|
|
function getOrCreateStream(url) {
|
|
let entry = streams.get(url);
|
|
if (entry) return entry;
|
|
const es = new EventSource(url);
|
|
entry = { es, url, ports: new Set() };
|
|
es.onopen = () => {
|
|
for (const port of entry.ports) {
|
|
try { port.postMessage({ kind: 'open', url }); }
|
|
catch { /* port dead — cleanup happens on unsubscribe / next subscribe */ }
|
|
}
|
|
};
|
|
es.onmessage = (e) => {
|
|
for (const port of entry.ports) {
|
|
try { port.postMessage({ kind: 'message', url, data: e.data }); }
|
|
catch { /* same */ }
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
for (const port of entry.ports) {
|
|
try { port.postMessage({ kind: 'error', url }); }
|
|
catch { /* same */ }
|
|
}
|
|
};
|
|
streams.set(url, entry);
|
|
return entry;
|
|
}
|
|
|
|
function unsubscribe(port, url) {
|
|
const entry = streams.get(url);
|
|
if (!entry) return;
|
|
entry.ports.delete(port);
|
|
if (entry.ports.size === 0) {
|
|
entry.es.close();
|
|
streams.delete(url);
|
|
}
|
|
}
|
|
|
|
self.onconnect = (connectEvent) => {
|
|
const port = connectEvent.ports[0];
|
|
const subscribedUrls = new Set();
|
|
port.onmessage = (e) => {
|
|
const msg = e.data;
|
|
if (!msg || typeof msg.url !== 'string') return;
|
|
if (msg.kind === 'subscribe') {
|
|
if (subscribedUrls.has(msg.url)) return; // idempotent
|
|
const entry = getOrCreateStream(msg.url);
|
|
entry.ports.add(port);
|
|
subscribedUrls.add(msg.url);
|
|
// Synthetic open for late subscribers — the upstream EventSource
|
|
// may already be OPEN when this tab joins, in which case the
|
|
// native onopen has long since fired and won't fire again until
|
|
// the next reconnect. Hand the new tab the open event explicitly
|
|
// so its onStreamOpen handler runs.
|
|
if (entry.es.readyState === EventSource.OPEN) {
|
|
try { port.postMessage({ kind: 'open', url: msg.url }); }
|
|
catch { /* port dead immediately — give up */ }
|
|
}
|
|
} else if (msg.kind === 'unsubscribe') {
|
|
if (!subscribedUrls.has(msg.url)) return;
|
|
unsubscribe(port, msg.url);
|
|
subscribedUrls.delete(msg.url);
|
|
}
|
|
};
|
|
// A port has no explicit "disconnect" event in the SharedWorker API
|
|
// — tabs close, the GC eventually reclaims the port, but postMessage
|
|
// to a dead port throws which the senders above catch. We don't
|
|
// proactively prune ports on a timer because the cost is bounded
|
|
// (one dead Set entry per stale tab) and the next subscribe / catch
|
|
// catches it.
|
|
};
|