← back to Dw Cadence Next2
server.js
118 lines
'use strict';
/*
* server.js — zero-dependency static server for the "Going Live · Next 2 Days"
* cadence viewer. Basic-auth gated (admin / DW2024!). Serves public/ and
* data/next2days.json.
*
* Self-tracking freshness (so the board follows the cadence as items drain):
* - rebuilds data on boot,
* - rebuilds on a 15-min in-process timer (no external cron / launchd job),
* - rebuilds on page load when the data is older than STALE_MS (async — serves
* the current file immediately; the next load shows the fresh build),
* - GET /rebuild forces one; GET /rebuild-status reports job health for the
* board's "last re-pull" line.
* A single `rebuilding` guard prevents overlapping runs.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const PORT = parseInt(process.env.PORT || '0', 10); // 0 = OS-assigned free port
const USER = process.env.BASIC_USER || 'admin';
const PASS = process.env.BASIC_PASS || 'DW2024!';
const ROOT = __dirname;
const DATA_FILE = path.join(ROOT, 'data', 'next2days.json');
const BUILDER = path.join(ROOT, 'scripts', 'build-data.js');
const INTERVAL_MS = 15 * 60 * 1000; // periodic re-pull
const STALE_MS = 15 * 60 * 1000; // on-load rebuild threshold
const TYPES = { '.html':'text/html; charset=utf-8', '.js':'text/javascript', '.css':'text/css',
'.json':'application/json', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon' };
// ── rebuild job state (exposed to the board) ─────────────────────────────────
let rebuilding = false;
const job = {
last_attempt: null, // ISO — when a rebuild last started
last_success: null, // ISO — when a rebuild last succeeded
ok: null, // bool — outcome of the most recent completed run
message: '', // one-line summary (builder stdout tail, or error)
total: null, // item count from the last successful build
interval_ms: INTERVAL_MS,
};
function currentTotal() {
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')).meta.total; } catch { return null; }
}
function dataAgeMs() {
try { return Date.now() - fs.statSync(DATA_FILE).mtimeMs; } catch { return Infinity; }
}
function doRebuild(cb) {
if (rebuilding) { cb && cb(new Error('already rebuilding')); return; }
rebuilding = true;
job.last_attempt = new Date().toISOString();
execFile('node', [BUILDER], { timeout: 120000 }, (e, out, err) => {
rebuilding = false;
job.ok = !e;
job.message = ((out || '').trim().split('\n').pop() || (err || '').trim() || (e ? String(e) : '')).slice(0, 200);
if (!e) { job.last_success = new Date().toISOString(); job.total = currentTotal(); }
console.log(`[rebuild] ${job.ok ? 'ok' : 'FAIL'} — ${job.message}`);
cb && cb(e, out, err);
});
}
function unauthorized(res){ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Cadence"' }); res.end('Authentication required'); }
const server = http.createServer((req, res) => {
// Unauthenticated liveness probe — so the fleet keepalive's HTTP check sees a
// 200 and never misreads the auth-gated 401 on / as "dead" (restart-loop guard).
if (req.url.split('?')[0] === '/healthz') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
return res.end('ok');
}
const hdr = req.headers.authorization || '';
const [scheme, b64] = hdr.split(' ');
if (scheme !== 'Basic' || !b64) return unauthorized(res);
const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
if (u !== USER || p !== PASS) return unauthorized(res);
let url = req.url.split('?')[0];
if (url === '/rebuild-status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ...job, rebuilding, data_age_ms: dataAgeMs() }));
}
if (url === '/rebuild') {
return doRebuild((e) => {
res.writeHead(e ? 500 : 200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ...job, rebuilding, data_age_ms: dataAgeMs() }));
});
}
// On-load stale guard: kick a background rebuild but DON'T block the page.
if ((url === '/' || url === '/index.html') && !rebuilding && dataAgeMs() > STALE_MS) {
doRebuild();
}
if (url === '/') url = '/public/index.html';
else if (url === '/next2days.json') url = '/data/next2days.json';
else if (!url.startsWith('/public/') && !url.startsWith('/data/')) url = '/public' + url;
const file = path.normalize(path.join(ROOT, url));
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end('forbidden'); }
fs.readFile(file, (e, buf) => {
if (e) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream' });
res.end(buf);
});
});
server.listen(PORT, '127.0.0.1', () => {
const p = server.address().port;
fs.writeFileSync(path.join(ROOT, '.port'), String(p));
console.log(`dw-cadence-next2 listening on http://127.0.0.1:${p} (basic-auth as ${USER})`);
doRebuild(); // refresh on boot
setInterval(doRebuild, INTERVAL_MS); // periodic re-pull (self-contained)
});