'use strict'; /** * Service Times — server part. * * Owns the one connection to the church website, keeps every campus's Sunday * service times in memory, and fans them — together with the production marks * an operator puts on a service — out to every tile over SSE. Browsers never * talk to the website. * * GET /state → { state } (instant paint for new tiles) * GET /stream → SSE, `state` events (change-detected, heartbeated) * POST /mark → mark one service (replay / do not use / issue) * POST /clear → drop every mark (a fresh week) * POST /refresh → re-read the website now * GET /campuses → { options } — the campuses in play, for the admin * broadcast picker and a tile's campus list * GET /discovered → { options } — every campus page the website publishes, * for the admin "Campuses" picker * * Why the marks live here and not in a tile's settings: a mark is the booth's * shared answer to "is this service usable?", and every campus watching the * board has to see the same one. Per-tile settings are per browser, so they * would let two people disagree about whether the 9:30 is going out. Marks * are written to the ProdDash data directory so a restart mid-morning does * not lose them, and they expire on their own (admin "Marks") so last week's * never greet a new Sunday. * * The website is the source of truth for times. Manual campuses (admin) are a * stopgap for a campus the site has not published yet; the moment the site * carries it, the site's times take over and the admin page says the manual * line can go. */ const fs = require('fs'); const os = require('os'); const path = require('path'); const { parseCampusPage, discoverCampusSlugs, parseManualCampuses, mergeCampuses, parseFeeds, assignRoles, slugify, } = require('./parse'); /** Abort a hung page read well before the refresh cadence. */ const REQUEST_TIMEOUT_MS = 12000; /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ const HEARTBEAT_MS = 15000; /** Fastest allowed re-read: service times change a few times a year. */ const MIN_REFRESH_MINUTES = 5; /** Backoff after a failed read — keep the last good times on screen meanwhile. */ const RETRY_MS = 60000; /** Guard against a redirect loop or a runaway campus list. */ const MAX_CAMPUSES = 40; const BODY_LIMIT_BYTES = 64 * 1024; const MARK_STATUSES = new Set(['ok', 'issue', 'replay', 'skip']); const NOTE_LIMIT = 280; /** Set by init(), read by the routes — both are rebuilt together on remount. */ let current = null; /* ── small helpers ──────────────────────────────────────────────────── */ function sendJson(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); } function readJsonBody(req) { return new Promise((resolve, reject) => { let data = ''; let size = 0; req.on('data', (chunk) => { size += chunk.length; if (size > BODY_LIMIT_BYTES) { reject(new Error('Request body too large.')); req.destroy(); return; } data += chunk; }); req.on('end', () => { if (!data.trim()) return resolve({}); try { const body = JSON.parse(data); resolve(body && typeof body === 'object' ? body : {}); } catch { reject(new Error('Malformed JSON.')); } }); req.on('error', reject); }); } /** Route handlers are async — the shell only catches synchronous throws. */ function guard(fn) { return (req, res) => { Promise.resolve(fn(req, res)).catch((err) => { try { sendJson(res, err && err.status ? err.status : 500, { error: err?.message || String(err) }); } catch { /* the client is already gone */ } }); }; } /** * Where ProdDash keeps per-machine state, resolved the way the shell does. * Never the checkout: the repo folder is replaced on update. */ function resolveDataDir() { const root = path.join(__dirname, '..', '..'); const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim(); if (fromEnv) return path.resolve(root, fromEnv); const home = os.homedir(); if (process.platform === 'win32') { return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash'); } if (process.platform === 'darwin') { return path.join(home, 'Library', 'Application Support', 'ProdDash'); } return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash'); } const MARKS_FILE = path.join(resolveDataDir(), 'service-times-marks.json'); function markKey(campus, service) { return `${campus}::${service}`; } /* ── admin config shaping ───────────────────────────────────────────── */ function siteBase(config) { const raw = String(config.siteUrl || '').trim().replace(/\/+$/, ''); if (!raw) return ''; return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; } function listSetting(value) { if (Array.isArray(value)) return value.map((v) => slugify(v)).filter(Boolean); if (typeof value === 'string') { return value.split(/[\n,]+/).map((v) => slugify(v)).filter(Boolean); } return []; } /* ── the website ────────────────────────────────────────────────────── */ async function getText(url) { const response = await fetch(url, { redirect: 'follow', headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { const err = new Error(`${response.status} ${response.statusText || ''}`.trim()); err.status = response.status; throw err; } return response.text(); } /** Every campus page the site publishes — the sitemap first, the index page as a fallback. */ async function discover(base, log) { const sources = []; for (const target of ['/page-sitemap.xml', '/wp-sitemap.xml', '/locations/', '/']) { try { sources.push(await getText(base + target)); if (target.includes('sitemap')) break; // a sitemap is the fuller list } catch (err) { log(`discovery: ${target} — ${err.message}`); } } if (!sources.length) return []; return discoverCampusSlugs(...sources).slice(0, MAX_CAMPUSES); } async function readCampus(base, slug) { try { const html = await getText(`${base}/locations/${slug}/`); return parseCampusPage(html, { slug }); } catch (err) { return { slug, name: slugify(slug).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), services: [], error: `Page unreadable: ${err.message}`, }; } } /* ── module ─────────────────────────────────────────────────────────── */ module.exports = { init({ config, log }) { const streams = new Set(); const base = siteBase(config); const chosen = listSetting(config.campuses); const broadcast = new Set(listSetting(config.broadcast)); const { feeds, errors: feedErrors } = parseFeeds(config.feeds); const { campuses: manualCampuses, errors: manualErrors } = parseManualCampuses(config.manual); const retentionHours = Math.max(0, Number(config.retentionHours) || 0); const refreshMs = Math.max(MIN_REFRESH_MINUTES, Number(config.refreshMinutes) || 360) * 60000; let marks = new Map(); let marksWritable = true; let discovered = []; let siteCampuses = []; let reachable = false; let lastError = base ? '' : 'No website address configured'; let lastFetched = 0; let timer = null; let stopped = false; let lastFrame = ''; /* ── marks: load, save, expire ──────────────────────────────────── */ function loadMarks() { try { const saved = JSON.parse(fs.readFileSync(MARKS_FILE, 'utf8')); for (const [key, value] of Object.entries(saved?.marks || {})) { if (value && typeof value === 'object') marks.set(key, value); } log(`${marks.size} mark(s) restored`); } catch { /* no marks yet — the usual case */ } } function saveMarks() { const payload = { marks: Object.fromEntries(marks), savedAt: Date.now() }; try { fs.mkdirSync(path.dirname(MARKS_FILE), { recursive: true }); const tmp = `${MARKS_FILE}.tmp`; fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n'); fs.renameSync(tmp, MARKS_FILE); marksWritable = true; } catch (err) { // Never fall back to writing inside the checkout: the marks stay in // memory and health() says they will not survive a restart. if (marksWritable) log(`marks cannot be saved (${err.message}) — keeping them in memory only`); marksWritable = false; } } /** Drop marks older than the retention window. Returns true if any went. */ function expireMarks() { if (!retentionHours) return false; const cutoff = Date.now() - retentionHours * 3600000; let dropped = false; for (const [key, mark] of marks) { if (!(Number(mark?.at) > cutoff)) { marks.delete(key); dropped = true; } } if (dropped) saveMarks(); return dropped; } /* ── the view every tile draws ──────────────────────────────────── */ function campusList() { const wanted = chosen.length ? siteCampuses.filter((c) => chosen.includes(c.slug)) : siteCampuses; const merged = assignRoles(mergeCampuses(wanted, manualCampuses), { broadcast, feeds }); // Marks ride along on the service they belong to. for (const campus of merged) { campus.services = campus.services.map((service) => { const mark = marks.get(markKey(campus.slug, service.id)) || null; return { ...service, mark }; }); } return merged; } function snapshot() { return { configured: Boolean(base), reachable, lastError, lastFetched, siteUrl: base, refreshMinutes: refreshMs / 60000, retentionHours, marksWritable, campuses: campusList(), discovered, manualErrors, feedErrors, now: Date.now(), }; } function broadcastState({ force = false } = {}) { if (!streams.size) return; const view = snapshot(); let frame; try { frame = JSON.stringify(view); } catch { return; } // `now` changes every call; compare without it so idle tiles stay idle. const compare = frame.replace(/,"now":\d+/, ''); if (!force && compare === lastFrame) return; lastFrame = compare; const payload = `event: state\ndata: ${frame}\n\n`; for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } } } /* ── the refresh loop ───────────────────────────────────────────── */ async function refresh() { if (stopped) return; if (!base) { reachable = false; lastError = 'No website address configured'; return; } try { const found = await discover(base, log); if (found.length) discovered = found; const slugs = chosen.length ? chosen : discovered; if (!slugs.length) throw new Error('No campus pages found on the site'); const read = await Promise.all(slugs.slice(0, MAX_CAMPUSES).map((slug) => readCampus(base, slug))); const usable = read.filter((campus) => campus.services.length); // A total failure keeps the last good times on screen — a site outage // must not blank the board in the middle of a service. if (!usable.length && siteCampuses.length) { throw new Error(read[0]?.error || 'No service times found on the site'); } siteCampuses = read; reachable = true; lastFetched = Date.now(); const broken = read.filter((campus) => campus.error); lastError = broken.length ? `${broken.length} campus page(s) unreadable: ${broken[0].error}` : ''; log(`read ${usable.length}/${read.length} campus page(s) from ${base}`); } catch (err) { reachable = false; lastError = err?.message || String(err); log(`refresh failed: ${lastError}`); } } function schedule(delay) { clearTimeout(timer); if (stopped) return; timer = setTimeout(async () => { await refresh(); expireMarks(); broadcastState(); schedule(reachable ? refreshMs : RETRY_MS); }, delay); timer.unref?.(); } loadMarks(); expireMarks(); if (base) { log(`reading service times from ${base} every ${Math.round(refreshMs / 60000)} min`); } else { log('no website address configured — set one in /admin'); } // First read immediately, then on the configured cadence. schedule(0); const heartbeat = setInterval(() => { expireMarks(); broadcastState(); for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS); heartbeat.unref?.(); current = { streams, snapshot, broadcastState, refreshNow: async () => { await refresh(); broadcastState({ force: true }); }, setMark(campusSlug, serviceId, patch) { const key = markKey(slugify(campusSlug), String(serviceId || '')); const status = MARK_STATUSES.has(patch.status) ? patch.status : 'ok'; if (status === 'ok' && !patch.note && !patch.source) { marks.delete(key); } else { marks.set(key, { status, note: String(patch.note || '').slice(0, NOTE_LIMIT), source: String(patch.source || '').slice(0, 120), at: Date.now(), }); } saveMarks(); broadcastState({ force: true }); }, clearMarks() { const had = marks.size; marks.clear(); saveMarks(); broadcastState({ force: true }); return had; }, }; return { stop() { stopped = true; clearTimeout(timer); clearInterval(heartbeat); for (const res of streams) { try { res.end(); } catch { /* already gone */ } } streams.clear(); if (current && current.streams === streams) current = null; }, health() { if (!base) return { status: 'error', message: 'No website address configured' }; const notes = []; if (!marksWritable) notes.push('marks are memory-only — the data directory is not writable'); if (manualErrors.length) notes.push(manualErrors[0]); if (feedErrors.length) notes.push(feedErrors[0]); const superseded = campusList().filter((c) => c.supersededManual).map((c) => c.name); if (superseded.length) { notes.push(`the website now publishes ${superseded.join(', ')} — the manual line(s) can be removed`); } const suffix = notes.length ? ` — ${notes.join('; ')}` : ''; if (!reachable) { return { status: 'error', message: `${lastError || 'Website unreachable'}${suffix}` }; } const count = campusList().reduce((sum, c) => sum + c.services.length, 0); const warn = lastError || notes.length; return { status: warn ? 'connecting' : 'ok', message: `${count} service(s) across ${campusList().length} campus(es)${lastError ? ` — ${lastError}` : ''}${suffix}`, }; }, }; }, /** * The board, plus one entry per campus. A campus booth wants its own times * and the service it is taking resi from — not the whole network — so those * entries fix the campus and drop the campus picker from their gear. */ tiles() { const board = { id: 'board', name: 'Service Times', description: 'Every campus, its services, and what is marked for replay or unusable', defaultSize: { w: 6, h: 5 }, minSize: { w: 3, h: 3 }, settings: {}, }; const campuses = current ? current.snapshot().campuses : []; return [ board, ...campuses.map((campus) => ({ id: campus.slug, name: campus.name, description: `${campus.name} service times`, defaultSize: { w: 3, h: 4 }, minSize: { w: 2, h: 2 }, settings: { campuses: [campus.slug] }, instanceSchema: { showDay: { type: 'boolean', label: 'Day', default: true, group: 'Show' }, showRole: { type: 'boolean', label: 'Role', default: true, group: 'Show' }, showSource: { type: 'boolean', label: 'Resi source', default: true, group: 'Show' }, showDependents: { type: 'boolean', label: 'Campuses fed', default: true, group: 'Show' }, showNotes: { type: 'boolean', label: 'Notes', default: true, group: 'Show' }, showUpdated: { type: 'boolean', label: 'Updated line', default: false, group: 'Show' }, }, instanceGroups: { Show: { collapsed: true, columns: 2 } }, })), ]; }, routes() { return { 'GET /state': (req, res) => { sendJson(res, 200, { state: current ? current.snapshot() : null }); }, 'GET /stream': (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.write('retry: 3000\n\n'); res.write(`event: state\ndata: ${JSON.stringify(current ? current.snapshot() : null)}\n\n`); if (!current) return void res.end(); const { streams } = current; // A viewer that stops reading (locked tablet, sleeping laptop) must // not queue frames forever; keepalive probes make the OS notice. try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } streams.add(res); req.on('close', () => streams.delete(res)); }, 'POST /mark': guard(async (req, res) => { if (!current) return sendJson(res, 503, { error: 'Module is restarting' }); const body = await readJsonBody(req); const campus = String(body.campus || '').trim(); const service = String(body.service || '').trim(); if (!campus || !service) return sendJson(res, 400, { error: 'campus and service are required' }); current.setMark(campus, service, { status: body.status, note: body.note, source: body.source, }); sendJson(res, 200, { ok: true }); }), 'POST /clear': guard(async (req, res) => { if (!current) return sendJson(res, 503, { error: 'Module is restarting' }); sendJson(res, 200, { ok: true, cleared: current.clearMarks() }); }), 'POST /refresh': guard(async (req, res) => { if (!current) return sendJson(res, 503, { error: 'Module is restarting' }); await current.refreshNow(); sendJson(res, 200, { ok: true, state: current.snapshot() }); }), 'GET /campuses': (req, res) => { const campuses = current ? current.snapshot().campuses : []; sendJson(res, 200, { options: campuses.map((campus) => ({ value: campus.slug, label: campus.name })), }); }, 'GET /discovered': (req, res) => { const state = current ? current.snapshot() : null; const names = new Map((state?.campuses || []).map((c) => [c.slug, c.name])); const slugs = state?.discovered?.length ? state.discovered : [...names.keys()]; sendJson(res, 200, { options: slugs.map((slug) => ({ value: slug, label: names.get(slug) || slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), })), }); }, }; }, };