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'; import { widgetOptions, widgetSummary, widgetDocument } from './widget.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 === '/api/widget' || url.pathname === '/embed') { let options; try { options = widgetOptions(url.searchParams, config.services, { embed: url.pathname === '/embed' }); } catch (error) { json(res, 400, { error: error.message }); return; } const summary = widgetSummary(store, config, options.serviceIds); if (url.pathname === '/api/widget') { json(res, 200, summary); return; } res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors *"); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(req.method === 'HEAD' ? undefined : widgetDocument(summary, options)); return; } const widgetAssets = { '/widget-client.js': [resolve('public/widget-client.js'), 'text/javascript; charset=utf-8'], '/widget/manrope-latin.woff2': [resolve('node_modules/@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2'), 'font/woff2'], '/widget/manrope-cyrillic.woff2': [resolve('node_modules/@fontsource-variable/manrope/files/manrope-cyrillic-wght-normal.woff2'), 'font/woff2'], }; if (widgetAssets[url.pathname]) { const [path, mime] = widgetAssets[url.pathname]; res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': url.pathname.endsWith('.woff2') ? 'public, max-age=86400' : 'no-cache' }); if (req.method === 'HEAD') res.end(); else createReadStream(path).on('error', () => res.destroy()).pipe(res); 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); }); }