Files
ProdDash/modules/prodcom-transcript/server.js
T
Claude a06bc921ce 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
2026-08-31 19:33:08 +00:00

105 lines
3.7 KiB
JavaScript

'use strict';
/**
* ProdCom Transcript — server part.
*
* One wildcard route that proxies /prodcom/* (including the SSE transcript
* stream) to the ProdCom API configured in the admin page, so browsers never
* talk to ProdCom directly (no CORS, and the API key never reaches clients).
* Ported from prodcom-listener/server.js.
*/
const http = require('http');
// Hop-by-hop headers that must not be forwarded
const HOP_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);
/** Health shown in the admin page: outcome of the most recent proxied request. */
let health = { status: 'connecting', message: 'No traffic yet' };
function proxy(req, res, target, apiKey) {
const upstreamPath = (req.wildcard || '/') + (req.search || '');
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
}
headers.host = target.host;
delete headers['accept-encoding']; // keep SSE unbuffered and simple
if (apiKey) headers.authorization = 'Bearer ' + apiKey;
const preq = http.request(
{
hostname: target.hostname,
port: target.port || 80,
path: upstreamPath,
method: req.method,
headers,
},
(pres) => {
health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` };
const outHeaders = {};
for (const [k, v] of Object.entries(pres.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
}
res.writeHead(pres.statusCode, outHeaders);
// Flush immediately — SSE streams may send no bytes for a while, and
// the browser needs the headers to consider the stream open.
res.flushHeaders();
pres.pipe(res);
// If ProdCom dies mid-stream, pipe() unpipes on error without ending
// the browser's response, leaving SSE clients hanging on a dead
// stream. Destroy it so they notice immediately and reconnect.
pres.on('close', () => {
if (!res.writableEnded) res.destroy();
});
}
);
preq.on('error', (e) => {
health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) };
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
});
// Tear down the upstream connection when the browser disconnects
res.on('close', () => preq.destroy());
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 = {
init({ config }) {
health = resolveTarget(config)
? { status: 'connecting', message: 'No traffic yet' }
: { status: 'error', message: 'No ProdCom server configured' };
return {
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
health: () => health,
};
},
routes({ config, log }) {
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
log(`proxying /prodcom/* → ${target.origin}`);
return {
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
};
},
};