mara on #453: address argus's two yellow nits.
bfcache restore gap
previously openStream registered a pagehide unsubscribe but never
re-subscribed on pageshow, so a bfcache restore left the consumer's
onmessage bound but no events flowing.
fix: maintain a registry of live subscriptions (Map<url, target,
route>). bind page lifecycle hooks once:
- pagehide: unsubscribe every URL, drop route listeners, invalidate
the cached SharedWorker port (it may be collected if all other
tabs closed while this page was frozen).
- pageshow { persisted: true }: get a fresh port via getSharedPort
(creates a new SharedWorker if needed), re-attach every route
listener, re-subscribe to every URL. target.readyState resets to
CONNECTING so the worker's synthetic open after subscribe fires
the consumer's onStreamOpen and triggers a refresh.
worker bundle format
stream-worker.js was bundled as format: 'esm' but loaded as
classic via new SharedWorker(url, name). today's worker has no
imports/exports so the ESM bundle is syntactically valid as a
classic script; argus's concern was that a future contributor
adding an import would silently break things.
fix: switched build.mjs to format: 'iife'. esbuild now wraps the
worker output in (() => { ... })(); any future import statement
would surface as a build error rather than ship broken code.
verified output starts with the IIFE wrapper.
other
- target.close() now reads _sharedPort lazily so close-after-bfcache
(port may have been recreated) doesn't try to postMessage on a
stale reference.
- _activeSubs.delete on close keeps the registry honest if a
consumer ever explicitly closes a stream (none do today, but the
shape stays correct).
validation: npm run build clean. stream-worker.js: 1.8 kb → 1.9 kb
(IIFE wrapper). common.js bfcache logic adds ~30 LOC inside the
existing module — bundle deltas negligible.
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.
First slice of the app.js split (#406). Pure utility / infrastructure
code that both /index.html and /flow.html use lifts out of the IIFE
into a sibling ES module:
- DOM helpers: `$`, `el`, `esc`, `form`, `fmtAgeSecs`
- Side-panel singleton (`Panel.open` / `openNamed` / `refresh` /
`close` / `bind`). The `ensure()` lazy-init makes it tolerate
being imported before the DOM element exists — `bind()` still
needs to be called once the host page is ready.
- Path linkification + file-preview side panel:
`appendLinkified`, `appendText`, `makePathLink`, plus the
internal `openFilePanel` + `fetchStateFile` + `mdNode` /
`svgImage` / `buildTabbedPreview` it depends on.
- Browser-notification module `NOTIF` (`bind`, `show`,
`renderControls`).
`app.js` now imports these from `./common.js` and the duplicated
definitions are gone. Each removal is replaced by a one-line
breadcrumb comment so a reader chasing a name from the bundled
output can find where it landed.
`truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in app.js for
now — each has caller-specific phrasing ("X running", "X ago") that
doesn't generalise cleanly. Lift them when a second consumer needs
the same shape.
## Next steps (separate PRs)
- Step 2: split app.js into `tabs.js` (entry for /index.html — tab
renderers + tab routing + refreshState) and `flow.js` (entry for
/flow.html — broker terminal + inbox derived store + compose),
both importing from common.js. Updates `build.mjs` for multiple
entry points and switches each HTML file's `<script src>`.
- Step 3 (#408 follow-up): backend-side stream split so /flow.html
doesn't have to subscribe to the dashboard's mutation events at all.
## Validation
- `npm run build` clean.
- Build deltas: `app.js` 154.3kb (was 153.6kb) — bundle size bumped
slightly due to per-module overhead; same code under the hood.
Source: app.js 2603 → 2287 lines (-316); common.js 367 lines (new).
- No HTML / CSS changes. Both pages still load `/static/app.js` as
before.
Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy:
- Notification toggle + send still works (NOTIF.bind, NOTIF.show)
- Side panel still opens for diff / file preview / logs (Panel)
- Path tokens in messages still render as clickable anchors that
open the file in the side panel (appendLinkified → makePathLink
→ openFilePanel)