argus pointed out the inline catch comment claimed dead ports get cleaned up on
next subscribe, but nothing actually prunes the allPorts Set on subscribe — the
honest answer is the one already at the bottom of onconnect: dead entries are
left in the Set, the bound cost is acceptable, and postMessage's throw is the
ambient signal we use. Point at that comment instead of repeating a wrong
description.
Firefox kills "idle" SharedWorkers under memory pressure with no native
signal to the client. The page's port silently becomes a no-op and
events stop flowing — observable symptom: mara's "dashboard never
refreshes; F5 fixes it" (because F5 creates a fresh page that creates
a fresh worker).
The worker now pings every connected port every 30s. The client tracks
last-activity-from-worker on every message arrival (incl. pings, since
those carry no URL — bumped before the URL filter in the route handler).
A visibility-gated watchdog polls every 15s; if the page is visible AND
has active subs AND hasn't heard from the worker in >90s, it presumes
the worker dead, logs a console warning, and re-subscribes on a fresh
port. Three pings missed before we act, so a normal tab-throttle blip
doesn't false-positive.
The fresh-port re-subscribe re-uses the bfcache-restore code path
(same shape: drop stale listeners, getSharedPort → new SharedWorker,
re-attach each route + repost subscribe). Recovery is per-tab — when
one tab's watchdog fires and brings up a new worker, other tabs that
share the named worker pick it up on their own watchdog cycle.
Falls back gracefully on environments without SharedWorker (the
existing direct-EventSource path is untouched) and is invisible on the
healthy path — pings are 30s apart, no UI surface.
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.