Shell: the + picker opens at once, and tiles share their streams

The Add-tile menu would stop appearing once enough tiles were on the
dashboard — a different count each time. The button's handler awaited
GET /api/modules before revealing the menu, and that request could never
complete: browsers allow about six HTTP/1.1 connections per host, an open
EventSource holds one for as long as it lives, and every stream-fed tile
(ProPresenter, Timers, PCO Plan, ProdCom) opened its own. With the shell's
own /api/events that is the whole allowance at five or six such tiles, and
every request after that — the picker's module list, a new tile's /state,
its stream — sat in the browser's queue for a connection that was never
handed back. The count varied because a stream in its 3-second retry loop
frees a connection for a moment, and a clock tile holds none.

Reproduced with six stream-fed tiles: the sixth stream and the picker's
/api/modules both pend forever, and the menu stays hidden after the click.

Three changes, all in the shell:

- Dropdowns open the moment they are clicked, from what the page already
  knows, and slot in what their rebuild fetches when it lands. A rebuild
  that fails leaves the previous contents rather than an invisible menu,
  and a generation token stops a late fetch from repainting a menu that has
  since been closed or reopened (the old await also let two quick clicks
  cancel each other out). The + picker repaints only when the module list
  actually changed; the layout menu shows Save and Reset at once and
  inserts the server's layouts above Reset when they arrive.
- Tiles asking for the same stream share one connection. moduleApi.sse()
  now subscribes to a shell-wide pool with one EventSource per URL, fanning
  events out to every subscriber and reconnecting on the same 3-second
  timer while anyone listens; a tile joining an open stream is told `open`
  straight away, so its backfill runs as before. Six tiles of three modules
  now hold three connections instead of six. Modules see no difference.
- /api/modules and /api/layouts fetches give up after 8 s instead of
  pending forever, so nothing awaiting them can hang.

Verified in the browser pane against a scratch server: the same six-tile
layout now opens the picker instantly with all five modules listed and
/api/modules completes; with the pool deliberately starved (three extra
EventSources) the picker still opens from its cached list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-13 13:03:48 -04:00
co-authored by Claude Fable 5.1
parent a6dc2ddb85
commit c46f98818e
+174 -70
View File
@@ -71,12 +71,34 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
/* ── dropdown menus ─────────────────────────────────────────────────── */
/* A menu opens the moment its button is clicked, drawn from what the page
already knows; whatever its rebuild has to fetch is slotted in when it
lands. It used to open only once the rebuild had finished, which made the
+ picker hostage to a network round trip — one that never completes when
the tiles' streams hold every connection the browser allows (see the
shared-stream pool below). rebuild(menu, stillOpen) may be async;
stillOpen() says whether the open it was started for is still current,
so a late fetch can't repaint a menu that has since closed or reopened. */
function wireDropdown(btnId, menuId, rebuild) {
const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId);
btn.addEventListener('click', async () => {
if (menu.hidden) await rebuild(menu);
menu.hidden = !menu.hidden;
let generation = 0;
btn.addEventListener('click', () => {
if (!menu.hidden) {
menu.hidden = true;
return;
}
const gen = ++generation;
const stillOpen = () => gen === generation && !menu.hidden;
menu.hidden = false;
// Whatever the rebuild does, the menu is on screen; a failure (now or
// later) leaves its previous contents rather than an invisible menu.
const failed = (err) => console.error(`[shell] ${menuId} could not be rebuilt:`, err);
try {
Promise.resolve(rebuild(menu, stillOpen)).catch(failed);
} catch (err) {
failed(err);
}
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
@@ -106,11 +128,22 @@ function menuItem(label, onClick, { sub = '', danger = false } = {}) {
let registry = new Map(); // module id -> manifest from GET /api/modules
/** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything
awaiting it. */
function fetchWithTimeout(url, ms, opts = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), ms);
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(timer));
}
/** Refresh the enabled-module list. On failure the last known list is kept
(the server may just be restarting mid-service). Returns true on success. */
async function loadRegistry() {
try {
const res = await fetch('/api/modules');
// Slow modules' tiles() are cut off server-side at 2 s, so a healthy
// answer is well inside this.
const res = await fetchWithTimeout('/api/modules', 8000);
if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m]));
@@ -753,6 +786,84 @@ function tileMessage(entry, text) {
entry.body.appendChild(msg);
}
/* ── shared event streams ───────────────────────────────────────────── */
/* 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. */
const ssePool = new Map(); // url -> shared source
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 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);
}
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);
},
};
}
/**
* The API handed to each module instance — the whole surface a module may
* touch outside its root element. See docs/MODULE-GUIDE.md.
@@ -798,40 +909,17 @@ function buildModuleApi(tile, man, entry) {
},
/**
* EventSource scoped the same way, with auto-reconnect: EventSource
* retries transient drops itself but gives up for good when a retry gets
* a completed non-SSE response (a 502 while the upstream is down), so a
* closed stream is recreated on a timer until it works again.
* EventSource scoped the same way, with auto-reconnect: a stream that
* gives up (a 502 while the upstream is down) is recreated on a timer
* until it works again. Tiles asking for the same stream share one
* connection — see the pool above; a module sees no difference.
* handlers: { open(e), error(e), message(e), events: { name: fn } }
*/
sse(subPath, handlers = {}) {
const url = '/api/modules/' + man.id + subPath;
let es = null;
let retryTimer = null;
let closed = false;
const connect = () => {
if (closed) return;
try { es?.close(); } catch { /* already closed */ }
es = new EventSource(url);
if (handlers.open) es.onopen = handlers.open;
if (handlers.message) es.onmessage = handlers.message;
for (const [name, fn] of Object.entries(handlers.events || {})) {
es.addEventListener(name, fn);
}
es.onerror = (e) => {
try { handlers.error?.(e); } catch { /* module's problem */ }
if (es.readyState === EventSource.CLOSED) {
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, 3000);
}
};
};
connect();
const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
const handle = {
close() {
closed = true;
clearTimeout(retryTimer);
try { es?.close(); } catch { /* already closed */ }
shared.close();
sseHandles.delete(handle);
},
};
@@ -1182,8 +1270,21 @@ function renderLayout(list) {
/* ── menus ──────────────────────────────────────────────────────────── */
wireDropdown('add-btn', 'add-menu', async (menu) => {
await loadRegistry();
/* What the + picker shows, as a string: repainting a menu the user is
already pointing at is only worth it when something in it changed. */
function addMenuSignature() {
return JSON.stringify([...registry.values()].map((m) => [m.id, m.name, m.description, m.tiles]));
}
wireDropdown('add-btn', 'add-menu', async (menu, stillOpen) => {
// Painted from the list already in hand, then refreshed: a module's tile
// list is live data, and Admin may have enabled or disabled something.
const shown = addMenuSignature();
renderAddMenu(menu);
if (await loadRegistry() && stillOpen() && addMenuSignature() !== shown) renderAddMenu(menu);
});
function renderAddMenu(menu) {
menu.innerHTML = '';
const manifests = [...registry.values()];
if (!manifests.length) {
@@ -1215,9 +1316,9 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
}, { sub: man.description || '' }));
}
}
});
}
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = '';
menu.appendChild(menuItem('Save as named layout…', async () => {
@@ -1250,43 +1351,46 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
divider.className = 'menu-divider';
menu.appendChild(divider);
// named layouts stored on the server
let layouts = [];
try {
const res = await fetch('/api/layouts');
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (layouts.length) {
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
menu.appendChild(title);
for (const l of layouts) {
menu.appendChild(menuItem(l.name, async () => {
menu.hidden = true;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
renderLayout(body.layout.tiles || []);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
toast(e.message, true);
}
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
}
menu.appendChild(divider.cloneNode());
}
menu.appendChild(menuItem('Reset layout', () => {
const reset = menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' }));
}, { danger: true, sub: 'Remove every tile from this browser' });
menu.appendChild(reset);
// Named layouts stored on the server slot in above Reset once they
// arrive; until then (or if they never do) the menu is already usable.
let layouts = [];
try {
const res = await fetchWithTimeout('/api/layouts', 8000);
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (!layouts.length || !stillOpen()) return;
const block = document.createDocumentFragment();
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
block.appendChild(title);
for (const l of layouts) {
block.appendChild(menuItem(l.name, async () => {
menu.hidden = true;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
renderLayout(body.layout.tiles || []);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
toast(e.message, true);
}
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
}
block.appendChild(divider.cloneNode());
menu.insertBefore(block, reset);
});
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */