Files
ridebus-status/server/index.mjs
2026-10-13 02:24:39 +03:00

97 lines
5.9 KiB
JavaScript

import { createServer } from 'node:http';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './config.mjs';
import { openStore } from './store.mjs';
import { createMonitor } from './monitor.mjs';
import { makeSnapshot } from './snapshot.mjs';
import { demoSnapshot } from './demo.mjs';
import { createScenarioRunner } from './scenarios.mjs';
import { createNotifier } from './notifications.mjs';
import { buildFeed, feedOptions } from './feed.mjs';
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.png': 'image/png', '.json': 'application/json' };
export async function createStatusServer(config, { dev = false, monitorEnabled = true } = {}) {
const store = openStore(config.dbPath);
const scenarios = createScenarioRunner(config);
const monitor = createMonitor(store, config, { runScenario: service => scenarios.run(service) });
const notifier = createNotifier(store, config);
const vite = dev ? await (await import('vite')).createServer({ server: { middlewareMode: true }, appType: 'spa' }) : null;
const root = resolve('dist');
const demoCache = new Map();
const snapshotCache = new Map();
function liveSnapshot(range) {
const now = Date.now(), revision = store.dataRevision(), cached = snapshotCache.get(range);
if (cached && cached.revision === revision && now >= cached.snapshot.generatedAt && now - cached.snapshot.generatedAt < 5000 && Math.floor(now / 86400000) === Math.floor(cached.snapshot.generatedAt / 86400000)) return cached.snapshot;
const snapshot = makeSnapshot(store, config, range, now);
snapshotCache.set(range, { snapshot, revision });
return snapshot;
}
function json(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
res.end(JSON.stringify(data));
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://localhost');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!['GET', 'HEAD'].includes(req.method)) { res.setHeader('Allow', 'GET, HEAD'); json(res, 405, { error: 'Method not allowed' }); return; }
if (url.pathname === '/healthz') {
// This is the status application's health, independent of target outages.
json(res, 200, { status: 'ok' }); return;
}
if (url.pathname === '/api/status') {
const range = Number(url.searchParams.get('range') || 30);
if (![7, 30, 90].includes(range)) { json(res, 400, { error: 'Range must be 7, 30, or 90 days' }); return; }
if (url.searchParams.get('demo') === '1') {
const cached = demoCache.get(range);
if (!cached || Date.now() - cached.generatedAt > 30000) demoCache.set(range, demoSnapshot(range));
json(res, 200, demoCache.get(range));
} else json(res, 200, liveSnapshot(range));
return;
}
if (url.pathname === '/feed.xml') {
let options;
try { options = feedOptions(url.searchParams, config.services); } catch (error) { json(res, 400, { error: error.message }); return; }
const host = new URL(`http://${req.headers.host || 'localhost'}`);
const local = ['localhost', '127.0.0.1', '[::1]'].includes(host.hostname);
const publicUrl = config.publicUrl || `${local ? 'http' : 'https'}://${host.host}`;
const xml = buildFeed(liveSnapshot(7), options, publicUrl);
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Disposition': 'inline' });
res.end(req.method === 'HEAD' ? undefined : xml);
return;
}
if (url.pathname.startsWith('/api/')) { json(res, 404, { error: 'Not found' }); return; }
if (vite) { vite.middlewares(req, res); return; }
let decoded;
try { decoded = decodeURIComponent(url.pathname); } catch { json(res, 400, { error: 'Invalid path' }); return; }
const path = resolve(root, `.${decoded}`);
if (path !== root && !path.startsWith(root + sep)) { json(res, 404, { error: 'Not found' }); return; }
const file = decoded === '/' ? resolve(root, 'index.html') : path;
if (!existsSync(file) || !statSync(file).isFile()) { json(res, 404, { error: 'Not found' }); return; }
res.setHeader('Content-Type', MIME[extname(file)] || 'application/octet-stream');
res.setHeader('Cache-Control', decoded.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache');
if (req.method === 'HEAD') { res.end(); return; }
createReadStream(file).on('error', () => res.destroy()).pipe(res);
} catch { if (!res.headersSent) json(res, 500, { error: 'Status is temporarily unavailable' }); else res.destroy(); }
});
if (monitorEnabled) { monitor.start(); notifier.start(); }
return {
server, store, monitor,
async close() { await monitor.stop(); await notifier.stop(); await scenarios.close(); await vite?.close(); server.closeIdleConnections(); if (server.listening) await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); store.close(); },
};
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
const config = loadConfig();
const app = await createStatusServer(config, { dev: process.argv.includes('--dev') });
app.server.listen(config.port, config.host, () => {
console.log(`RideBus Status: http://localhost:${app.server.address().port}`);
console.log(`Monitoring ${config.services.length} services every ${config.intervalSeconds}s`);
});
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, async () => { await app.close(); process.exit(0); });
}