The Electron app that runs on the backstage PC: stage lineup photos with mic/pack labels, packaged for Windows. It also carries the ProPresenter Now/Next feature in two forms that share one engine — propresenter-core, where every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone display it serves. Until now this existed only on the production machine and as a read-only snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build output, and credentials, photos and processed images are written to the Electron userData directory, never here. tmp/pp-mock.js stays — it is the ProPresenter mock ProdDash's own CLAUDE.md tells people to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
791 lines
30 KiB
JavaScript
791 lines
30 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* The ProPresenter Now / Next display server.
|
|
*
|
|
* Runs in two modes:
|
|
*
|
|
* STANDALONE — `node server.js`. Owns its own ProPresenter connection, stored in
|
|
* config.json, configurable from any browser that opens the page.
|
|
*
|
|
* EMBEDDED — the Band Lineup app passes in the client it is already polling with
|
|
* (`createDisplayServer({ client })`). One poll loop feeds Band Lineup's own column and
|
|
* every screen in the building, the band app's UI is the only place the connection is
|
|
* configured, and this server touches no config file at all. In this mode it must never
|
|
* stop or reconfigure the client it was handed — doing so would kill the backstage display
|
|
* mid-service.
|
|
*
|
|
* Either way, browsers get the live view-model over Server-Sent Events, so one install
|
|
* can drive every screen: studios, hallway TVs, green-room tablets.
|
|
*
|
|
* Zero npm dependencies: Node built-ins only (needs Node 18+ for global fetch).
|
|
*/
|
|
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const { createProPresenterClient, clearedViewModel, DEFAULT_PP_PORT } = require('../propresenter-core');
|
|
const {
|
|
resolveDataDir,
|
|
configPath,
|
|
loadConfig,
|
|
saveConfig,
|
|
viewerAddresses,
|
|
defaultRouteAddress,
|
|
} = require('./config');
|
|
const { createMdnsResponder } = require('./mdns');
|
|
|
|
const DEFAULT_PORT = 7654;
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
/** SSE comment ping cadence — keeps idle connections alive through proxies and sleepy Wi-Fi. */
|
|
const HEARTBEAT_MS = 15000;
|
|
/** Cap on remembered viewer-facing local addresses, so a hostile client can't grow it forever. */
|
|
const MAX_OBSERVED = 16;
|
|
/**
|
|
* Per-viewer write buffer we tolerate before dropping them. A tablet that locks or a PC that
|
|
* enters modern standby keeps the TCP connection open but stops reading, and res.write()
|
|
* returns false rather than throwing — so without this every frame queues in the main
|
|
* process forever (measured: ~78 MB RSS after two hours of polling for one dead viewer).
|
|
* A healthy screen is brought straight back by the page's own reconnect logic.
|
|
*/
|
|
const MAX_VIEWER_BACKLOG = 256 * 1024;
|
|
/**
|
|
* Caps on live viewers. A building has a dozen screens at most; without a limit anyone who
|
|
* can open sockets can pile up connections inside the Electron main process (300 were
|
|
* accepted before this). Excess connections are REFUSED — an established screen is never
|
|
* dropped to make room for a new one, because the established one is the one someone is
|
|
* watching.
|
|
*/
|
|
const MAX_STREAMS = 32;
|
|
const MAX_STREAMS_PER_IP = 6;
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.ico': 'image/x-icon',
|
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
'.woff2': 'font/woff2',
|
|
};
|
|
|
|
function sendJson(res, status, body) {
|
|
const payload = JSON.stringify(body);
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(payload);
|
|
}
|
|
|
|
function readBody(req, limitBytes = 64 * 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build the display server.
|
|
* @param {object} [options]
|
|
* @param {number} [options.port] TCP port to listen on.
|
|
* @param {object} [options.client] An existing ProPresenter client to mirror (EMBEDDED mode).
|
|
* @param {string} [options.hostKind] Machine-readable host id, e.g. 'band-lineup'.
|
|
* @param {string} [options.hostLabel] Human phrase for the host, shown to viewers.
|
|
* @param {string} [options.mdnsName] Bare label to publish over mDNS; '' disables it.
|
|
* @param {string} [options.dataDir] Where config.json is read/written (standalone only).
|
|
* @param {boolean} [options.readOnly] Refuse settings changes over HTTP.
|
|
*/
|
|
function createDisplayServer(options = {}) {
|
|
const port = Number.parseInt(String(options.port ?? process.env.PORT ?? DEFAULT_PORT), 10) || DEFAULT_PORT;
|
|
/** Presence of an injected client IS the mode switch. */
|
|
const embedded = Boolean(options.client);
|
|
const hostKind = String(options.hostKind || (embedded ? 'embedded' : 'standalone'));
|
|
const hostLabel = String(options.hostLabel || (embedded ? 'the computer hosting this display' : 'the display server'));
|
|
const mdnsName = options.mdnsName === undefined ? 'nownext' : String(options.mdnsName || '');
|
|
// Embedded callers pass the host app's version; standalone reads its own package.json.
|
|
const appVersion = String(options.appVersion || (() => {
|
|
try {
|
|
return require('./package.json').version || '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
})());
|
|
|
|
// Standalone owns a config file; embedded owns nothing on disk (the band app persists the
|
|
// connection itself, so writing a second copy here would only invite the two to disagree).
|
|
const dataDir = embedded ? null : resolveDataDir(options.dataDir);
|
|
let config = embedded ? null : loadConfig(dataDir);
|
|
|
|
const readOnly = embedded ? true : (options.readOnly ?? (process.env.PP_DISPLAY_READONLY === '1'));
|
|
const readOnlyReason = embedded ? 'managed-by-host' : (readOnly ? 'locked' : '');
|
|
|
|
/** Latest view-model, served to new viewers immediately so screens never start blank. */
|
|
let latest = clearedViewModel();
|
|
/** Serialized copy of the last frame actually broadcast, for change detection. */
|
|
let lastStateFrame = '';
|
|
/** @type {Set<import('http').ServerResponse>} */
|
|
const streams = new Set();
|
|
/** Local addresses that real (non-loopback) viewers actually reached us on. */
|
|
const observed = new Set();
|
|
/**
|
|
* Whether any screen has connected since this server started. Latched HERE, where the
|
|
* fact is known, not sampled by a UI poll — that poll is suppressed for the whole of
|
|
* show mode, so a service where three studios watched for 90 minutes would otherwise
|
|
* look like one where nothing ever connected.
|
|
*/
|
|
let everHadViewer = false;
|
|
let peakViewers = 0;
|
|
let defaultRoute = '';
|
|
let heartbeat = null;
|
|
let listening = false;
|
|
let lastServerError = '';
|
|
let mdns = null;
|
|
|
|
const client = embedded
|
|
? options.client
|
|
: createProPresenterClient({
|
|
onData: (view) => {
|
|
publish(view);
|
|
},
|
|
});
|
|
|
|
if (!embedded) client.configure(config);
|
|
|
|
function broadcast(event, payload) {
|
|
if (!streams.size) return;
|
|
let frame;
|
|
try {
|
|
// Stringify OUTSIDE the per-client loop, and inside the guard: a serialization
|
|
// failure must not escape into the caller (in embedded mode the caller is the poll
|
|
// loop, and a throw there would end polling).
|
|
frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
} catch (err) {
|
|
lastServerError = err instanceof Error ? err.message : String(err);
|
|
return;
|
|
}
|
|
for (const res of streams) {
|
|
try {
|
|
const backlog = res.writableLength || res.socket?.writableLength || 0;
|
|
if (backlog > MAX_VIEWER_BACKLOG) {
|
|
streams.delete(res);
|
|
res.destroy();
|
|
continue;
|
|
}
|
|
res.write(frame);
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Push a fresh view-model to every viewer. In embedded mode this is the ONLY writer of
|
|
* `latest` — the band app calls it from the poll loop's onData. Never throws.
|
|
*/
|
|
function publish(view) {
|
|
try {
|
|
latest = view;
|
|
// The poll loop emits every tick whether anything changed or not (~100 frames a
|
|
// minute). Sending only real changes saves each screen a steady few KB/s and keeps
|
|
// idle viewers genuinely idle. New viewers still get `latest` on connect.
|
|
let frame;
|
|
try {
|
|
frame = JSON.stringify(view);
|
|
} catch (err) {
|
|
lastServerError = err instanceof Error ? err.message : String(err);
|
|
return;
|
|
}
|
|
if (frame === lastStateFrame) return;
|
|
lastStateFrame = frame;
|
|
broadcast('state', view);
|
|
} catch (err) {
|
|
lastServerError = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}
|
|
|
|
/** Push a fresh connection status to every viewer. Call after anything changes it. */
|
|
function publishStatus() {
|
|
try {
|
|
broadcast('status', publicStatus());
|
|
} catch (err) {
|
|
lastServerError = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}
|
|
|
|
/** Ranked list of ways to reach this display, best first. */
|
|
function addressList() {
|
|
const out = [];
|
|
if (mdns && mdns.isPublishing()) {
|
|
out.push({
|
|
url: `http://${mdns.name}:${port}`,
|
|
kind: 'name',
|
|
confirmed: false,
|
|
detail: 'Best bookmark — survives an IP change.',
|
|
});
|
|
}
|
|
for (const entry of viewerAddresses({ defaultRoute, observed: [...observed] })) {
|
|
out.push({
|
|
url: `http://${entry.address}:${port}`,
|
|
kind: 'ip',
|
|
confirmed: entry.confirmed,
|
|
detail: entry.confirmed
|
|
? 'Confirmed working.'
|
|
: `Network adapter: ${entry.iface}`,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function publicStatus() {
|
|
const info = client.getConfig();
|
|
const addresses = addressList();
|
|
return {
|
|
host: info.host,
|
|
port: info.port || '',
|
|
enabled: info.enabled,
|
|
hasPassword: info.hasPassword,
|
|
reachable: latest.reachable,
|
|
readOnly,
|
|
readOnlyReason,
|
|
hostKind,
|
|
hostLabel,
|
|
hostVersion: appVersion,
|
|
// What to tell a viewer when ProPresenter is not connected. On a locked studio screen
|
|
// "press the gear and type an IP" is advice nobody there can act on.
|
|
setupHint: readOnly
|
|
? `Nobody needs to do anything here — the connection is set up on ${hostLabel}.`
|
|
: 'Press the gear, then enter the IP address and port of the ProPresenter computer.',
|
|
defaultProPresenterPort: DEFAULT_PP_PORT,
|
|
// Deliberately not published to the network: the absolute config path and the host's
|
|
// adapter names. The host app reads those in-process; a viewer has no use for them.
|
|
configPath: null,
|
|
viewers: streams.size,
|
|
// Screens, not sockets. One machine can hold more than one connection — a reloaded
|
|
// tab whose old stream has not been reaped yet, or two tabs of the same page — and
|
|
// reporting sockets makes the app claim more screens than exist in the building.
|
|
viewerDevices: new Set([...streams].map((res) => res.__viewerIp).filter(Boolean)).size,
|
|
everHadViewer,
|
|
peakViewers,
|
|
displayPort: port,
|
|
mdnsName: mdns && mdns.isPublishing() ? mdns.name : '',
|
|
mdnsConflict: mdns ? mdns.getConflictHost() : '',
|
|
addresses,
|
|
// Kept for older callers/scripts that read a flat list.
|
|
viewerUrls: addresses.filter((entry) => entry.kind === 'ip').map((entry) => entry.url),
|
|
};
|
|
}
|
|
|
|
function startPolling() {
|
|
if (embedded) throw new Error('startPolling() is not available in embedded mode.');
|
|
client.configure(config);
|
|
if (!client.start()) {
|
|
publish(clearedViewModel());
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (embedded) throw new Error('stopPolling() is not available in embedded mode.');
|
|
client.stop();
|
|
publish(clearedViewModel());
|
|
}
|
|
|
|
function serveStatic(req, res, urlPath) {
|
|
let rel;
|
|
try {
|
|
rel = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath).replace(/^\/+/, '');
|
|
} catch {
|
|
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Bad request');
|
|
return;
|
|
}
|
|
// Resolve inside PUBLIC_DIR only — never let a crafted path escape the web root.
|
|
const target = path.resolve(PUBLIC_DIR, rel);
|
|
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + path.sep)) {
|
|
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Forbidden');
|
|
return;
|
|
}
|
|
fs.readFile(target, (err, data) => {
|
|
if (err) {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not found');
|
|
return;
|
|
}
|
|
res.writeHead(200, {
|
|
'Content-Type': MIME[path.extname(target).toLowerCase()] || 'application/octet-stream',
|
|
// no-store, not no-cache: a wall screen must never run a stale page after an update.
|
|
// Paired with the page's own version-triggered reload, a new build reaches every
|
|
// screen within a poll or two without anyone touching them.
|
|
'Cache-Control': 'no-store, must-revalidate',
|
|
});
|
|
res.end(data);
|
|
});
|
|
}
|
|
|
|
/** True if an address belongs to this machine. */
|
|
function isOwnAddress(address) {
|
|
if (!address) return false;
|
|
for (const entries of Object.values(os.networkInterfaces())) {
|
|
for (const entry of entries || []) {
|
|
if (entry.address === address) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Remember the local address a real viewer reached us on — that beats every ranking
|
|
* heuristic, because it is evidence rather than inference.
|
|
*
|
|
* Requests from this machine to its own LAN address do NOT count: they prove the socket
|
|
* is bound, not that anything else on the network can get through the firewall to it.
|
|
* Labelling those "confirmed working" would be exactly the false reassurance this is
|
|
* meant to replace.
|
|
*/
|
|
function noteViewerRoute(req) {
|
|
try {
|
|
const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
|
if (!remote || remote.startsWith('127.') || remote === '::1') return;
|
|
if (isOwnAddress(remote)) return;
|
|
const local = String(req.socket?.localAddress || '').replace(/^::ffff:/, '');
|
|
if (!local || local.startsWith('127.') || local === '::1') return;
|
|
if (!observed.has(local) && observed.size < MAX_OBSERVED) observed.add(local);
|
|
} catch {
|
|
/* socket already gone */
|
|
}
|
|
}
|
|
|
|
/** Hosts we will accept a state-changing request for (blocks DNS-rebinding writes). */
|
|
function hostAllowedForWrite(hostHeader) {
|
|
const raw = String(hostHeader || '').trim().toLowerCase();
|
|
if (!raw) return false;
|
|
// Strip the port; reject anything that isn't a plain host[:port].
|
|
const host = raw.replace(/:\d+$/, '');
|
|
if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1') return true;
|
|
if (mdns && mdns.name && host === mdns.name.toLowerCase()) return true;
|
|
return viewerAddresses({ defaultRoute, observed: [...observed] }).some((entry) => entry.address === host);
|
|
}
|
|
|
|
/**
|
|
* Guard the two state-changing routes. Reads stay wide open — an unattended wall display
|
|
* must never be gated — but a write has to look like it came from this app's own page.
|
|
*
|
|
* Without this, a plain cross-site form works with no JavaScript and no preflight: a
|
|
* `<form enctype="text/plain">` can post a body that JSON.parse accepts, which is enough
|
|
* to retarget or disconnect a standalone display from any page a volunteer happens to open.
|
|
* Requiring application/json forces a CORS preflight that a hostile origin cannot pass.
|
|
*/
|
|
function refuseUnsafeWrite(req, res) {
|
|
const contentType = String(req.headers['content-type'] || '').toLowerCase();
|
|
if (!contentType.startsWith('application/json')) {
|
|
sendJson(res, 415, { error: 'Send application/json.' });
|
|
return true;
|
|
}
|
|
const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
|
|
if (site && site !== 'same-origin' && site !== 'none') {
|
|
sendJson(res, 403, { error: 'Cross-site requests are not allowed.' });
|
|
return true;
|
|
}
|
|
const origin = req.headers.origin;
|
|
if (origin) {
|
|
let originHost = '';
|
|
try {
|
|
originHost = new URL(origin).host;
|
|
} catch {
|
|
originHost = 'invalid';
|
|
}
|
|
if (!hostAllowedForWrite(originHost)) {
|
|
sendJson(res, 403, { error: 'Cross-site requests are not allowed.' });
|
|
return true;
|
|
}
|
|
}
|
|
if (!hostAllowedForWrite(req.headers.host)) {
|
|
sendJson(res, 403, { error: 'Unrecognised host name for a settings change.' });
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function handleApi(req, res, urlPath) {
|
|
if (urlPath === '/api/stream' && req.method === 'GET') {
|
|
const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
|
if (streams.size >= MAX_STREAMS) {
|
|
res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '30' });
|
|
res.end(JSON.stringify({ error: `This display is already serving ${streams.size} screens.` }));
|
|
return true;
|
|
}
|
|
let sameIp = 0;
|
|
for (const existing of streams) {
|
|
if (existing.__viewerIp === remote) sameIp += 1;
|
|
}
|
|
if (sameIp >= MAX_STREAMS_PER_IP) {
|
|
res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '30' });
|
|
res.end(JSON.stringify({ error: 'Too many connections from this device.' }));
|
|
return true;
|
|
}
|
|
res.__viewerIp = remote;
|
|
// A browser that vanishes without closing the TCP connection (tab discarded, laptop
|
|
// lid shut, Wi-Fi dropped) leaves a socket the kernel will happily hold for many
|
|
// minutes, which inflates the count the operator reads. Keepalive probes make the
|
|
// OS notice and tear it down.
|
|
try {
|
|
req.socket.setKeepAlive(true, 15000);
|
|
} catch {
|
|
/* socket already gone */
|
|
}
|
|
// Build both payloads BEFORE writing headers: a throw after writeHead becomes an
|
|
// ERR_HTTP_HEADERS_SENT unhandled rejection, which in Electron takes the app down.
|
|
const stateFrame = `event: state\ndata: ${JSON.stringify(latest)}\n\n`;
|
|
const statusFrame = `event: status\ndata: ${JSON.stringify(publicStatus())}\n\n`;
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
res.write('retry: 3000\n\n');
|
|
res.write(stateFrame);
|
|
res.write(statusFrame);
|
|
streams.add(res);
|
|
everHadViewer = true;
|
|
peakViewers = Math.max(peakViewers, streams.size);
|
|
req.on('close', () => {
|
|
streams.delete(res);
|
|
// The viewer count changed; tell the remaining screens (and the host UI).
|
|
publishStatus();
|
|
});
|
|
publishStatus();
|
|
return true;
|
|
}
|
|
|
|
if (urlPath === '/api/state' && req.method === 'GET') {
|
|
sendJson(res, 200, { state: latest, status: publicStatus() });
|
|
return true;
|
|
}
|
|
|
|
if (urlPath === '/api/status' && req.method === 'GET') {
|
|
sendJson(res, 200, publicStatus());
|
|
return true;
|
|
}
|
|
|
|
if (urlPath === '/api/playlists' && req.method === 'GET') {
|
|
// The one route that had no guard. Nothing over HTTP needs it: the display page never
|
|
// calls it, and the host app reads playlists in-process over IPC. Left open it hands
|
|
// any unauthenticated client the entire ProPresenter library — not just today's order,
|
|
// but every playlist name, including anything held back or staff-only.
|
|
if (embedded || readOnly) {
|
|
sendJson(res, 409, {
|
|
error: `Playlists are managed on ${hostLabel}.`,
|
|
readOnlyReason,
|
|
});
|
|
return true;
|
|
}
|
|
try {
|
|
sendJson(res, 200, { playlists: await client.listPlaylists() });
|
|
} catch (error) {
|
|
sendJson(res, 502, { error: error instanceof Error ? error.message : 'Could not list playlists.' });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (urlPath === '/api/connect' && req.method === 'POST') {
|
|
if (embedded) {
|
|
// Hard refuse. Half-completing this would retarget — or silently stop — the poll
|
|
// loop the backstage display itself depends on.
|
|
sendJson(res, 409, {
|
|
error: `This display follows ${hostLabel}. Change the ProPresenter connection there.`,
|
|
readOnlyReason,
|
|
});
|
|
return true;
|
|
}
|
|
if (readOnly) {
|
|
sendJson(res, 403, { error: 'This display is locked. Change the connection on the host machine.' });
|
|
return true;
|
|
}
|
|
if (refuseUnsafeWrite(req, res)) return true;
|
|
let payload = {};
|
|
try {
|
|
payload = JSON.parse((await readBody(req)) || '{}');
|
|
} catch {
|
|
sendJson(res, 400, { error: 'Malformed request.' });
|
|
return true;
|
|
}
|
|
const host = String(payload.host || '').trim();
|
|
const portNum = Number.parseInt(String(payload.port ?? ''), 10);
|
|
const ppPort = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0;
|
|
// An omitted password field keeps the stored one; an empty string clears it.
|
|
const password = payload.password === undefined ? config.password : String(payload.password);
|
|
if (!host || !ppPort) {
|
|
sendJson(res, 400, { error: 'Enter the ProPresenter host (IP) and port.' });
|
|
return true;
|
|
}
|
|
|
|
// Test on a throwaway client: pointing the live one at an unverified address would
|
|
// make every screen in the building flash "Reconnecting…" while an operator types.
|
|
const probe = createProPresenterClient();
|
|
probe.configure({ enabled: true, host, port: ppPort, password });
|
|
try {
|
|
await probe.testConnection();
|
|
} catch (error) {
|
|
sendJson(res, 502, { error: error instanceof Error ? error.message : 'Could not reach ProPresenter.' });
|
|
return true;
|
|
}
|
|
|
|
config = saveConfig(dataDir, { enabled: true, host, port: ppPort, password });
|
|
client.reset();
|
|
client.stop();
|
|
startPolling();
|
|
publishStatus();
|
|
sendJson(res, 200, { ok: true, status: publicStatus() });
|
|
return true;
|
|
}
|
|
|
|
if (urlPath === '/api/disconnect' && req.method === 'POST') {
|
|
if (embedded) {
|
|
sendJson(res, 409, {
|
|
error: `This display follows ${hostLabel}. Disconnect there instead.`,
|
|
readOnlyReason,
|
|
});
|
|
return true;
|
|
}
|
|
if (readOnly) {
|
|
sendJson(res, 403, { error: 'This display is locked. Change the connection on the host machine.' });
|
|
return true;
|
|
}
|
|
if (refuseUnsafeWrite(req, res)) return true;
|
|
stopPolling();
|
|
client.reset();
|
|
// Keep host/port on disk for an easy reconnect; drop the password.
|
|
config = saveConfig(dataDir, { enabled: false, host: config.host, port: config.port, password: '' });
|
|
client.configure({ ...config, password: '' });
|
|
publishStatus();
|
|
sendJson(res, 200, { ok: true, status: publicStatus() });
|
|
return true;
|
|
}
|
|
|
|
if (urlPath === '/api/health' && req.method === 'GET') {
|
|
sendJson(res, 200, { ok: true, viewers: streams.size, reachable: latest.reachable });
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
// Everything in here runs inside the Electron main process when embedded, so a single
|
|
// unhandled throw would take the whole band app down with it.
|
|
try {
|
|
noteViewerRoute(req);
|
|
|
|
let urlPath;
|
|
try {
|
|
// A malformed Host header makes the URL constructor throw. Don't trust it.
|
|
urlPath = new URL(req.url, 'http://display.invalid').pathname;
|
|
} catch {
|
|
sendJson(res, 400, { error: 'Bad request.' });
|
|
return;
|
|
}
|
|
|
|
if (urlPath.startsWith('/api/')) {
|
|
handleApi(req, res, urlPath)
|
|
.then((handled) => {
|
|
if (!handled) sendJson(res, 404, { error: 'Unknown endpoint.' });
|
|
})
|
|
.catch((error) => {
|
|
lastServerError = error instanceof Error ? error.message : String(error);
|
|
if (!res.headersSent) {
|
|
sendJson(res, 500, { error: 'Server error.' });
|
|
} else {
|
|
try { res.end(); } catch { /* already gone */ }
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
res.writeHead(405, { Allow: 'GET, HEAD' }).end('Method not allowed');
|
|
return;
|
|
}
|
|
serveStatic(req, res, urlPath);
|
|
} catch (error) {
|
|
lastServerError = error instanceof Error ? error.message : String(error);
|
|
try {
|
|
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
|
else res.end();
|
|
} catch { /* already gone */ }
|
|
}
|
|
});
|
|
|
|
// Post-listen errors (a dropped interface, an ECONNRESET storm) must be handled or Node
|
|
// treats them as uncaught. This has to be a persistent handler, not the one-shot reject
|
|
// used by listen(), which is already settled by then.
|
|
server.on('error', (error) => {
|
|
lastServerError = error instanceof Error ? error.message : String(error);
|
|
});
|
|
server.on('clientError', (error, socket) => {
|
|
lastServerError = error instanceof Error ? error.message : String(error);
|
|
try { socket.destroy(); } catch { /* already gone */ }
|
|
});
|
|
|
|
async function listen() {
|
|
defaultRoute = await defaultRouteAddress();
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const onError = (error) => {
|
|
server.removeListener('listening', onListening);
|
|
reject(error);
|
|
};
|
|
const onListening = () => {
|
|
server.removeListener('error', onError);
|
|
resolve();
|
|
};
|
|
server.once('error', onError);
|
|
server.once('listening', onListening);
|
|
// 0.0.0.0 on purpose: the whole point is other screens in the building can open this.
|
|
// NOTE on Windows: this bind is what triggers the Defender Firewall prompt — not the
|
|
// first remote connection. localhost keeps working either way, so the host machine
|
|
// can look perfectly healthy while every studio times out.
|
|
server.listen(port, '0.0.0.0');
|
|
});
|
|
|
|
listening = true;
|
|
heartbeat = setInterval(() => {
|
|
for (const res of streams) {
|
|
try {
|
|
if ((res.writableLength || res.socket?.writableLength || 0) > MAX_VIEWER_BACKLOG) {
|
|
streams.delete(res);
|
|
res.destroy();
|
|
continue;
|
|
}
|
|
res.write(': ping\n\n');
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}, HEARTBEAT_MS);
|
|
heartbeat.unref?.();
|
|
|
|
if (mdnsName) {
|
|
mdns = createMdnsResponder({
|
|
name: mdnsName,
|
|
getAddresses: () => viewerAddresses({ defaultRoute, observed: [...observed] }).map((entry) => entry.address),
|
|
});
|
|
await mdns.start().catch(() => ({ ok: false }));
|
|
}
|
|
|
|
if (embedded) {
|
|
// Seed from the client we were handed so the first viewer sees the live service
|
|
// immediately instead of a blank card.
|
|
latest = client.getViewModel();
|
|
} else if (config.enabled) {
|
|
startPolling();
|
|
}
|
|
|
|
return { port, addresses: addressList(), mdnsName: mdns && mdns.isPublishing() ? mdns.name : '' };
|
|
}
|
|
|
|
async function close() {
|
|
if (heartbeat) {
|
|
clearInterval(heartbeat);
|
|
heartbeat = null;
|
|
}
|
|
if (mdns) {
|
|
mdns.stop();
|
|
mdns = null;
|
|
}
|
|
// EMBEDDED: never touch the injected client. Stopping it here would silently kill the
|
|
// backstage Now/Next column every time hosting is switched off or the app quits.
|
|
if (!embedded) client.stop();
|
|
for (const res of streams) {
|
|
try { res.end(); } catch { /* already gone */ }
|
|
}
|
|
streams.clear();
|
|
if (!listening) return;
|
|
listening = false;
|
|
await new Promise((resolve) => {
|
|
// server.close() waits for every connection that isn't *idle* to finish, and a socket
|
|
// that connected without ever completing a request never becomes idle — which is
|
|
// exactly what browser speculative preconnect leaves behind (Safari on iPad, Chrome
|
|
// and Edge on the studio PCs all do it). close() would then never call back, wedging
|
|
// the caller: a port change would stop the old server and never start the new one.
|
|
// Node also cancels its own header/request timeouts during close, so nothing else
|
|
// reaps those sockets. Tear them down ourselves, with a hard bound as a backstop.
|
|
const bail = setTimeout(resolve, 2000);
|
|
bail.unref?.();
|
|
server.close(() => {
|
|
clearTimeout(bail);
|
|
resolve();
|
|
});
|
|
// Safe here: every SSE response was already ended above, so viewers see a clean
|
|
// stream end and reconnect on their own.
|
|
server.closeAllConnections?.();
|
|
});
|
|
}
|
|
|
|
return {
|
|
server,
|
|
listen,
|
|
close,
|
|
port,
|
|
dataDir,
|
|
readOnly,
|
|
embedded,
|
|
status: publicStatus,
|
|
addresses: addressList,
|
|
publish,
|
|
publishStatus,
|
|
viewers: () => streams.size,
|
|
isListening: () => listening,
|
|
getLastError: () => lastServerError,
|
|
client,
|
|
};
|
|
}
|
|
|
|
module.exports = { createDisplayServer, DEFAULT_PORT };
|
|
|
|
// Run directly: node server.js
|
|
if (require.main === module) {
|
|
const display = createDisplayServer();
|
|
display
|
|
.listen()
|
|
.then(({ port, addresses, mdnsName }) => {
|
|
const status = display.status();
|
|
console.log(`Stage Now / Next display on http://localhost:${port}`);
|
|
if (mdnsName) console.log(` bookmark this: http://${mdnsName}:${port}`);
|
|
for (const entry of addresses) console.log(` ${entry.url}${entry.confirmed ? ' (confirmed working)' : ''}`);
|
|
console.log(` config: ${display.dataDir ? configPath(display.dataDir) : '(none)'}`);
|
|
console.log(
|
|
status.enabled
|
|
? ` following ProPresenter at ${status.host}:${status.port}`
|
|
: ' no ProPresenter connection saved yet — open the page and press the gear to set one up',
|
|
);
|
|
if (status.readOnly) console.log(' settings are LOCKED (PP_DISPLAY_READONLY=1)');
|
|
})
|
|
.catch((error) => {
|
|
console.error(`Could not start the display server: ${error.message}`);
|
|
process.exit(1);
|
|
});
|
|
|
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
process.on(signal, () => {
|
|
display.close().then(() => process.exit(0));
|
|
});
|
|
}
|
|
}
|