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:
co-authored by
Claude Fable 5.1
parent
a6dc2ddb85
commit
c46f98818e
+174
-70
@@ -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) ───────────── */
|
||||
|
||||
Reference in New Issue
Block a user