← 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)
});