Shell: one stream connection per page

Browsers allow about six HTTP/1.1 connections to a host and an open
EventSource holds one. Sharing a stream per URL was not enough: a
dashboard with six modules still ran out — Safari first — and every later
request (a remounted tile's /state, an avatar) waited in the browser's
queue for ever; a Timers clock tile went blank after any settings change.

The page now holds ONE EventSource, GET /api/stream?s=<keys>, naming the
streams it wants (`slack:/stream`, `shell:` for the shell's own events).
The server relays each module stream into it over loopback — module
routes are untouched and still serve curl — prefixing every event name
with its key, and brackets each stream's life with `<key>|__open` and
`<key>|__down` (re-opened after a pause). sseSubscribe keeps its shape:
subscribers see their events by their own names, a stream joining the
connection is remade with the new list a moment later, and every stream
announces `open` again after it so backfills run as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-15 11:02:36 -04:00
co-authored by Claude Fable 5.1
parent 818def822d
commit 680a34ac7a
2 changed files with 262 additions and 77 deletions
+121 -77
View File
@@ -794,80 +794,130 @@ function tileMessage(entry, text) {
entry.body.appendChild(msg);
}
/* ── shared event streams ───────────────────────────────────────────── */
/* ── one stream per page ─────────────────────────────────────────────── */
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
EventSource holds one of them for as long as it lives. One stream per
tile plus the shell's own /api/events reaches that ceiling at five or six
tiles, and from then on every further request — the + picker's module
list, a new tile's /state, its stream — sits in the browser's queue
waiting for a connection that is never handed back. So tiles that ask
for the same stream share one connection: the pool keeps one EventSource
per URL, fans its events out to every subscriber, and reconnects it (on
the same 3-second timer the per-tile version used) while anyone is still
listening. A tile joining an already-open stream is told `open` straight
away, so its backfill runs exactly as if the connection were its own. */
EventSource holds one of them for as long as it lives. Sharing one
stream per URL was not enough: a dashboard with six modules still ran
out (Safari first), and from then on every further request — a remounted
tile's /state, an avatar, the + picker's list — sat in the browser's
queue waiting for a connection that was never handed back. So the page
holds ONE EventSource, GET /api/stream?s=<keys>, and the server relays
every stream named in it (a module's, or `shell:` for the shell's own)
with the key prefixed to each event name. Subscribers still see the
events they asked for by their own names. Adding or dropping a stream
remakes the connection with the new list, a moment later so several
tiles mounting together cause one reconnect; every stream announces
`open` again after it, so backfills run as before. */
const ssePool = new Map(); // url -> shared source
const muxSubs = new Map(); // key -> { subs: Set<{ handlers }> }
let muxEs = null; // the page's one EventSource
let muxKeys = ''; // the key list it was opened with
let muxTimer = null; // the pending (re)connect
let muxRetry = null; // retry after the connection gave up
const muxListened = new Set(); // "key|name" pairs with a listener on muxEs
/** The stream key for a URL: `/api/modules/<id><path>` → `<id>:<path>`, `/api/events` → `shell:`. */
function muxKeyFor(url) {
if (url === '/api/events') return 'shell:';
const m = /^\/api\/modules\/([^/?#]+)(\/[^?#]*)?$/.exec(url);
return m ? `${decodeURIComponent(m[1])}:${m[2] || '/'}` : null;
}
function muxFanOut(key, kind, e) {
for (const sub of [...(muxSubs.get(key)?.subs || [])]) {
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
}
}
/** Listen for `<key>|<name>` on the open connection and hand it to that key's subscribers as `name`. */
function muxListen(key, name) {
const full = `${key}|${name}`;
if (!muxEs || muxListened.has(full)) return;
muxListened.add(full);
muxEs.addEventListener(full, (e) => {
if (name === '__open') return muxFanOut(key, 'open', new Event('open'));
if (name === '__down') return muxFanOut(key, 'error', new Event('error'));
const ev = { type: name, data: e.data, lastEventId: e.lastEventId };
muxFanOut(key, name === 'message' ? 'message' : 'event', ev);
});
}
function muxConnect() {
clearTimeout(muxTimer);
muxTimer = null;
clearTimeout(muxRetry);
muxRetry = null;
const keys = [...muxSubs.keys()].sort();
const wanted = keys.join(',');
if (!keys.length) {
try { muxEs?.close(); } catch { /* not open */ }
muxEs = null;
muxKeys = '';
return;
}
if (muxEs && muxKeys === wanted && muxEs.readyState !== EventSource.CLOSED) return;
try { muxEs?.close(); } catch { /* not open */ }
muxListened.clear();
muxKeys = wanted;
const es = new EventSource(`/api/stream?s=${encodeURIComponent(wanted)}`);
muxEs = es;
// Each stream says `open` for itself (the server's __open), so nothing to do on the connection's own open.
es.onerror = () => {
if (es !== muxEs) return;
for (const key of muxSubs.keys()) muxFanOut(key, 'error', new Event('error'));
// EventSource retries transient drops itself but gives up for good on a
// completed non-SSE response (a 502 while the server restarts).
if (es.readyState === EventSource.CLOSED && muxSubs.size) {
clearTimeout(muxRetry);
muxRetry = setTimeout(muxConnect, 3000);
}
};
for (const [key, entry] of muxSubs) {
muxListen(key, '__open');
muxListen(key, '__down');
muxListen(key, 'message');
for (const sub of entry.subs) for (const name of Object.keys(sub.handlers.events || {})) muxListen(key, name);
}
}
function sseSubscribe(url, handlers = {}) {
let shared = ssePool.get(url);
if (!shared) {
shared = { subs: new Set(), es: null, retryTimer: null, names: new Set() };
const fanOut = (kind, e) => {
for (const sub of [...shared.subs]) {
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
}
};
shared.listen = (name) => shared.es.addEventListener(name, (e) => fanOut('event', e));
shared.connect = () => {
clearTimeout(shared.retryTimer);
shared.retryTimer = null;
try { shared.es?.close(); } catch { /* already closed */ }
const es = new EventSource(url);
shared.es = es;
es.onopen = (e) => fanOut('open', e);
es.onmessage = (e) => fanOut('message', e);
es.onerror = (e) => {
fanOut('error', e);
// EventSource retries transient drops itself but gives up for good on
// a completed non-SSE response (a 502 while the upstream is down).
if (es.readyState === EventSource.CLOSED && shared.subs.size) {
clearTimeout(shared.retryTimer);
shared.retryTimer = setTimeout(shared.connect, 3000);
}
};
for (const name of shared.names) shared.listen(name);
};
ssePool.set(url, shared);
const key = muxKeyFor(url);
if (!key) {
console.warn('[shell] not a stream this page can carry:', url);
return { close() {} };
}
let entry = muxSubs.get(key);
if (!entry) {
entry = { subs: new Set() };
muxSubs.set(key, entry);
}
const sub = { handlers };
shared.subs.add(sub);
for (const name of Object.keys(handlers.events || {})) {
if (shared.names.has(name)) continue;
shared.names.add(name);
if (shared.es) shared.listen(name);
entry.subs.add(sub);
const carried = Boolean(muxEs) && muxKeys.split(',').includes(key) && muxEs.readyState !== EventSource.CLOSED;
if (carried) {
// Joining a stream the connection already carries: listeners for any
// new event names, and `open` a moment later so the caller's own setup
// finishes first — exactly as a fresh connection would have reported it.
for (const name of Object.keys(handlers.events || {})) muxListen(key, name);
if (muxEs.readyState === EventSource.OPEN) {
setTimeout(() => {
if (!entry.subs.has(sub)) return;
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
}, 0);
}
} else {
clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 20);
}
if (!shared.es || shared.es.readyState === EventSource.CLOSED) {
// first subscriber, or the stream had given up while nobody listened
shared.connect();
} else if (shared.es.readyState === EventSource.OPEN) {
// Joining mid-flight: report the open the way a fresh connection would
// have, a moment later, so the caller's own setup finishes first.
setTimeout(() => {
if (!shared.subs.has(sub)) return;
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
}, 0);
}
return {
close() {
if (!shared.subs.delete(sub) || shared.subs.size) return;
clearTimeout(shared.retryTimer);
try { shared.es?.close(); } catch { /* already closed */ }
ssePool.delete(url);
if (!entry.subs.delete(sub) || entry.subs.size) return;
muxSubs.delete(key);
// A remount closes and reopens within milliseconds: wait before remaking the connection.
clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 500);
},
};
}
@@ -1959,20 +2009,14 @@ new ResizeObserver(() => {
config or enabled flags change. Tiles of a changed module get
onConfigChange(cfg) if they implement it, otherwise a clean remount. */
let eventsSource = null;
let eventsRetry = null;
function subscribeEvents() {
try { eventsSource?.close(); } catch { /* not open */ }
eventsSource = new EventSource('/api/events');
eventsSource.addEventListener('modules-changed', () => handleModulesChanged());
eventsSource.addEventListener('theme', handleThemeEvent);
eventsSource.onerror = () => {
if (eventsSource.readyState === EventSource.CLOSED) {
clearTimeout(eventsRetry);
eventsRetry = setTimeout(subscribeEvents, 4000);
}
};
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
sseSubscribe('/api/events', {
events: {
'modules-changed': () => handleModulesChanged(),
theme: handleThemeEvent,
},
});
}
async function handleModulesChanged() {