dashboard: SharedWorker for SSE multiplexing (closes #448)
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.
This commit is contained in:
parent
8dc3432570
commit
4504f9ede3
6 changed files with 246 additions and 3 deletions
|
|
@ -58,6 +58,97 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
|||
// tied to its caller, so they don't generalise cleanly. We can lift
|
||||
// them when a second consumer needs the same shape.
|
||||
|
||||
// ─── shared-worker SSE pipe (#448) ──────────────────────────────────────
|
||||
// Returns an EventSource-shaped object backed by a SharedWorker that
|
||||
// holds ONE upstream `new EventSource(url)` and fans events out to
|
||||
// every connected tab. Replaces direct `new EventSource(url)` at the
|
||||
// dashboard's two consumer sites (app.js inline + flow.js via
|
||||
// terminal.js's `streamFactory` option) so N hyperhive tabs share
|
||||
// ONE backend connection — way under the browser's per-host
|
||||
// connection cap, immune to per-tab throttling that drops the SSE
|
||||
// when Firefox suspends background tabs.
|
||||
//
|
||||
// Graceful fallback to direct EventSource on environments without
|
||||
// SharedWorker (some embedded browsers, some Safari versions). The
|
||||
// per-tab connection cost is the same as today — no regression.
|
||||
//
|
||||
// The page consumer uses the returned object like a regular
|
||||
// EventSource: assign `onmessage` / `onopen` / `onerror`. `.close()`
|
||||
// tells the worker to drop the subscription; the worker closes the
|
||||
// upstream EventSource when the last subscriber leaves.
|
||||
const SHARED_WORKER_PATH = '/static/stream-worker.js';
|
||||
const SHARED_WORKER_NAME = 'hyperhive-stream';
|
||||
|
||||
// One SharedWorker instance per page, reused by all openStream calls
|
||||
// on that page. Lazy — pages with no streams don't spawn the worker.
|
||||
let _sharedPort = null;
|
||||
function getSharedPort() {
|
||||
if (_sharedPort) return _sharedPort;
|
||||
if (typeof SharedWorker === 'undefined') return null;
|
||||
try {
|
||||
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
|
||||
_sharedPort = sw.port;
|
||||
_sharedPort.start();
|
||||
return _sharedPort;
|
||||
} catch (err) {
|
||||
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function openStream(url) {
|
||||
const port = getSharedPort();
|
||||
if (!port) return new EventSource(url);
|
||||
|
||||
// Build an EventSource-shaped facade so consumer code is unchanged.
|
||||
// `target.onmessage` / `onopen` / `onerror` are assigned by the
|
||||
// consumer; the routing function below forwards events received
|
||||
// from the worker (filtered by url, since one port can multiplex
|
||||
// multiple subscriptions).
|
||||
const target = {
|
||||
onmessage: null,
|
||||
onopen: null,
|
||||
onerror: null,
|
||||
readyState: 0, // CONNECTING
|
||||
close() {
|
||||
try { port.postMessage({ kind: 'unsubscribe', url }); }
|
||||
catch { /* port dead */ }
|
||||
port.removeEventListener('message', route);
|
||||
},
|
||||
};
|
||||
const route = (e) => {
|
||||
const m = e.data;
|
||||
if (!m || m.url !== url) return;
|
||||
if (m.kind === 'open') {
|
||||
target.readyState = 1; // OPEN
|
||||
if (target.onopen) {
|
||||
try { target.onopen({ target }); }
|
||||
catch (err) { console.error('openStream onopen threw', err); }
|
||||
}
|
||||
} else if (m.kind === 'message') {
|
||||
if (target.onmessage) {
|
||||
try { target.onmessage({ data: m.data, target }); }
|
||||
catch (err) { console.error('openStream onmessage threw', err); }
|
||||
}
|
||||
} else if (m.kind === 'error') {
|
||||
if (target.onerror) {
|
||||
try { target.onerror({ target }); }
|
||||
catch (err) { console.error('openStream onerror threw', err); }
|
||||
}
|
||||
}
|
||||
};
|
||||
port.addEventListener('message', route);
|
||||
port.postMessage({ kind: 'subscribe', url });
|
||||
// Drop the subscription when the tab unloads so the worker can
|
||||
// close the upstream EventSource when the last subscriber leaves.
|
||||
// `pagehide` fires for both real unloads and bfcache transitions.
|
||||
window.addEventListener('pagehide', () => {
|
||||
try { port.postMessage({ kind: 'unsubscribe', url }); }
|
||||
catch { /* port dead — worker side already cleaned up */ }
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
// ─── side panel ─────────────────────────────────────────────────────────
|
||||
// Singleton drawer that swipes in from the right. Long content
|
||||
// (file previews, approval diffs, journald logs, applied config)
|
||||
|
|
|
|||
Loading…
Reference in a new issue