97 lines
5.9 KiB
JavaScript
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); });
|
|
}
|