hyperhive/frontend/packages/dashboard/build.mjs
iris 4504f9ede3 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.
2026-05-26 01:26:56 +02:00

80 lines
3 KiB
JavaScript

// esbuild build for @hive/dashboard. Output layout (`dist/`):
//
// dist/index.html served by the Rust router at GET /
// dist/flow.html served at GET /flow.html
// dist/static/app.js /index.html entry — tab renderers +
// tab routing + refreshState
// dist/static/flow.js /flow.html entry — broker terminal +
// operator inbox + @-mention composer
// dist/static/{app,flow}.js.map source map siblings
// dist/static/dashboard.css served at /static/dashboard.css
// (@import resolved from @hive/shared)
//
// Both JS entries inline `./common.js` (DOM helpers, Panel singleton,
// NOTIF, path linkification) — esbuild dedupes the shared module
// between bundles. The Rust binary mounts `dist/` as a
// `tower_http::ServeDir` fallback; the layout above keeps every URL
// the HTML files reference reachable without rewriting paths in the
// HTML.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
// Bundle both JS entries. ES-module output, browser target, no minify
// (line-aligned source aids debugging; minification belongs in a later
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [src('app.js'), src('flow.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
});
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
// scripts run in a different global (`self` is the worker scope, no
// `window`) so they can't be inlined into app.js / flow.js. Output is
// at `static/stream-worker.js`; common.js's `openStream` references
// `/static/stream-worker.js` as the SharedWorker URL. ES-module-shaped
// (so a future `import` from within the worker can pull in shared
// utilities), browser target — webworker target subset.
await build({
entryPoints: [src('stream-worker.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
});
// Bundle the CSS — esbuild resolves @import including the package
// re-exports from @hive/shared.
await build({
entryPoints: [src('dashboard.css')],
outfile: staticDir('dashboard.css'),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
});
for (const html of ['index.html', 'flow.html']) {
copyFileSync(src(html), dist(html));
}
console.log('dashboard build ok →', dist(''));