Unified endpoint config type: one Host/IP : Port control in admin

New configSchema type 'endpoint' declared in the module spec: anything
that points at a device or service on the network must be one endpoint
field, rendered identically for every module in /admin as a joined
Host/IP : Port control and stored/handed to the module server as
{ host, port }. Migrates all three connection modules — ProdCom (was a
full URL field), ProPresenter and Timers (were separate host + port
fields). Legacy stored configs (prodcomUrl / flat host+port) keep
working until the admin form is re-saved, at which point the new shape
replaces them. MODULE-GUIDE documents the type and the presentation
rule: no split host/port fields, and full URLs only for genuine web
addresses.

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 19:33:08 +00:00
parent 6412dd9524
commit a06bc921ce
11 changed files with 116 additions and 39 deletions
+4 -4
View File
@@ -83,16 +83,16 @@ are written by the app itself.
- **ProdCom** — live transcript stream with in-place updates of in-progress - **ProdCom** — live transcript stream with in-place updates of in-progress
speech, channel colors, per-tile channel visibility toggles and group speech, channel colors, per-tile channel visibility toggles and group
presets, timestamps, text size and jump-to-latest in the title bar. presets, timestamps, text size and jump-to-latest in the title bar.
Admin config: ProdCom URL, API key. Admin config: ProdCom server (host : port), API key.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
Admin config: host, port, password. Admin config: ProPresenter endpoint (host : port), password.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
and pick a selection via the tile's "Timers ▾" menu; overruns are and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config: host, unmistakably red. Read-only — no start/stop controls. Admin config:
port, password. ProPresenter endpoint (host : port), password.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
+29 -1
View File
@@ -64,7 +64,7 @@ shell code changes, ever.
Both schemas map field names to specs: Both schemas map field names to specs:
``` ```
{ "type": "string" | "number" | "boolean" | "select" | "password", { "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
"label": "Shown next to the field", "label": "Shown next to the field",
"default": <value>, "default": <value>,
"options": [ { "value": "a", "label": "A" }, … ] // "select" only "options": [ { "value": "a", "label": "A" }, … ] // "select" only
@@ -77,6 +77,34 @@ browsers** — not in `/api/modules`, and never echoed back into the admin
form. Anything upstream that needs the secret must go through your server form. Anything upstream that needs the secret must go through your server
routes. routes.
### Connection settings: use `endpoint`
Anything that points at a device or service on the network — ProPresenter,
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
module's connection is entered the same way. The admin page renders an
endpoint as a single "Host / IP : Port" control; the value is stored and
handed to your server as an object:
```json
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": { "host": "", "port": 1025 }
}
```
```js
// in your server entry
const { host, port } = config.propresenter;
const base = `http://${host}:${port}`;
```
Do **not** split host and port into separate `string`/`number` fields, and
do **not** ask for a full `http://…` URL unless the setting genuinely is a
web address where protocol or path matter (a page to embed, a webhook).
Endpoints belong in admin config, not per-tile settings — connections are
server-wide by design.
Admin config lives in `config/modules.json` (written by the admin page — Admin config lives in `config/modules.json` (written by the admin page —
your module never touches that file). Per-tile settings live inside each your module never touches that file). Per-tile settings live inside each
browser's layout. browser's layout.
+4 -4
View File
@@ -9,10 +9,10 @@
"minSize": { "w": 3, "h": 2 }, "minSize": { "w": 3, "h": 2 },
"defaultSize": { "w": 4, "h": 4 }, "defaultSize": { "w": 4, "h": 4 },
"configSchema": { "configSchema": {
"prodcomUrl": { "prodcom": {
"type": "string", "type": "endpoint",
"label": "ProdCom URL", "label": "ProdCom server",
"default": "http://10.3.11.152:24480" "default": { "host": "10.3.11.152", "port": 24480 }
}, },
"apiKey": { "apiKey": {
"type": "password", "type": "password",
+15 -4
View File
@@ -71,11 +71,22 @@ function proxy(req, res, target, apiKey) {
req.pipe(preq); req.pipe(preq);
} }
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
before the endpoint field existed) can only still be present until the
admin form is re-saved, so when it exists it wins over the endpoint's
schema default. */
function resolveTarget(config) {
if (config.prodcomUrl) return new URL(config.prodcomUrl);
const ep = config.prodcom;
if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`);
return null;
}
module.exports = { module.exports = {
init({ config }) { init({ config }) {
health = config.prodcomUrl health = resolveTarget(config)
? { status: 'connecting', message: 'No traffic yet' } ? { status: 'connecting', message: 'No traffic yet' }
: { status: 'error', message: 'No ProdCom URL configured' }; : { status: 'error', message: 'No ProdCom server configured' };
return { return {
stop() { /* nothing persistent to tear down — streams die with their sockets */ }, stop() { /* nothing persistent to tear down — streams die with their sockets */ },
health: () => health, health: () => health,
@@ -83,8 +94,8 @@ module.exports = {
}, },
routes({ config, log }) { routes({ config, log }) {
// An invalid URL throws here, which surfaces as a mount error in /admin. // An invalid host/URL throws here, which surfaces as a mount error in /admin.
const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480'); const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
log(`proxying /prodcom/* → ${target.origin}`); log(`proxying /prodcom/* → ${target.origin}`);
return { return {
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''), '* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
+4 -9
View File
@@ -9,15 +9,10 @@
"minSize": { "w": 3, "h": 3 }, "minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 4, "h": 5 }, "defaultSize": { "w": 4, "h": 5 },
"configSchema": { "configSchema": {
"host": { "propresenter": {
"type": "string", "type": "endpoint",
"label": "ProPresenter host / IP", "label": "ProPresenter",
"default": "" "default": { "host": "", "port": 1025 }
},
"port": {
"type": "number",
"label": "ProPresenter network port",
"default": 1025
}, },
"password": { "password": {
"type": "password", "type": "password",
+11 -5
View File
@@ -23,7 +23,13 @@ let current = null;
module.exports = { module.exports = {
init({ config, log }) { init({ config, log }) {
const streams = new Set(); const streams = new Set();
const enabled = Boolean(config.host && Number(config.port)); // Endpoint config. Legacy flat host/port keys (saved before the endpoint
// field existed) can only still be present until the admin form is
// re-saved, so when they exist they win over the endpoint's default.
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(config.host ?? ep.host ?? '');
const port = Number(config.port ?? ep.port) || 1025;
const enabled = Boolean(host && port);
let latest = clearedViewModel(); let latest = clearedViewModel();
let lastFrame = ''; let lastFrame = '';
@@ -54,14 +60,14 @@ module.exports = {
client.configure({ client.configure({
enabled, enabled,
host: String(config.host || ''), host,
port: Number(config.port) || 1025, port,
password: String(config.password || ''), password: String(config.password || ''),
}); });
if (enabled) { if (enabled) {
client.start(); client.start();
log(`following ProPresenter at ${config.host}:${config.port}`); log(`following ProPresenter at ${host}:${port}`);
} else { } else {
log('no ProPresenter host configured — set one in /admin'); log('no ProPresenter host configured — set one in /admin');
} }
@@ -91,7 +97,7 @@ module.exports = {
}, },
health() { health() {
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` }; if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` };
return { return {
status: 'error', status: 'error',
message: latest.lastError message: latest.lastError
+4 -9
View File
@@ -9,15 +9,10 @@
"minSize": { "w": 2, "h": 1 }, "minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 2 }, "defaultSize": { "w": 4, "h": 2 },
"configSchema": { "configSchema": {
"host": { "propresenter": {
"type": "string", "type": "endpoint",
"label": "ProPresenter host / IP", "label": "ProPresenter",
"default": "" "default": { "host": "", "port": 1025 }
},
"port": {
"type": "number",
"label": "ProPresenter network port",
"default": 1025
}, },
"password": { "password": {
"type": "password", "type": "password",
+6 -2
View File
@@ -65,8 +65,12 @@ function clearedState(enabled) {
module.exports = { module.exports = {
init({ config, log }) { init({ config, log }) {
const streams = new Set(); const streams = new Set();
const host = String(config.host || ''); // Endpoint config. Legacy flat host/port keys (saved before the endpoint
const port = Number(config.port) || 1025; // field existed) can only still be present until the admin form is
// re-saved, so when they exist they win over the endpoint's default.
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(config.host ?? ep.host ?? '');
const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || ''); const password = String(config.password || '');
const enabled = Boolean(host && port); const enabled = Boolean(host && port);
+6
View File
@@ -93,6 +93,12 @@ body { overflow: auto; }
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; } .module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
.module-form .field { max-width: 420px; } .module-form .field { max-width: 420px; }
/* endpoint fields: every module's upstream is entered the same way */
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
.endpoint-row .ep-host { flex: 1; min-width: 0; }
.endpoint-row .ep-port { width: 92px; flex: none; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
.module-actions { .module-actions {
+26
View File
@@ -96,6 +96,32 @@ function fieldFor(key, spec, value, passwordSet) {
input.checked = Boolean(value); input.checked = Boolean(value);
field.append(input, document.createTextNode(label)); field.append(input, document.createTextNode(label));
read = () => input.checked; read = () => input.checked;
} else if (type === 'endpoint') {
// One consistent "Host / IP : Port" control for every module's upstream.
const span = document.createElement('span');
span.textContent = label;
const row = document.createElement('span');
row.className = 'endpoint-row';
const host = document.createElement('input');
host.type = 'text';
host.className = 'ep-host';
host.placeholder = 'Host / IP';
host.autocomplete = 'off';
host.spellcheck = false;
host.value = value?.host ? String(value.host) : '';
const sep = document.createElement('span');
sep.className = 'ep-sep';
sep.textContent = ':';
const port = document.createElement('input');
port.type = 'number';
port.className = 'ep-port';
port.placeholder = 'Port';
port.min = '1';
port.max = '65535';
port.value = value?.port ? String(value.port) : '';
row.append(host, sep, port);
field.append(span, row);
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
} else if (type === 'select' && Array.isArray(spec.options)) { } else if (type === 'select' && Array.isArray(spec.options)) {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
+7 -1
View File
@@ -413,7 +413,13 @@ function applyConfigPatch(id, patch) {
} }
if (type === 'number') value = Number(value) || 0; if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean') value = Boolean(value); else if (type === 'boolean') value = Boolean(value);
else value = value === undefined || value === null ? '' : String(value); else if (type === 'endpoint') {
const src = value && typeof value === 'object' ? value : {};
value = {
host: String(src.host ?? '').trim(),
port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)),
};
} else value = value === undefined || value === null ? '' : String(value);
next[key] = value; next[key] = value;
} }
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next }; modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };