screentinker/brightsign/server/node-server.html
screentinker 0b063cd415
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Licence gate + SBOM (production deps) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Make the on-device server opt-in, and stop the status port answering the LAN (#291)
A fleet gets one package, and exactly one box per site should host the server.
Defaulting to on would mean every player that ever received this package
started listening on 8181, and the mistake would stay invisible until two of
them fought over the same displays.

st-config.json on the storage root, {"server": 1}, switches it on. Absent,
unreadable, unparseable, or anything other than an affirmative value leaves it
off - there is no reading of a broken config file that should end with a
device deciding to host a server. It sits at the root rather than in data/
because that is where an operator drops it over the DWS, and autozip never
writes it, so a re-provision cannot silently flip a site either way. The
package ships st-config.example.json, never st-config.json, for the same
reason.

With the server off, NOTHING listens: roNodeJs is never created, so there is
no 8181 and no 8182. The page is told through its URL rather than discovering
it, because "nothing is answering" would otherwise render as a fault and send
someone looking for a server that was never meant to exist. It now has a
fourth state that says so and offers the one line of JSON that changes it.

Separately: the status listener was bound to every interface, so anything on
the customer's LAN could read the install log, disk usage, the device's own
address and a tail of the server's console - that last one carries whatever
the server printed most recently. Its only consumer is a page on the same
device. Now 127.0.0.1 only, confirmed against /proc/net/tcp rather than by
probing, after a first attempt at verifying it fell back to loopback and
reported that as the LAN result.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:32:37 -05:00

257 lines
12 KiB
HTML

<!doctype html>
<!--
ScreenTinker server diagnostics, displayed on the player.
THE SERVER IS NOT IN THIS PAGE. It runs as a separate roNodeJs process; this is only the screen.
It used to host the server, and that was a mistake worth recording. A Node context inside an
Electron renderer is not Node: shebangs are not stripped, require(ESM) is unsupported,
setInterval is the DOM's and returns a number rather than a Timeout, and worker_threads cannot
create a thread at all. Four separate boot failures, each invisible to a local test because a
local test runs on real Node. BrightSign's own guidance is explicit - roNodeJs for "a long
running process like ... running a web server", roHtmlWidget for "browser-based apps".
The other half of that mistake was lifecycle: a server inside the page dies with the page, and
takes an open SQLite WAL with it. roNodeJs runs in the background uninterrupted.
So this page does one job - show what the server process reports, including while it is still
downloading itself - and it does that over HTTP because the two are no longer in one process.
-->
<meta charset="utf-8">
<title>ScreenTinker server</title>
<style>
html, body { margin: 0; height: 100%; background: #0b0f1a; color: #e6edf7;
font: 22px/1.5 ui-monospace, "DejaVu Sans Mono", monospace; }
.wrap { padding: 40px 56px; }
h1 { font-size: 34px; margin: 0 0 4px; letter-spacing: .5px; }
.sub { color: #7d8da5; margin-bottom: 28px; }
.url { font-size: 44px; color: #4ade80; margin: 18px 0 26px; word-break: break-all; }
table { border-collapse: collapse; margin-bottom: 26px; }
td { padding: 3px 26px 3px 0; }
td.k { color: #7d8da5; }
.bad { color: #f87171; }
/* Not green: green reads as "ready", and it is not ready. */
.url.pending { color: #7d8da5; }
/* The player is a full-screen layer ABOVE the diagnostics, shown only once the server is
genuinely ready. Diagnostics stay mounted underneath so they can be revealed instantly. */
#player { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: none;
background: #000; z-index: 10; }
#log { background: #060911; border: 1px solid #1e2a3d; border-radius: 6px; padding: 14px 18px;
font-size: 17px; line-height: 1.45; height: 34vh; overflow: hidden; color: #9fb3cd;
white-space: pre-wrap; }
</style>
<iframe id="player" title="ScreenTinker player" allow="autoplay; fullscreen"></iframe>
<div class="wrap">
<h1>ScreenTinker server</h1>
<div class="sub" id="sub">starting&hellip;</div>
<div class="url" id="url">&mdash;</div>
<table>
<tr><td class="k">uptime</td><td id="uptime">&mdash;</td>
<td class="k">memory</td><td id="mem">&mdash;</td></tr>
<tr><td class="k">disk</td><td id="disk">&mdash;</td>
<td class="k">database</td><td id="db">&mdash;</td></tr>
<tr><td class="k">node</td><td id="node">&mdash;</td>
<td class="k">load</td><td id="load">&mdash;</td></tr>
</table>
<div id="setupNote" style="display:none;font-size:26px;color:#fbbf24;margin:-10px 0 22px">
Open the address above and create the first account to finish setup.
</div>
<div id="log">waiting for the server&hellip;</div>
</div>
<script>
// Plain browser JavaScript. This page no longer needs nodejs_enabled: it does not require()
// anything, it just polls the server process. One less hybrid context to reason about.
(function () {
var el = function (id) { return document.getElementById(id); };
function fail(what, e) {
el('sub').innerHTML = '<span class="bad">' + what + '</span>';
el('log').textContent = String(e && e.stack ? e.stack : e);
}
/*
* The server runs in a DIFFERENT PROCESS now (roNodeJs), so this page cannot require() it.
*
* It used to: both halves lived inside one roHtmlWidget, and the page pulled status straight out
* of the module. Moving the server to roNodeJs is what makes it survive this page reloading, and
* gives it a real Node runtime instead of a renderer - at the cost that the two now have to talk.
* They talk over HTTP on a small port that the wrapper binds immediately, before the payload is
* even downloaded, because "downloading" and "failed to start" are exactly the states this screen
* exists to show.
*/
var STATUS_URL = 'http://127.0.0.1:8182/';
var last = null;
var lastError = null;
function poll() {
// XHR rather than fetch: this page is loaded from file://, and XHR's failure modes here are
// easier to report than a rejected promise with an opaque TypeError.
var xhr = new XMLHttpRequest();
xhr.open('GET', STATUS_URL + '?t=' + Date.now(), true);
xhr.timeout = 4000;
xhr.onload = function () {
try { last = JSON.parse(xhr.responseText); lastError = null; }
catch (e) { lastError = 'bad status payload'; }
paint();
};
xhr.onerror = function () { lastError = 'no answer from the server process'; paint(); };
xhr.ontimeout = function () { lastError = 'status request timed out'; paint(); };
try { xhr.send(); } catch (e) { lastError = String(e && e.message ? e.message : e); paint(); }
}
/*
* WHICH LAYER IS ON SCREEN.
*
* Three states, and the transitions between them are the whole point:
*
* installing / down / failed diagnostics. The operator can see why.
* up, but no account yet diagnostics, plus the address to go and create one. A player
* with no account to belong to has nothing to show, and hiding
* the address would leave the box unsetuppable - it has no
* keyboard.
* up, account exists the player, full screen.
*
* ⚠️ THE PLAYER IS AN IFRAME, NOT A NAVIGATION. Setting location.href would replace this
* document, and with it the poller that is the only thing able to notice the server failing
* later. As a layer, the diagnostics are always one style change away from being back on screen -
* which is exactly what should happen if the server dies at 3am.
*
* needsSetup === null means the probe has not answered yet, and is deliberately NOT treated as
* false: flipping to the player on an unanswered probe would show a blank player to an operator
* who is still waiting to be told where to sign up.
*/
/*
* WHICH LAYER BELONGS ON SCREEN. Pure, so the transition table can be tested - see
* server/test/brightsign-screen-state.test.js.
*
* 'diagnostics' installing, down, or failed. The operator can see why.
* 'setup' up, but nobody has created an account. Diagnostics PLUS the address to go
* and create one: a player with no account has nothing to show, and hiding the
* address would leave the box unsetuppable - it has no keyboard.
* 'player' up, and an account exists.
*
* needsSetup === null means the probe has not answered yet, and is deliberately NOT false:
* flipping to the player on an unanswered probe shows a blank player to someone who is still
* waiting to be told where to sign up.
*/
function screenState(s, serverEnabled) {
// Off on purpose is not the same as broken. Without this the page would poll a listener that
// was never started and report "no answer from the server process" - which reads as a fault
// and would send someone looking for one.
if (serverEnabled === false) return 'disabled';
if (!s || !s.serving || s.fatal) return 'diagnostics';
if (s.needsSetup === true) return 'setup';
if (s.needsSetup === false) return 'player';
return 'diagnostics';
}
/*
* ⚠️ THE PLAYER IS AN IFRAME, NOT A NAVIGATION. Setting location.href would replace this
* document and with it the poller - the only thing able to notice the server failing later. As a
* layer, the diagnostics are always one style change away from being back on screen, which is
* exactly what should happen if the server dies at 3am.
*/
var playerShown = false;
/*
* st-config.json decides whether this box runs a server; autorun.brs passes the answer through
* because with the server off there is no status listener to ask.
*/
var serverEnabled = !/[?&]server=0(&|$)/.test(String(location.search));
function applyLayer(s) {
var state = screenState(s, serverEnabled);
var frame = el('player');
if (state === 'player') {
if (!playerShown) {
// (Re)load on every transition INTO player. If the server had been down, whatever the
// player last rendered is an error page and it will not recover on its own.
frame.src = 'http://127.0.0.1:' + s.port + '/player/';
frame.style.display = 'block';
playerShown = true;
}
return true;
}
if (playerShown) {
// Blank the frame so a dead server is not being hammered by a player retrying behind an
// invisible layer.
frame.style.display = 'none';
frame.removeAttribute('src');
playerShown = false;
}
el('setupNote').style.display = state === 'setup' ? 'block' : 'none';
if (state === 'disabled') {
el('sub').textContent = 'local server disabled';
el('sub').className = 'sub';
el('url').textContent = 'set {"server": 1} in st-config.json to enable';
el('url').className = 'url pending';
el('log').textContent =
'This player is not running a ScreenTinker server.\n\n' +
'Exactly one device per site should host one. To make it this device, put\n' +
' {"server": 1}\n' +
'in st-config.json on the storage root and reboot.';
}
return false;
}
function paint() {
var s = last;
if (applyLayer(s)) return;
if (!s) {
// Before the first successful poll there is genuinely nothing to report. Say that, rather
// than paint zeros that look like a running server with no traffic.
el('sub').textContent = lastError ? ('waiting for the server process \u2014 ' + lastError)
: 'starting\u2026';
el('sub').className = 'sub';
el('url').textContent = 'starting\u2026';
el('url').className = 'url pending';
return;
}
// While the payload is coming down there is no server yet and nothing to be alarmed about, so
// say what is happening rather than showing a bare 'starting...' for the length of a 71MB fetch.
var inst = s.install || {};
var busy = inst.phase && inst.phase !== 'installed' && inst.phase !== 'idle' && inst.phase !== 'failed';
el('sub').className = 'sub' + (s.fatal ? ' bad' : '');
el('sub').textContent = s.fatal ? 'FAILED'
: busy ? (inst.phase + (inst.pct !== null && inst.pct !== undefined ? ' ' + inst.pct + '%' : '')
+ (inst.detail ? ' \u2014 ' + inst.detail : ''))
: 'running';
// Only show the address once something answers on it - see `serving` in bs-server-boot.js.
// Until then say what is actually happening, so nobody types in a URL that cannot work.
if (s.serving && s.ip) {
el('url').textContent = 'http://' + s.ip + ':' + s.port;
el('url').className = 'url';
} else {
el('url').textContent = !s.ip ? 'no network'
: busy ? 'installing\u2026'
: s.fatal ? 'not running' : 'starting\u2026';
el('url').className = 'url pending';
}
el('uptime').textContent = s.uptimeSec + 's';
el('mem').textContent = 'rss ' + s.rssMb + 'MB / free ' + s.freeMemMb + 'MB';
el('disk').textContent = s.disk
? (s.disk.freeMb + 'MB free of ' + s.disk.totalMb + 'MB (' + s.disk.usedPct + '% used)')
: 'n/a';
el('db').textContent = (s.dbMb === null || s.dbMb === undefined) ? 'n/a' : (s.dbMb + 'MB');
el('node').textContent = s.node;
el('load').textContent = s.loadAvg && s.loadAvg.length ? s.loadAvg[0] : 'n/a';
el('log').textContent = (s.log || []).map(function (l) { return l.m; }).join('\n');
}
paint();
if (serverEnabled) {
poll();
setInterval(poll, 2000);
} else {
// No listener was ever started, so polling would only manufacture errors.
applyLayer(null);
}
})();
</script>