Module contract: modules can present multiple tiles to the picker

A module may now present a list of tiles instead of a single picker
entry. A server module exports tiles({config, log}) — sync or async,
called on every picker load so the list can be live data (discovered
ProPresenter timers, admin-configured pages); a client-only module can
declare a static tiles array in module.json. The + picker groups the
entries under the module's name; adding one presets that entry's
instance settings, gives the tile the entry's name as its title, and
honors per-entry default/min sizes, all persisted with the layout.
moduleApi.variant tells the instance which entry it was added as. A
throwing or hung tiles() (2 s deadline) falls back to the classic single
entry so a dead upstream can never break the picker; modules without
tiles are byte-for-byte unaffected. Documented in docs/MODULE-GUIDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-08-31 17:53:06 +00:00
parent 404b840154
commit 6211538b34
3 changed files with 159 additions and 15 deletions
+62 -3
View File
@@ -125,6 +125,13 @@ function clientConfig(id) {
* init({ config, log }) -> handle with stop() (and optionally health())
* routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler,
* 'GET /proxy/*': handler, '* /any/*': handler }
* tiles({ config, log }) -> optional, sync or async: the tiles this module
* presents in the Add-tile picker, e.g.
* [{ id, name, description, settings, minSize,
* defaultSize }]. Called on every picker load,
* so the list may be dynamic (discovered timers,
* admin-configured pages). Omitted or empty →
* the module appears once, as before.
* Routes are mounted under /api/modules/<id>/. A trailing "/*" makes a prefix
* route; the matched remainder is passed as req.wildcard (req.search carries
* the query string). init() runs before routes(), so routes can close over
@@ -173,6 +180,9 @@ function mountModule(id) {
if (typeof mod.routes === 'function') {
entry.routes = parseRouteTable(mod.routes({ config, log }));
}
if (typeof mod.tiles === 'function') {
entry.tiles = () => mod.tiles({ config, log });
}
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
} catch (err) {
entry.error = err instanceof Error ? err.message : String(err);
@@ -243,8 +253,51 @@ function readBody(req, limitBytes = 512 * 1024) {
});
}
/** One entry of a module's tile list, with everything untrusted dropped. */
function sanitizeTileEntry(raw) {
if (!raw || typeof raw !== 'object' || !raw.id || !raw.name) return null;
const out = {
id: String(raw.id),
name: String(raw.name),
description: raw.description ? String(raw.description) : '',
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
};
for (const key of ['minSize', 'defaultSize']) {
const size = raw[key];
if (size && Number(size.w) > 0 && Number(size.h) > 0) {
out[key] = { w: Number(size.w), h: Number(size.h) };
}
}
return out;
}
/**
* The tiles a module presents in the picker. A module's tiles() runs on every
* picker load so the list can be live data — but a hung or throwing module
* must never stall the picker, so it gets a short deadline and any failure
* falls back to the classic single entry (empty list).
*/
async function moduleTiles(id) {
const man = manifests.get(id);
const entry = mounted.get(id);
let list = Array.isArray(man.tiles) ? man.tiles : []; // static, for client-only modules
if (entry?.tiles) {
try {
const dynamic = await Promise.race([
Promise.resolve(entry.tiles()),
new Promise((_, reject) => setTimeout(() => reject(new Error('tiles() timed out')), 2000).unref()),
]);
if (Array.isArray(dynamic)) list = dynamic;
} catch (err) {
console.error(`[proddash] module "${id}" tiles() failed:`, err?.message || err);
list = [];
}
}
return list.map(sanitizeTileEntry).filter(Boolean).slice(0, 100);
}
/** Manifest as sent to the dashboard client (enabled modules only). */
function clientManifest(id) {
async function clientManifest(id) {
const man = manifests.get(id);
return {
id: man.id,
@@ -258,6 +311,7 @@ function clientManifest(id) {
instanceSchema: man.instanceSchema || {},
hasServer: Boolean(man.server),
config: clientConfig(id),
tiles: await moduleTiles(id),
};
}
@@ -652,8 +706,13 @@ function handleRequest(req, res) {
/* — shell API — */
if (urlPath === '/api/modules' && req.method === 'GET') {
const list = [...manifests.keys()].filter(isEnabled).map(clientManifest);
return sendJson(res, 200, { modules: list });
Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest))
.then((list) => sendJson(res, 200, { modules: list }))
.catch((err) => {
console.error('[proddash] /api/modules failed:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
});
return;
}
/* — shell events: open dashboards learn about admin changes live — */