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
+155 -51
View File
@@ -71,12 +71,34 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
/* ── dropdown menus ─────────────────────────────────────────────────── */ /* ── 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) { function wireDropdown(btnId, menuId, rebuild) {
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId); const menu = document.getElementById(menuId);
btn.addEventListener('click', async () => { let generation = 0;
if (menu.hidden) await rebuild(menu); btn.addEventListener('click', () => {
menu.hidden = !menu.hidden; 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) => { document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) { 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 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 /** 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. */ (the server may just be restarting mid-service). Returns true on success. */
async function loadRegistry() { async function loadRegistry() {
try { 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); if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json(); const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m])); registry = new Map((body.modules || []).map((m) => [m.id, m]));
@@ -753,6 +786,84 @@ function tileMessage(entry, text) {
entry.body.appendChild(msg); 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 * The API handed to each module instance — the whole surface a module may
* touch outside its root element. See docs/MODULE-GUIDE.md. * 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 * EventSource scoped the same way, with auto-reconnect: a stream that
* retries transient drops itself but gives up for good when a retry gets * gives up (a 502 while the upstream is down) is recreated on a timer
* a completed non-SSE response (a 502 while the upstream is down), so a * until it works again. Tiles asking for the same stream share one
* closed stream is recreated on a timer until it works again. * connection — see the pool above; a module sees no difference.
* handlers: { open(e), error(e), message(e), events: { name: fn } } * handlers: { open(e), error(e), message(e), events: { name: fn } }
*/ */
sse(subPath, handlers = {}) { sse(subPath, handlers = {}) {
const url = '/api/modules/' + man.id + subPath; const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
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 handle = { const handle = {
close() { close() {
closed = true; shared.close();
clearTimeout(retryTimer);
try { es?.close(); } catch { /* already closed */ }
sseHandles.delete(handle); sseHandles.delete(handle);
}, },
}; };
@@ -1182,8 +1270,21 @@ function renderLayout(list) {
/* ── menus ──────────────────────────────────────────────────────────── */ /* ── menus ──────────────────────────────────────────────────────────── */
wireDropdown('add-btn', 'add-menu', async (menu) => { /* What the + picker shows, as a string: repainting a menu the user is
await loadRegistry(); 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 = ''; menu.innerHTML = '';
const manifests = [...registry.values()]; const manifests = [...registry.values()];
if (!manifests.length) { if (!manifests.length) {
@@ -1215,9 +1316,9 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
}, { sub: man.description || '' })); }, { sub: man.description || '' }));
} }
} }
}); }
wireDropdown('layout-btn', 'layout-menu', async (menu) => { wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = ''; menu.innerHTML = '';
menu.appendChild(menuItem('Save as named layout…', async () => { menu.appendChild(menuItem('Save as named layout…', async () => {
@@ -1250,19 +1351,29 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
divider.className = 'menu-divider'; divider.className = 'menu-divider';
menu.appendChild(divider); menu.appendChild(divider);
// named layouts stored on the server const reset = menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { 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 = []; let layouts = [];
try { try {
const res = await fetch('/api/layouts'); const res = await fetchWithTimeout('/api/layouts', 8000);
if (res.ok) layouts = (await res.json()).layouts || []; if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ } } catch { /* server briefly away — the menu just shows none */ }
if (layouts.length) { if (!layouts.length || !stillOpen()) return;
const block = document.createDocumentFragment();
const title = document.createElement('div'); const title = document.createElement('div');
title.className = 'menu-title'; title.className = 'menu-title';
title.textContent = 'Load from server'; title.textContent = 'Load from server';
menu.appendChild(title); block.appendChild(title);
for (const l of layouts) { for (const l of layouts) {
menu.appendChild(menuItem(l.name, async () => { block.appendChild(menuItem(l.name, async () => {
menu.hidden = true; menu.hidden = true;
try { try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name)); const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
@@ -1278,15 +1389,8 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
} }
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` })); }, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
} }
menu.appendChild(divider.cloneNode()); block.appendChild(divider.cloneNode());
} menu.insertBefore(block, reset);
menu.appendChild(menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' }));
}); });
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */ /* ── menu bar show/hide (notch, like the tile title bars) ───────────── */