screentinker/brightsign/server/bs-server-boot.js
ScreenTinker b25bfaea57 Stop the player rendering black when the server hosts the display
A BrightSign hosting ScreenTinker shows a local page from file:///ssd:/node-server.html
that layers the player in an iframe — an iframe rather than a navigation, because
navigating replaces the document and kills the poller that notices the server dying.

helmet sets X-Frame-Options: SAMEORIGIN, and file:// is not the same origin as
http://127.0.0.1:8181, so the frame rendered BLACK. Every asset inside it returned 200
— the player page and all six of its scripts — and nothing appeared in any log. Only
the response headers said why, which is a miserable thing to debug on a device with no
console.

⚠️ AND IT IS NOT ONLY /player. Chrome evaluates SAMEORIGIN against the TOP-LEVEL
document rather than the immediate parent, so with a file:// page at the top, every
iframe the player itself uses — widget renders, kiosk views — is blocked by the same
rule one level deeper. Scoping this to /player would have cleared the black screen and
left every widget in the playlist black instead: the same bug, found later, on a
customer's wall. The test covers that case explicitly.

Scoped by CONTEXT, not by path: only when the process was started as a player host
(bs-server-boot.js sets ST_PLAYER_HOST; nothing else does) AND the request arrived on
loopback, i.e. from the box's own browser. An ordinary server keeps SAMEORIGIN, and so
does any request off the network — which is where clickjacking would have to come from,
since a remote page cannot reach another machine's 127.0.0.1. Where a CSP is set, only
its frame-ancestors directive is rewritten; the rest of the policy survives.

Verified on XT245 URD3C6000823: the player renders and shows its pairing code, while a
request to the same URL from the LAN still returns X-Frame-Options: SAMEORIGIN.
Full suite 1766 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 21:36:56 -05:00

573 lines
26 KiB
JavaScript

'use strict';
/*
* The Node half of "ScreenTinker server, running on the player it serves".
*
* BrightScript launches this with roNodeJs: a REAL Node process, not an roHtmlWidget. That
* distinction is the whole reason this file got simpler. Inside a widget the server is a Node
* context inside an Electron renderer, and four separate things break — shebangs are not stripped,
* require(ESM) is unsupported, setInterval is the DOM's and returns a number, and worker_threads
* cannot create a thread at all. BrightSign's own notes say to use roNodeJs "for long running
* processes like gathering metrics or running a web server", and roHtmlWidget "for browser-based
* apps". The server is the former. Their cra-template examples do exactly this: server in
* roNodeJs, widget pointed at localhost.
*
* It also fixes the durability problem: a widget's server shares the PAGE's lifecycle, so a load
* error or a deploy tears down the server and its open SQLite WAL with it. roNodeJs "will run in
* the background uninterrupted".
*
* This file is the only thing between that arrangement and the ordinary server:
*
* 1. capture console output into a ring buffer, so the screen can show a log tail on a box with
* no monitor attached to its serial port,
* 2. post a status frame — IP, disk, memory, uptime, recent log — to BrightScript on a timer,
* 3. start the real server unmodified.
*
* ⚠️ It must never be the reason the server fails to boot. Everything here is wrapped: a broken
* status frame is worth less than a running server, and on a device with no console the difference
* between "crashed" and "started but silent" is invisible.
*/
const os = require('os');
const net = require('net');
const http = require('http');
/*
* ⚠️ GIVE THE PAGE NODE'S TIMERS.
*
* This runs inside an roHtmlWidget, which is a BROWSER as well as a Node context, and the browser
* wins for globals. The DOM's setInterval returns a NUMBER; Node's returns a Timeout object with
* .unref(). So ordinary server code that has always worked dies here:
*
* TypeError: setInterval(...).unref is not a function
* at server/routes/widgets.js:359
*
* Swapping the globals for node:timers fixes every call site at once - the two unguarded ones and
* the sixteen written as `if (t.unref) t.unref()`, which on this platform were silently NOT
* unreffing. Done here rather than in the server so the product keeps one timer idiom, and because
* this is a property of the host, not of the code.
*
* The same trap as the shebang and as require(ESM): plain Node is not what this runs on, and a
* local test under plain Node cannot see any of it.
*/
const nodeTimers = require('timers');
for (const name of ['setTimeout', 'setInterval', 'setImmediate',
'clearTimeout', 'clearInterval', 'clearImmediate']) {
if (typeof nodeTimers[name] === 'function') globalThis[name] = nodeTimers[name];
}
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------------------------
// Talking to BrightScript
//
// roNodeJs delivers whatever we write to stdout as a message when it is valid JSON on one line;
// the BrightScript side reads it off the message port. Newline-delimited JSON keeps the framing
// trivial on a side that has no JSON streaming parser.
// ---------------------------------------------------------------------------------------------
// Kept for the case where this is run directly with `node bs-server-boot.js` (how it is tested on
// a desktop). Inside the widget nothing reads stdout — the PAGE calls status() and renders it, so
// this is a debugging aid, not the channel.
function post(obj) {
try { if (process.stdout && process.stdout.write) process.stdout.write(JSON.stringify(obj) + '\n'); }
catch (e) { /* never fatal */ }
}
// What the screen shows. Set when the server cannot start, so the page can say so instead of
// displaying a frozen "starting..." forever.
let fatalMessage = null;
// ---------------------------------------------------------------------------------------------
// The log ring
//
// Bounded on purpose: this runs for months. An unbounded array fed by a chatty server is a slow
// leak on the one device nobody is watching, which is the same reasoning the bridge's pending
// queue uses.
// ---------------------------------------------------------------------------------------------
const LOG_MAX = 200;
const logRing = [];
function remember(level, args) {
try {
const line = args.map((a) => (typeof a === 'string' ? a : require('util').inspect(a, { depth: 1 }))).join(' ');
for (const part of line.split('\n')) {
if (!part.trim()) continue;
logRing.push({ t: Date.now(), level, m: part.slice(0, 300) });
if (logRing.length > LOG_MAX) logRing.shift();
}
} catch (e) { /* logging must not throw */ }
}
for (const level of ['log', 'info', 'warn', 'error']) {
const original = console[level].bind(console);
console[level] = (...args) => { remember(level, args); original(...args); };
}
// ---------------------------------------------------------------------------------------------
// What the screen shows
// ---------------------------------------------------------------------------------------------
function firstIPv4() {
try {
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
if (/^(lo|docker|veth)/.test(name)) continue;
for (const a of addrs || []) {
// Node 18+ reports family as the string 'IPv4'; older builds used the number 4. The player
// is on 24, but this costs one comparison and removes a version dependency.
if ((a.family === 'IPv4' || a.family === 4) && !a.internal) return a.address;
}
}
} catch (e) { /* fall through */ }
return null;
}
/* Disk usage for the volume the server actually writes to, via statfs. */
function diskFor(dir) {
try {
// The data directory may not exist on the first frame — the server creates it during boot, and
// the whole point of that first frame is to show something before the server is up. statfs on
// any path on the same volume gives the same answer, so fall back to where we are installed.
const target = fs.existsSync(dir) ? dir : __dirname;
const s = fs.statfsSync(target);
const total = s.blocks * s.bsize;
const free = s.bavail * s.bsize;
return { totalMb: Math.round(total / 1048576), freeMb: Math.round(free / 1048576),
usedPct: total ? Math.round(((total - free) / total) * 100) : null };
} catch (e) { return null; }
}
function dbBytes(dir) {
let sum = 0;
try {
for (const f of fs.readdirSync(dir)) {
if (!/\.db(-wal|-shm)?$/.test(f)) continue;
try { sum += fs.statSync(path.join(dir, f)).size; } catch (e) { /* skip */ }
}
} catch (e) { return null; }
return Math.round(sum / 1048576);
}
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
/*
* ⚠️ EXPORT IT, do not merely compute it. This line is load-bearing twice over.
*
* server/config.js reads process.env.DATA_DIR and falls back to its own __dirname, so without this
* the server puts its database, uploads and .jwt_secret INSIDE server/ - inside the payload tree.
* That tree is deleted and replaced wholesale on the next payload update, so the first update would
* have silently destroyed the database, the uploaded content and the signing secret.
*
* It also made the diagnostic screen lie: it reported "database n/a" because it looked in
* DATA_DIR/db while the server was writing to server/db, which reads as "there is no database" when
* there is a perfectly good one a directory away.
*/
process.env.DATA_DIR = DATA_DIR;
/*
* Tell the server it is hosting the display it serves. The only thing this currently unlocks is
* letting the local diagnostic page frame /player over loopback — helmet's X-Frame-Options
* SAMEORIGIN otherwise renders that iframe black, because this page is served from file://.
*/
process.env.ST_PLAYER_HOST = '1';
/*
* Read at FRAME time, not at load time. server.env is applied below, after this module's constants
* would have been evaluated — so a PORT captured here would show the default on screen while the
* server was actually listening on the configured one. The screen exists to tell an operator where
* to point a browser; a plausible wrong number is worse than no number.
*/
const currentPort = () => process.env.PORT || 3001;
/*
* Local configuration, seeded once and then owned by the device.
*
* The packager REFUSES to bundle a .env — that guard exists because the first build of this package
* swept up the developer's real one along with a 33MB database and 105MB of uploads. So the package
* carries a template instead, and the first boot copies it into DATA_DIR, where it sits alongside
* the data: a package update replaces the code and leaves the operator's settings intact.
*
* Precedence is deliberate: anything already in process.env — which is how autorun.brs passes
* DATA_DIR and PORT through roNodeJs — wins. The file fills in what the launcher did not say.
*
* No secrets live here. The JWT signing secret is generated per install into
* DATA_DIR/certs/.jwt_secret by server/config.js, so every player gets its own; one shipped in a
* package would be identical on every device that installed it.
*/
function loadLocalEnv() {
const template = path.join(__dirname, 'server.env.example');
let source = path.join(DATA_DIR, 'server.env');
try {
if (!fs.existsSync(source)) {
if (!fs.existsSync(template)) return;
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
/*
* ⚠️ NOT fs.copyFileSync. On the player this failed with
* EPERM: operation not permitted, copyfile '...server.env.example' -> '.../server.env'
* copyFileSync does not merely copy bytes: it opens the destination and then fchmods it to
* match the source's mode. /storage/ssd is exFAT, which has no permission bits, so the
* chmod is refused. Writing the bytes ourselves never asks for a mode and works fine — which
* is also why unpacking the zip onto the same volume was never a problem.
*/
fs.writeFileSync(source, fs.readFileSync(template));
remember('log', [`created ${source} from the template — edit it on the device`]);
} catch (e) {
/*
* Persisting is a convenience; APPLYING the configuration is not. Read the template directly
* rather than giving up, or a read-only data directory silently downgrades the server to
* defaults — which is precisely what happened here: PORT=8080 never applied and the screen
* advertised :3001 while claiming to be running.
*/
remember('warn', ['could not persist server.env, using the packaged template',
String(e && e.message ? e.message : e)]);
source = template;
}
}
for (const line of fs.readFileSync(source, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 1) continue;
const k = t.slice(0, eq).trim();
// Already set by the launcher: leave it alone.
if (process.env[k] !== undefined) continue;
process.env[k] = t.slice(eq + 1).trim();
}
} catch (e) {
// A malformed config must not stop the server booting: it comes up on defaults and says so.
remember('error', ['could not read server.env', String(e && e.message ? e.message : e)]);
}
}
loadLocalEnv();
/*
* Turn off the native-dependency preflight.
*
* preflight-deps.js exists to catch better-sqlite3 compiled against the wrong NODE_MODULE_VERSION:
* it probes with a real `new Database(':memory:')`, runs `npm rebuild`, and hard-exits if the
* module still will not load. That is the correct behaviour on a normal install and it is exactly
* wrong here — this build has no better-sqlite3 at all, by design, because it reaches SQLite
* through node:sqlite. Left on, it finds the module missing, tries to rebuild a package that is not
* in package.json, fails, and refuses to boot the server.
*
* Set here rather than relying on BrightScript to pass it: the check runs on require of the server,
* and a package that boots only when the launcher remembers an environment variable is a package
* that will eventually not boot.
*/
process.env.ST_SKIP_DEP_PREFLIGHT = '1';
/*
* Declared HERE, above statusFrame, and not down beside the installer that maintains it.
*
* `let` is hoisted but not initialised, so a reference before this line throws ReferenceError
* rather than reading undefined. statusFrame() is called on the first tick — long before the
* install block further down — so declaring it next to its logic put the whole boot in a temporal
* dead zone: one frame, one ReferenceError, no server, and a blank screen to debug it with.
*/
let installState = { phase: 'idle', detail: '', pct: null };
let lastLoggedInstall = null;
/*
* Is anything actually LISTENING?
*
* The screen showed http://192.168.1.46:8080 in large green type while the server was still
* downloading its own code, and again while it was dead from a failed require. An address that
* does not answer is worse than no address: someone reads it off the screen, the browser hangs,
* and the player looks broken in a way that has nothing to do with the real fault.
*
* So prove it rather than infer it - a real TCP connect to the port, on the same interval as the
* status frame. Cheap, and it cannot be fooled by the server having got halfway up.
*/
/*
* Has anyone created the first account yet?
*
* The screen has three states, and this is the one the server has to be asked about: a server that
* is up but has no users is not ready to show a player, it is waiting for someone to open the
* dashboard and create an admin. /api/auth/config answers it and is public by design.
*
* Asked HERE rather than from the page because the page is loaded from file:// - origin "null" -
* and the server sets no CORS headers on its own API. This process is already talking to it.
*
* null means "not known yet", which is deliberately distinct from false: the page must not flip to
* the player on a probe that has not answered.
*/
let needsSetup = null;
function probeSetup() {
if (!serving) { needsSetup = null; return; }
const req = http.request(
{ host: '127.0.0.1', port: Number(currentPort()), path: '/api/auth/config', timeout: 3000 },
(res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
try { needsSetup = !!JSON.parse(body).needsSetup; }
catch (e) { /* a malformed answer is not an answer */ }
});
});
req.on('error', () => { /* server not answering yet; leave the previous value */ });
req.on('timeout', () => req.destroy());
req.end();
}
let serving = false;
function probeListening() {
const port = Number(currentPort());
if (!port) { serving = false; return; }
const sock = net.connect({ host: '127.0.0.1', port });
const done = (ok) => { serving = ok; sock.destroy(); };
sock.setTimeout(1500);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false));
sock.once('error', () => done(false));
}
function statusFrame() {
const mem = process.memoryUsage();
return {
type: 'st-server-status',
ip: firstIPv4(),
port: currentPort(),
pid: process.pid,
node: process.versions.node,
uptimeSec: Math.round(process.uptime()),
rssMb: Math.round(mem.rss / 1048576),
heapMb: Math.round(mem.heapUsed / 1048576),
freeMemMb: Math.round(os.freemem() / 1048576),
loadAvg: os.loadavg().map((n) => Math.round(n * 100) / 100),
disk: diskFor(DATA_DIR),
install: installState,
serving,
needsSetup,
dbMb: dbBytes(path.join(DATA_DIR, 'db')),
log: logRing.slice(-14),
};
}
// ---------------------------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------------------------
post({ type: 'st-server-boot', node: process.versions.node, arch: process.arch, dataDir: DATA_DIR });
// A frame straight away so the screen is never blank while the server warms up, then on a timer.
// 2s is a compromise: fast enough to watch a boot, slow enough that a 3-core player is not being
// asked to serialise state constantly while it is also serving.
const timer = setInterval(() => { probeListening(); probeSetup(); post(statusFrame()); }, 2000);
if (typeof timer.unref === 'function') timer.unref();
post(statusFrame());
process.on('uncaughtException', (e) => {
remember('error', ['UNCAUGHT', e && e.stack ? e.stack : String(e)]);
post(statusFrame());
fatalMessage = String(e && e.message ? e.message : e);
post({ type: 'st-server-fatal', message: fatalMessage });
// NOT process.exit() any more. Inside a widget that would take the page down with it, losing the
// one surface that can report what went wrong. The screen shows FAILED and the reason instead.
});
process.on('unhandledRejection', (e) => remember('error', ['UNHANDLED REJECTION', String(e)]));
/*
* Is the server payload here — and if not, go and get it.
*
* The boot files and the ~71MB of server + node_modules ship separately: BrightSignOS cannot open an
* autorun.zip that big (it renames it to autorun.zip_invalid and forces recovery), while a 32KB one
* boots fine. So this downloads and unpacks the rest itself, which Node has no trouble with, and
* means the payload can also be updated without re-provisioning the device.
*/
const SERVER_ENTRY = path.join(__dirname, 'server', 'server.js');
/*
* ---------------------------------------------------------------------------------------------
* Media tools (ffprobe/ffmpeg) — staged into /tmp, then put on PATH.
* ---------------------------------------------------------------------------------------------
*
* Without this the server logs, on every boot:
*
* [MEDIA] ffmpeg, ffprobe not found on PATH — video thumbnails and durations are DISABLED
*
* ⚠️ /tmp IS NOT LAZINESS, IT IS THE ONLY OPTION. Every writable volume on this player is mounted
* noexec — the exFAT SSD, the ext4 flash, even /storage/tmp — and we run as uid 994 (nodejs), so
* `mount -o remount,exec` answers "permission denied (are you root?)". A symlink does not help
* either: noexec is a property of the filesystem holding the inode, not of the path used to reach
* it, so a link in /tmp pointing at flash still fails with EACCES. Copying is what moves the inode
* onto a filesystem that permits execution. /tmp is tmpfs and is the one writable place without
* noexec, so the binaries are unpacked there at every boot. They are shipped gzipped because the
* unpacked pair is ~7MB of RAM and the compressed pair is ~3MB on disk.
*
* ⚠️ THE BINARIES MUST BE OURS. BrightSignOS ships the whole ffmpeg 5.1 shared-library stack
* (libavformat/libavcodec/...) to back GStreamer, and a stock Debian ffprobe linked against them
* starts, prints its banner — and then SIGSEGVs the instant it opens a file, because their Yocto
* build is patched for hardware decode. So these are cross-built here, --disable-gpl (LGPL 2.1+),
* fully static, linking nothing of theirs. ffprobe carries no decoders at all: durations and stream
* geometry come from the container, which is why it is 1.8MB against ffmpeg's 5MB.
*/
const MEDIA_BIN_DIR = '/tmp/screentinker-bin';
function stageMediaTools() {
const zlib = require('zlib');
const staged = [];
try {
fs.mkdirSync(MEDIA_BIN_DIR, { recursive: true });
for (const name of ['ffprobe', 'ffmpeg']) {
// Shipped in the payload for a real install; DATA_DIR is the fallback so a device can be
// given the tools without recutting the package.
const candidates = [path.join(__dirname, 'bin', name + '.gz'),
path.join(DATA_DIR, 'bin', name + '.gz')];
const src = candidates.find((c) => fs.existsSync(c));
if (!src) continue;
const bytes = zlib.gunzipSync(fs.readFileSync(src));
const dest = path.join(MEDIA_BIN_DIR, name);
fs.writeFileSync(dest, bytes);
fs.chmodSync(dest, 0o755);
staged.push(name + ' ' + Math.round(bytes.length / 1024) + 'KB');
}
if (!staged.length) {
console.log('[media] no bundled ffprobe/ffmpeg found — thumbnails and durations stay disabled');
return;
}
// The server probes for these BY NAME with execFile, so the directory has to be on PATH before
// server.js is required. That is the whole reason this runs where it does.
process.env.PATH = MEDIA_BIN_DIR + (process.env.PATH ? ':' + process.env.PATH : '');
console.log('[media] staged ' + staged.join(', ') + ' into ' + MEDIA_BIN_DIR + ' (on PATH)');
// Prove it actually RUNS, and put the banner on the diagnostic screen and the serial console.
// Asynchronous: a hung binary must not hold up the server, and this is only reporting.
require('child_process').execFile(path.join(MEDIA_BIN_DIR, 'ffprobe'), ['-hide_banner', '-version'],
{ timeout: 10000, encoding: 'utf8' }, (err, stdout) => {
if (err) {
console.warn('[media] staged ffprobe did not run: ' + (err && err.message));
return;
}
console.log('[media] ' + String(stdout).split('\n')[0]);
});
} catch (e) {
// Never fatal. A player with no thumbnails is worth more than a player that would not boot.
console.warn('[media] could not stage media tools: ' + (e && e.message ? e.message : e));
}
}
function startServer() {
try {
stageMediaTools();
require('./server/server.js');
} catch (e) {
remember('error', ['server failed to start', e && e.stack ? e.stack : String(e)]);
fatalMessage = String(e && e.message ? e.message : e);
post(statusFrame());
post({ type: 'st-server-fatal', message: fatalMessage });
}
}
if (fs.existsSync(SERVER_ENTRY)) {
installState = { phase: 'installed', detail: 'already present', pct: 100 };
startServer();
} else {
/*
* Where the payload comes from. Configurable because a self-hosted install will not be fetching
* from ours — this is the one address the device cannot discover for itself, since the page is
* loaded from a file:// URL and the launcher has no environment to pass it through.
*/
const payloadUrl = process.env.ST_PAYLOAD_URL
|| 'https://alpha.screentinker.com/scripts/server-payload.zip';
installState = { phase: 'starting', detail: payloadUrl, pct: null };
remember('log', ['server payload not installed — fetching ' + payloadUrl]);
let installer;
try {
installer = require(path.join(__dirname, 'bs-payload-install.js'));
} catch (e) {
fatalMessage = 'installer missing: ' + String(e && e.message ? e.message : e);
remember('error', [fatalMessage]);
}
if (installer) {
installer.install({
url: payloadUrl,
installDir: __dirname,
onState: (st) => {
installState = st;
/*
* One line per PHASE CHANGE or per quarter of progress - not per tick.
*
* The first version logged when pct was 0, 100 or null, which reads as "the interesting
* moments" and is not: a download fires its progress callback on every chunk, so once it
* reached 100% it logged on every one of them. The 200-entry ring filled with dozens of
* copies of "downloading: 73MB of 73MB" and pushed out everything worth reading.
*/
const bucket = st.pct === null || st.pct === undefined ? 'x' : Math.floor(st.pct / 25);
const key = st.phase + ':' + bucket;
if (key !== lastLoggedInstall) {
lastLoggedInstall = key;
remember('log', [st.phase + ': ' + st.detail]);
}
},
}).then((r) => {
remember('log', ['payload installed (' + r.files + ' files) — starting the server']);
startServer();
}).catch((e) => {
installState = { phase: 'failed', detail: String(e && e.message ? e.message : e), pct: null };
fatalMessage = 'could not install the server payload: ' + installState.detail;
remember('error', [fatalMessage]);
post({ type: 'st-server-fatal', message: fatalMessage });
});
}
}
/*
* The diagnostic page lives in a DIFFERENT PROCESS now, so it cannot require() this file the way
* it did when both ran inside the widget. It polls this instead.
*
* Deliberately a separate tiny listener rather than a route on the real server: its entire job is
* to report on a server that is downloading, extracting, or failing to start — exactly the states
* in which the real server cannot answer anything. It binds immediately, before the payload exists.
*/
function status() {
const f = statusFrame();
f.fatal = fatalMessage;
return f;
}
const STATUS_PORT = Number(process.env.ST_STATUS_PORT || 8182);
try {
const statusServer = http.createServer((req, res) => {
// The page is loaded from file://, whose origin is "null" - it needs CORS to read this at all.
res.writeHead(200, {
'content-type': 'application/json',
'access-control-allow-origin': '*',
'cache-control': 'no-store',
});
let body;
try { body = JSON.stringify(status()); }
catch (e) { body = JSON.stringify({ fatal: 'status unavailable: ' + (e && e.message) }); }
res.end(body);
});
statusServer.on('error', (e) => {
// Losing the screen must never cost us the server.
remember('error', ['status listener failed', String(e && e.message ? e.message : e)]);
});
/*
* ⚠️ LOOPBACK ONLY. Its only consumer is node-server.html running on this same device.
*
* Bound to every interface - which is what listen(port) does - it answered from anywhere on the
* customer's LAN with the install progress, disk usage, the device's own address and a tail of
* the server's console. That last one is the problem: a log tail carries whatever the server
* last printed, which is not a thing to hand to an unauthenticated caller on a network we do
* not control.
*/
statusServer.listen(STATUS_PORT, '127.0.0.1', () => remember('log', [
'status listener on 127.0.0.1:' + STATUS_PORT +
' - loopback only, it feeds THIS diagnostics screen (install progress, uptime, the log below)' +
// currentPort(), not a captured constant: server.env is read after this module is evaluated, so
// a value captured here would print the 3001 default rather than the port actually in use.
' while the app on :' + currentPort() + ' is downloading, starting, or down. Not part of the' +
' app; nothing' +
' off-device can reach it.',
]));
if (statusServer.unref) statusServer.unref();
} catch (e) {
remember('error', ['could not start the status listener', String(e && e.message ? e.message : e)]);
}
module.exports = { status };