Module system: discovery, route mounting, client loader, clock module
Milestone 2. Server scans modules/*/module.json, serves module assets at /modules/<id>/, mounts optional server routes (exact + trailing-/* prefix patterns) under /api/modules/<id>/ with per-module config from config/modules.json, and lists enabled manifests at GET /api/modules. Shell dynamically imports each module's client.js and hands it the moduleApi (config, instanceSettings, scoped fetch, auto-reconnecting sse, setStatus). Clock module proves the contract end to end; password-typed config fields never leave the server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -3,3 +3,7 @@ dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
|
||||
# runtime state written by the admin page / layout saves
|
||||
config/modules.json
|
||||
config/layouts/
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Clock — the reference ProdDash module. Client-only: no server entry.
|
||||
Everything a module may do is shown here: render inside root, read
|
||||
instanceSettings, report status, clean up in stop(). */
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let timer = null;
|
||||
let timeEl = null;
|
||||
let dateEl = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
function settings() {
|
||||
return moduleApi.instanceSettings; // shell merges schema defaults for us
|
||||
}
|
||||
|
||||
function renderFrame() {
|
||||
root.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'clock-wrap';
|
||||
timeEl = document.createElement('div');
|
||||
timeEl.className = 'clock-time';
|
||||
dateEl = document.createElement('div');
|
||||
dateEl.className = 'clock-date';
|
||||
wrap.append(timeEl, dateEl);
|
||||
root.appendChild(wrap);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const { format, showSeconds, showDate } = settings();
|
||||
const now = new Date();
|
||||
timeEl.textContent = now.toLocaleTimeString([], {
|
||||
hour12: format === '12h',
|
||||
hour: format === '12h' ? 'numeric' : '2-digit',
|
||||
minute: '2-digit',
|
||||
...(showSeconds ? { second: '2-digit' } : {}),
|
||||
});
|
||||
dateEl.hidden = !showDate;
|
||||
if (showDate) {
|
||||
dateEl.textContent = now.toLocaleDateString([], {
|
||||
weekday: 'long', month: 'long', day: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Scale the digits to the tile (CSS alone can't see the tile's box). */
|
||||
function fit() {
|
||||
const { showSeconds, showDate } = settings();
|
||||
const chars = (showSeconds ? 8 : 5) + 1;
|
||||
const size = Math.min((root.clientWidth * 1.7) / chars, root.clientHeight * (showDate ? 0.42 : 0.55));
|
||||
timeEl.style.fontSize = Math.max(12, Math.floor(size)) + 'px';
|
||||
dateEl.style.fontSize = Math.max(10, Math.floor(size * 0.28)) + 'px';
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
renderFrame();
|
||||
tick();
|
||||
fit();
|
||||
timer = setInterval(tick, 250);
|
||||
resizeObserver = new ResizeObserver(fit);
|
||||
resizeObserver.observe(root);
|
||||
moduleApi.setStatus('ok', 'Ticking');
|
||||
},
|
||||
stop() {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
root.innerHTML = '';
|
||||
},
|
||||
onResize() {
|
||||
fit();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "clock",
|
||||
"name": "Clock",
|
||||
"version": "1.0.0",
|
||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
||||
"client": "client.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
"defaultSize": { "w": 3, "h": 2 },
|
||||
"instanceSchema": {
|
||||
"format": {
|
||||
"type": "select",
|
||||
"label": "Format",
|
||||
"default": "24h",
|
||||
"options": [
|
||||
{ "value": "24h", "label": "24-hour" },
|
||||
{ "value": "12h", "label": "12-hour" }
|
||||
]
|
||||
},
|
||||
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true },
|
||||
"showDate": { "type": "boolean", "label": "Show date", "default": false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* Clock module — scoped under its tile via [data-module="clock"], styled
|
||||
with the shell's CSS variables only. */
|
||||
|
||||
[data-module="clock"] .clock-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2%;
|
||||
}
|
||||
|
||||
[data-module="clock"] .clock-time {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-module="clock"] .clock-date {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
+158
-32
@@ -104,32 +104,20 @@ function menuItem(label, onClick, { sub = '', danger = false } = {}) {
|
||||
|
||||
/* ── module registry ────────────────────────────────────────────────── */
|
||||
|
||||
/* Filled from GET /api/modules once the module system is up (milestone 2).
|
||||
Until then — or if the fetch fails — the shell offers a built-in
|
||||
placeholder tile so the grid itself is usable and testable. */
|
||||
|
||||
const PLACEHOLDER_MANIFEST = {
|
||||
id: '__placeholder',
|
||||
name: 'Placeholder',
|
||||
description: 'An empty tile for trying out the grid',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 1, h: 1 },
|
||||
builtin: true,
|
||||
};
|
||||
|
||||
let registry = new Map(); // module id -> manifest
|
||||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||||
|
||||
/** 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() {
|
||||
registry = new Map();
|
||||
try {
|
||||
const res = await fetch('/api/modules');
|
||||
if (!res.ok) throw new Error('modules ' + res.status);
|
||||
const body = await res.json();
|
||||
for (const m of body.modules || []) registry.set(m.id, m);
|
||||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||||
return true;
|
||||
} catch {
|
||||
/* no module API yet (or server just restarted) — placeholder only */
|
||||
return false;
|
||||
}
|
||||
if (!registry.size) registry.set(PLACEHOLDER_MANIFEST.id, PLACEHOLDER_MANIFEST);
|
||||
}
|
||||
|
||||
/* ── layout state ───────────────────────────────────────────────────── */
|
||||
@@ -434,34 +422,167 @@ function clearAllTiles() {
|
||||
|
||||
/* ── module mounting ────────────────────────────────────────────────── */
|
||||
|
||||
/* Milestone 2 turns this into the real module loader (dynamic import of each
|
||||
module's client.js plus the moduleApi). For now only the built-in
|
||||
placeholder renders. */
|
||||
const clientModuleCache = new Map(); // module id -> Promise<ES module>
|
||||
|
||||
function mountModule(tile) {
|
||||
function loadClientModule(man) {
|
||||
if (!clientModuleCache.has(man.id)) {
|
||||
clientModuleCache.set(man.id, import(`/modules/${man.id}/${man.client || 'client.js'}`));
|
||||
}
|
||||
return clientModuleCache.get(man.id);
|
||||
}
|
||||
|
||||
/** Inject a module's stylesheet once (shared by every instance of it). */
|
||||
function ensureModuleStyle(man) {
|
||||
if (!man.style) return;
|
||||
const id = 'module-style-' + man.id;
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement('link');
|
||||
link.id = id;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `/modules/${man.id}/${man.style}`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function tileMessage(entry, text) {
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'tile-msg';
|
||||
msg.textContent = text;
|
||||
entry.body.appendChild(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* The API handed to each module instance — the whole surface a module may
|
||||
* touch outside its root element. See docs/MODULE-GUIDE.md.
|
||||
*/
|
||||
function buildModuleApi(tile, man, entry) {
|
||||
const sseHandles = new Set();
|
||||
|
||||
const api = {
|
||||
id: man.id,
|
||||
instanceId: tile.id,
|
||||
|
||||
/** Admin (server-wide) config, read-only. Password fields never reach the client. */
|
||||
config: Object.freeze({ ...(man.config || {}) }),
|
||||
|
||||
/** Per-tile settings: schema defaults overlaid with what this tile saved. */
|
||||
get instanceSettings() {
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(man.instanceSchema || {})) {
|
||||
if (spec && 'default' in spec) out[key] = spec.default;
|
||||
}
|
||||
return Object.assign(out, tile.settings || {});
|
||||
},
|
||||
|
||||
/** Persist per-tile settings into the layout (no remount — the module
|
||||
already knows, it made the change). */
|
||||
saveInstanceSettings(patch) {
|
||||
Object.assign(tile.settings, patch || {});
|
||||
saveLayout();
|
||||
},
|
||||
|
||||
/** fetch scoped to this module's server routes. */
|
||||
fetch(subPath, opts) {
|
||||
return fetch('/api/modules/' + man.id + subPath, opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 handle = {
|
||||
close() {
|
||||
closed = true;
|
||||
clearTimeout(retryTimer);
|
||||
try { es?.close(); } catch { /* already closed */ }
|
||||
sseHandles.delete(handle);
|
||||
},
|
||||
};
|
||||
sseHandles.add(handle);
|
||||
return handle;
|
||||
},
|
||||
|
||||
/** Drive the tile's status dot: 'ok' | 'connecting' | 'error'. */
|
||||
setStatus(state, msg = '') {
|
||||
const cls = state === 'ok' || state === 'connecting' || state === 'error' ? state : '';
|
||||
entry.dot.className = 'dot ' + cls;
|
||||
entry.dot.title = msg;
|
||||
},
|
||||
|
||||
/* shell-internal: safety net so a forgotten stream can't outlive the tile */
|
||||
_closeAll() {
|
||||
for (const handle of [...sseHandles]) handle.close();
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
async function mountModule(tile) {
|
||||
const entry = tileEls.get(tile.id);
|
||||
if (!entry) return;
|
||||
const man = registry.get(tile.module);
|
||||
if (!man) {
|
||||
entry.body.innerHTML = '<div class="tile-msg">Module “' + tile.module + '” is not installed.</div>';
|
||||
tileMessage(entry, `Module “${tile.module}” is not installed or is disabled. Check Admin.`);
|
||||
entry.dot.className = 'dot error';
|
||||
entry.dot.title = 'Module unavailable';
|
||||
return;
|
||||
}
|
||||
if (man.builtin) {
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'tile-msg';
|
||||
msg.textContent = 'Placeholder tile — drag me by the header, resize me by the corner.';
|
||||
entry.body.appendChild(msg);
|
||||
entry.dot.className = 'dot ok';
|
||||
entry.dot.className = 'dot connecting';
|
||||
ensureModuleStyle(man);
|
||||
try {
|
||||
const mod = await loadClientModule(man);
|
||||
if (tileEls.get(tile.id) !== entry || entry.instance) return; // tile removed/remounted while loading
|
||||
const factory = mod.default;
|
||||
if (typeof factory !== 'function') throw new Error('client.js has no default-export factory');
|
||||
const api = buildModuleApi(tile, man, entry);
|
||||
const instance = factory({ root: entry.body, moduleApi: api }) || {};
|
||||
entry.instance = instance;
|
||||
entry.api = api;
|
||||
instance.start?.();
|
||||
} catch (err) {
|
||||
console.error(`[shell] module "${tile.module}" failed to start:`, err);
|
||||
entry.body.innerHTML = '';
|
||||
tileMessage(entry, `“${man.name}” failed to start: ${err?.message || err}`);
|
||||
entry.dot.className = 'dot error';
|
||||
}
|
||||
}
|
||||
|
||||
function unmountModule(tile) {
|
||||
const entry = tileEls.get(tile.id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
entry?.instance?.stop?.();
|
||||
entry.instance?.stop?.();
|
||||
} catch { /* stopping is best-effort */ }
|
||||
if (entry) entry.instance = null;
|
||||
try {
|
||||
entry.api?._closeAll();
|
||||
} catch { /* ditto */ }
|
||||
entry.instance = null;
|
||||
entry.api = null;
|
||||
}
|
||||
|
||||
/* ── per-tile settings popover ──────────────────────────────────────── */
|
||||
@@ -598,7 +719,12 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
||||
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||
|
||||
async function boot() {
|
||||
await loadRegistry();
|
||||
const ok = await loadRegistry();
|
||||
if (!ok) {
|
||||
toast('ProdDash server unreachable — retrying…', true);
|
||||
setTimeout(boot, 4000);
|
||||
return;
|
||||
}
|
||||
renderLayout(loadLayoutFromStorage());
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ const os = require('os');
|
||||
const ROOT = __dirname;
|
||||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||
const CONFIG_DIR = path.join(ROOT, 'config');
|
||||
const MODULES_DIR = path.join(ROOT, 'modules');
|
||||
const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json');
|
||||
|
||||
/* ── config ─────────────────────────────────────────────────────────── */
|
||||
/* ── config files ───────────────────────────────────────────────────── */
|
||||
|
||||
function readJson(file, fallback) {
|
||||
try {
|
||||
@@ -33,9 +35,270 @@ function readJson(file, fallback) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = file + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
|
||||
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
||||
}
|
||||
|
||||
const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {});
|
||||
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
||||
|
||||
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
||||
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
||||
|
||||
/* ── module discovery ───────────────────────────────────────────────── */
|
||||
|
||||
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
||||
const manifests = new Map();
|
||||
|
||||
function discoverModules() {
|
||||
manifests.clear();
|
||||
let dirs = [];
|
||||
try {
|
||||
dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
} catch {
|
||||
return; // no modules/ folder yet — the shell still runs
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
const manifestPath = path.join(MODULES_DIR, dir, 'module.json');
|
||||
const man = readJson(manifestPath, null);
|
||||
if (!man || typeof man !== 'object') {
|
||||
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
||||
continue;
|
||||
}
|
||||
if (man.id && man.id !== dir) {
|
||||
console.warn(`[proddash] module "${dir}": manifest id "${man.id}" must match its folder name — skipped`);
|
||||
continue;
|
||||
}
|
||||
man.id = dir;
|
||||
if (!man.client) {
|
||||
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
||||
continue;
|
||||
}
|
||||
man.dir = path.join(MODULES_DIR, dir);
|
||||
manifests.set(dir, man);
|
||||
}
|
||||
}
|
||||
|
||||
function moduleState(id) {
|
||||
const s = modulesConfig[id];
|
||||
return s && typeof s === 'object' ? s : {};
|
||||
}
|
||||
|
||||
function isEnabled(id) {
|
||||
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
||||
}
|
||||
|
||||
/** Admin config for a module: schema defaults overlaid with stored values. */
|
||||
function effectiveConfig(id) {
|
||||
const man = manifests.get(id);
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(man?.configSchema || {})) {
|
||||
if (spec && 'default' in spec) out[key] = spec.default;
|
||||
}
|
||||
const stored = moduleState(id).config;
|
||||
if (stored && typeof stored === 'object') Object.assign(out, stored);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Config as sent to browsers: password-typed fields never leave the server. */
|
||||
function clientConfig(id) {
|
||||
const man = manifests.get(id);
|
||||
const cfg = effectiveConfig(id);
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(cfg)) {
|
||||
if (man?.configSchema?.[key]?.type === 'password') continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── module server mounting ─────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A module's optional server.js exports:
|
||||
* init({ config, log }) -> handle with stop() (and optionally health())
|
||||
* routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler,
|
||||
* 'GET /proxy/*': handler, '* /any/*': handler }
|
||||
* 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
|
||||
* state init created.
|
||||
*/
|
||||
const mounted = new Map(); // id -> { routes: [...], handle, error }
|
||||
|
||||
function makeLog(id) {
|
||||
return (...args) => console.log(`[${id}]`, ...args);
|
||||
}
|
||||
|
||||
function parseRouteTable(table) {
|
||||
const routes = [];
|
||||
for (const [key, handler] of Object.entries(table || {})) {
|
||||
if (typeof handler !== 'function') continue;
|
||||
const sp = key.indexOf(' ');
|
||||
if (sp < 0) continue;
|
||||
const method = key.slice(0, sp).toUpperCase();
|
||||
let route = key.slice(sp + 1).trim();
|
||||
let wildcard = false;
|
||||
if (route.endsWith('/*')) {
|
||||
wildcard = true;
|
||||
route = route.slice(0, -2) || '';
|
||||
}
|
||||
if (!route.startsWith('/')) route = '/' + route;
|
||||
routes.push({ method, path: route, wildcard, handler });
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
function mountModule(id) {
|
||||
const man = manifests.get(id);
|
||||
if (!man) return;
|
||||
const entry = { routes: [], handle: null, error: '' };
|
||||
mounted.set(id, entry);
|
||||
if (!man.server) return;
|
||||
const log = makeLog(id);
|
||||
try {
|
||||
const serverPath = path.join(man.dir, man.server);
|
||||
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
||||
const mod = require(serverPath);
|
||||
const config = effectiveConfig(id);
|
||||
if (typeof mod.init === 'function') {
|
||||
entry.handle = mod.init({ config, log }) || null;
|
||||
}
|
||||
if (typeof mod.routes === 'function') {
|
||||
entry.routes = parseRouteTable(mod.routes({ config, log }));
|
||||
}
|
||||
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
||||
} catch (err) {
|
||||
entry.error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[proddash] module "${id}" failed to mount:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function unmountModule(id) {
|
||||
const entry = mounted.get(id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
entry.handle?.stop?.();
|
||||
} catch (err) {
|
||||
console.error(`[proddash] module "${id}" failed to stop cleanly:`, err);
|
||||
}
|
||||
mounted.delete(id);
|
||||
}
|
||||
|
||||
/** Re-init a module after its config changed (admin page). */
|
||||
function remountModule(id) {
|
||||
unmountModule(id);
|
||||
if (manifests.has(id) && isEnabled(id)) mountModule(id);
|
||||
}
|
||||
|
||||
function mountAllModules() {
|
||||
for (const id of manifests.keys()) {
|
||||
if (isEnabled(id)) mountModule(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Health of a mounted module, as reported by its own handle.health(). */
|
||||
function moduleHealth(id) {
|
||||
const entry = mounted.get(id);
|
||||
if (!entry) return null;
|
||||
if (entry.error) return { status: 'error', message: entry.error };
|
||||
try {
|
||||
const h = entry.handle?.health?.();
|
||||
if (h && typeof h === 'object') return { status: String(h.status || 'ok'), message: String(h.message || '') };
|
||||
} catch { /* a bad health() must not break the admin page */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── shell API helpers ──────────────────────────────────────────────── */
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBody(req, limitBytes = 512 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
let size = 0;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > limitBytes) {
|
||||
reject(new Error('Request body too large.'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
req.on('end', () => resolve(data));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Manifest as sent to the dashboard client (enabled modules only). */
|
||||
function clientManifest(id) {
|
||||
const man = manifests.get(id);
|
||||
return {
|
||||
id: man.id,
|
||||
name: String(man.name || man.id),
|
||||
version: String(man.version || ''),
|
||||
description: String(man.description || ''),
|
||||
client: String(man.client),
|
||||
style: man.style ? String(man.style) : '',
|
||||
minSize: man.minSize || { w: 1, h: 1 },
|
||||
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
||||
instanceSchema: man.instanceSchema || {},
|
||||
hasServer: Boolean(man.server),
|
||||
config: clientConfig(id),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── module API dispatch ────────────────────────────────────────────── */
|
||||
|
||||
function dispatchModuleApi(req, res, id, subPath, search) {
|
||||
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
||||
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
||||
const entry = mounted.get(id);
|
||||
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
||||
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
||||
|
||||
for (const route of entry.routes) {
|
||||
if (route.method !== '*' && route.method !== req.method) continue;
|
||||
if (route.wildcard) {
|
||||
if (subPath === route.path || subPath.startsWith(route.path + '/')) {
|
||||
req.wildcard = subPath.slice(route.path.length) || '/';
|
||||
req.search = search;
|
||||
try {
|
||||
route.handler(req, res);
|
||||
} catch (err) {
|
||||
console.error(`[${id}] route handler threw:`, err);
|
||||
if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' });
|
||||
else try { res.end(); } catch { /* gone */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (subPath === route.path) {
|
||||
req.search = search;
|
||||
try {
|
||||
route.handler(req, res);
|
||||
} catch (err) {
|
||||
console.error(`[${id}] route handler threw:`, err);
|
||||
if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' });
|
||||
else try { res.end(); } catch { /* gone */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendJson(res, 404, { error: `Module "${id}" has no route for ${req.method} ${subPath}` });
|
||||
}
|
||||
|
||||
/* ── static files ───────────────────────────────────────────────────── */
|
||||
|
||||
const MIME = {
|
||||
@@ -90,22 +353,61 @@ function serveFile(res, baseDir, urlPath) {
|
||||
|
||||
/* ── request routing ────────────────────────────────────────────────── */
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Everything is guarded: one throw must never take the dashboard down mid-service.
|
||||
try {
|
||||
function handleRequest(req, res) {
|
||||
let urlPath;
|
||||
let search = '';
|
||||
try {
|
||||
urlPath = new URL(req.url, 'http://proddash.invalid').pathname;
|
||||
const u = new URL(req.url, 'http://proddash.invalid');
|
||||
urlPath = u.pathname;
|
||||
search = u.search || '';
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
return res.end('Bad request');
|
||||
}
|
||||
|
||||
/* — shell API — */
|
||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||
const list = [...manifests.keys()].filter(isEnabled).map(clientManifest);
|
||||
return sendJson(res, 200, { modules: list });
|
||||
}
|
||||
|
||||
/* — module APIs: /api/modules/<id>/… (any method; modules decide) — */
|
||||
const apiMatch = urlPath.match(/^\/api\/modules\/([^/]+)(\/.*)?$/);
|
||||
if (apiMatch) {
|
||||
const id = decodeURIComponent(apiMatch[1]);
|
||||
const subPath = apiMatch[2] || '/';
|
||||
return dispatchModuleApi(req, res, id, subPath, search);
|
||||
}
|
||||
|
||||
if (urlPath.startsWith('/api/')) {
|
||||
return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
||||
}
|
||||
|
||||
/* — static — */
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405, { Allow: 'GET, HEAD' });
|
||||
return res.end('Method not allowed');
|
||||
}
|
||||
|
||||
/* module client assets: /modules/<id>/… */
|
||||
const assetMatch = urlPath.match(/^\/modules\/([^/]+)(\/.*)?$/);
|
||||
if (assetMatch) {
|
||||
const id = decodeURIComponent(assetMatch[1]);
|
||||
const man = manifests.get(id);
|
||||
if (!man || !isEnabled(id)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
return res.end('Not found');
|
||||
}
|
||||
return serveFile(res, man.dir, assetMatch[2] || '/');
|
||||
}
|
||||
|
||||
serveFile(res, PUBLIC_DIR, urlPath);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Everything is guarded: one throw must never take the dashboard down mid-service.
|
||||
try {
|
||||
handleRequest(req, res);
|
||||
} catch (err) {
|
||||
console.error('[proddash] request error:', err);
|
||||
try {
|
||||
@@ -118,6 +420,11 @@ const server = http.createServer((req, res) => {
|
||||
// Never time out long-lived SSE connections.
|
||||
server.requestTimeout = 0;
|
||||
|
||||
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||
|
||||
discoverModules();
|
||||
mountAllModules();
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log('ProdDash running');
|
||||
console.log(' Local : http://localhost:' + PORT);
|
||||
@@ -128,4 +435,16 @@ server.listen(PORT, '0.0.0.0', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const ids = [...manifests.keys()];
|
||||
console.log(ids.length
|
||||
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ')
|
||||
: ' Modules : none installed');
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 1500).unref();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user