From c46f98818eb96334b7df06db74ebf14f12063c69 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 13:03:48 -0400 Subject: [PATCH] =?UTF-8?q?Shell:=20the=20=EF=BC=8B=20picker=20opens=20at?= =?UTF-8?q?=20once,=20and=20tiles=20share=20their=20streams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/shell.js | 244 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 174 insertions(+), 70 deletions(-) diff --git a/public/shell.js b/public/shell.js index 3b2e20c..c0293f0 100644 --- a/public/shell.js +++ b/public/shell.js @@ -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) ───────────── */