'use strict'; /* * Make sure the dependencies this build needs are actually installed and loadable — BEFORE anything * requires them. * * The normal upgrade path (scripts/upgrade.sh) runs `npm ci --omit=dev`, so this is not for the * happy case. It is for the three ways a running box ends up with the wrong node_modules: * * ROLLBACK checking out an older tag to back out a bad release restores that tag's * package.json but not its packages, so the server dies on a MODULE_NOT_FOUND for * something the newer build had removed. That is a bad moment to be reading a * stack trace: you are already rolling back because something else broke. * NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes * every boot fail with NODE_MODULE_VERSION mismatch, which reads like database * corruption and is not. * * ⚠️ better-sqlite3 is pinned to EXACTLY 12.9.0, not a caret range, and the reason * is invisible from package.json: 12.10.0 DROPPED the prebuilt binary for Node 20 * (ABI 115) while still advertising `"node": "20.x || ..."` in engines. So a caret * resolves to 12.11.x, finds no prebuild on Node 20, and silently falls through to * `node-gyp rebuild` — a from-source compile during install, and during the repair * below. That matters here: this file rebuilds synchronously BEFORE the server * listens, and prod's systemd unit has TimeoutStartSec=90 with Restart=always, so a * slow or failing compile is a boot loop rather than a self-heal. 12.9.0 is the last * version shipping prebuilds for BOTH Node 20 (115) and Node 22 (127), which is what * lets the runtime move without the module having to compile at all. * Re-check the release assets before widening the pin. * HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install. * * All three present as a server that will not start, with an error that names a file rather than * the action needed. Detecting and repairing is a few seconds; diagnosing is an outage. * * ⚠️ Deliberately dependency-free — only Node builtins. Anything it required could be the very * thing that is missing. * * Set ST_SKIP_DEP_PREFLIGHT=1 to turn it off (air-gapped hosts, or an operator who manages * node_modules themselves and does not want a boot reaching for the network). */ const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const SERVER_DIR = path.join(__dirname, '..'); const NODE_MODULES = path.join(SERVER_DIR, 'node_modules'); const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; // a cold install on a Pi is genuinely slow /** Which declared dependencies are not on disk. */ function missingDeps() { let pkg; try { pkg = JSON.parse(fs.readFileSync(path.join(SERVER_DIR, 'package.json'), 'utf8')); } catch { return []; // no package.json is not our problem to diagnose } const declared = Object.keys(pkg.dependencies || {}); return declared.filter((name) => { // A scoped or nested name is still one directory below node_modules. try { return !fs.existsSync(path.join(NODE_MODULES, name, 'package.json')); } catch { return true; } }); } /** * Is the native module loadable by THIS Node? * * Checked by actually loading it, because the failure is an ABI mismatch that no version string * comparison catches reliably — a rebuild against the same major can still differ. */ function nativeModuleBroken() { try { /* * ⚠️ CONSTRUCT one, do not merely require it. * * better-sqlite3's entry point is plain JavaScript and loads the compiled `.node` binding * lazily, so `require()` alone SUCCEEDS under a Node whose ABI the binary was not built for — * the first version of this check did exactly that and reported a broken install as healthy, * verified against a real Node 18 / Node 20 mismatch. Opening an in-memory database is what * actually pulls the binding in, and it touches no file. */ const Database = require('better-sqlite3'); new Database(':memory:').close(); return null; } catch (e) { const msg = String((e && e.message) || ''); if (/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|was compiled against a different/i.test(msg)) return msg; if (/Cannot find module/i.test(msg)) return msg; // Anything else is a real error in the module, not an installation problem — let it surface // later with its own stack rather than being masked by an npm run. return null; } } /* * Can this host use Node's built-in driver instead? * * ⚠️ NODE 24 IN PRACTICE. node:sqlite is only usable without a flag from Node 23.4 onwards; on the * 22.x line — which is this project's declared floor — it exists ONLY behind --experimental-sqlite, * so a 22.x server started normally has no built-in driver at all. * * Hence a probe rather than a version comparison: `require` answers what this exact process can do, * including the 22.x-with-the-flag case, and cannot be fooled by a floor that says 22.9 while the * player it has to serve runs 24.15. */ function nodeSqliteAvailable() { try { require('node:sqlite'); return true; } catch { return false; } } /* * Is attempting a rebuild the right move, rather than falling back? * * Yes when there is no built-in driver to fall back TO — then a broken native module is fatal and * the existing loud failure is exactly right. Yes when a toolchain is present, because the native * driver is faster and is what production runs, so a repairable install should be repaired. * * No on a host with node:sqlite and no compiler: that is a player, and `npm rebuild` there is a * five-minute node-gyp failure ending in a server that never starts. */ function canRebuildNative() { // Explicitly asked for the built-in driver: nothing to rebuild, whatever the host has. if (String(process.env.ST_SQLITE_DRIVER || '').trim().startsWith('node')) return false; if (!nodeSqliteAvailable()) return true; for (const bin of ['python3', 'make', 'g++']) { try { execFileSync(bin, ['--version'], { stdio: 'ignore', timeout: 5000 }); } catch { return false; } } return true; } function run(args, label) { console.log(`[preflight] ${label}: npm ${args.join(' ')}`); execFileSync('npm', args, { cwd: SERVER_DIR, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS }); } function fail(reason, hint) { console.error(`[preflight] ${reason}`); console.error(`[preflight] ${hint}`); console.error('[preflight] Set ST_SKIP_DEP_PREFLIGHT=1 to boot without this check.'); process.exit(1); } function preflight() { // Same spellings as every other boolean the server accepts, so an operator who writes `true` // does not silently get a boot that reaches for the registry anyway. if (['1', 'true', 'yes'].includes(String(process.env.ST_SKIP_DEP_PREFLIGHT || '').toLowerCase())) return; const missing = missingDeps(); const nodeModulesAbsent = !fs.existsSync(NODE_MODULES); if (missing.length || nodeModulesAbsent) { const what = nodeModulesAbsent ? 'node_modules is missing' : `${missing.length} dependency/dependencies missing: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`; console.warn(`[preflight] ${what} — installing before start.`); try { /* * `npm ci` when there is a lockfile and nothing installed: it is reproducible and it is what * upgrade.sh uses. Otherwise `npm install`, because `ci` DELETES node_modules first and would * throw away a working tree to fix one missing package. */ const hasLock = fs.existsSync(path.join(SERVER_DIR, 'package-lock.json')); if (hasLock && nodeModulesAbsent) { /* * Nothing installed, so `ci` has nothing to destroy and gives a reproducible tree. * * `--omit=dev` ONLY when this is plainly a production boot. Applying it unconditionally * meant a cold start on a developer machine installed 307 packages and left `npm test` * broken — js-yaml, puppeteer-core and socket.io-client absent — which is the same class of * surprise as the prune this file already warns about, arriving through the other branch of * the same `if`. */ const prod = process.env.NODE_ENV === 'production'; run(prod ? ['ci', '--omit=dev', '--no-audit', '--no-fund'] : ['ci', '--no-audit', '--no-fund'], 'installing'); } else { /* * ⚠️ Install ONLY what is missing, by name, and never `--omit=dev` on a populated tree. * * `npm install --omit=dev` reconciles the whole tree, which PRUNES devDependencies — so * merely starting the server deleted socket.io-client, puppeteer-core and js-yaml, and broke * `npm test`. A review watched it happen. A boot-time repair that quietly removes packages * is worse than the failure it fixes, so this touches nothing it was not asked to. * * `--no-save` because a server starting up has no business editing package.json. */ run(['install', '--no-save', '--no-audit', '--no-fund', ...missing], 'installing missing packages'); } } catch (e) { /* * An install can fail because ANOTHER server started at the same moment and won the race — * observed as `ENOTEMPTY … rename node_modules/fs-extra`. The tree is complete by the time we * see the error, so exiting here killed a process that had nothing wrong with it. Re-check * before giving up; only a genuinely incomplete tree is fatal. */ const afterFailure = missingDeps(); if (afterFailure.length) { fail(`could not install dependencies: ${e && e.message}`, 'Run `npm ci --omit=dev` in the server directory, or check network access to the npm registry.'); } console.warn(`[preflight] install reported an error but the tree is complete (${e && e.message}) — continuing.`); } const still = missingDeps(); if (still.length) { fail(`still missing after install: ${still.join(', ')}`, 'Check the npm output above.'); } console.log('[preflight] dependencies installed.'); } /* * ⚠️ A HOST WITH NO NATIVE MODULE IS NOT A BROKEN HOST ANY MORE. * * db/sqlite-driver.js falls back to node:sqlite when better-sqlite3 is absent or unloadable, which * is how the server runs on a BrightSign player: no compiler, no node-gyp, no prebuild for aarch64. * Rebuilding — let alone failing the boot — would be wrong there, so if the built-in driver can * serve, say so and carry on. Where a toolchain DOES exist the native module is still preferred and * still repaired, because it is faster and it is what production runs. */ const nativeProblem = nativeModuleBroken(); if (nativeProblem && !canRebuildNative()) { console.log('[preflight] better-sqlite3 is unavailable; using the built-in node:sqlite driver.'); console.log(`[preflight] ${String(nativeProblem).split('\n')[0]}`); return; } if (nativeProblem) { console.warn(`[preflight] better-sqlite3 will not load under Node ${process.version} — rebuilding.`); console.warn(`[preflight] ${nativeProblem.split('\n')[0]}`); try { run(['rebuild', 'better-sqlite3'], 'rebuilding native module'); } catch (e) { fail(`could not rebuild better-sqlite3: ${e && e.message}`, `Run \`npm rebuild better-sqlite3\` in the server directory. This usually means Node changed version (now ${process.version}) and the module needs recompiling; a build toolchain (python3, make, g++) must be present.`); } if (nativeModuleBroken()) { fail('better-sqlite3 still will not load after a rebuild.', 'Delete server/node_modules and run `npm ci --omit=dev`.'); } console.log('[preflight] native module rebuilt.'); } } module.exports = { preflight, missingDeps, nativeModuleBroken };