import express from 'express'; import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; import { ApiError, createBoardClient } from './board.mjs'; import { createWeatherClient } from './weather.mjs'; import { createDeviceBridge } from './device.mjs'; export function createApp(client = createBoardClient(), weatherClient = createWeatherClient(), deviceBridge = createDeviceBridge(), clientFactory = createBoardClient) { const app = express(); app.disable('x-powered-by'); app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); }); app.get('/healthz', (_req, res) => res.json({ status: 'ok' })); app.use('/api/device', express.json({ limit: '8kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); if (req.method !== 'GET') { let origin; try { origin = req.get('Origin') ? new URL(req.get('Origin')) : null; } catch { return res.status(403).json({ error: 'invalid_origin' }); } if (req.get('Sec-Fetch-Site') === 'cross-site' || origin && origin.host !== req.get('Host') || !req.is('application/json')) return res.status(403).json({ error: 'invalid_origin' }); } next(); }); app.get('/api/device/status', async (req, res) => res.json(await deviceBridge.status(req, client.config()))); app.post('/api/device/pair', async (req, res) => res.json(await deviceBridge.start(req, res))); app.post('/api/device/heartbeat', async (req, res) => res.json(await deviceBridge.heartbeat(req))); const clients = new Map(); app.use('/api', (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; } next(); }); app.use('/api', async (req, _res, next) => { if (!deviceBridge.enabled || req.path === '/config') { req.boardClient = client; return next(); } const device = await deviceBridge.context(req); if (!clients.has(device.cityId)) clients.set(device.cityId, clientFactory({ cityId: device.cityId, cityName: device.cityName, stopName: device.stopName, defaultStopId: device.settings.stopId })); req.boardClient = clients.get(device.cityId); if (req.query.stopId && req.query.stopId !== String(device.settings.stopId)) throw new ApiError(409, 'settings_changed'); next(); }); app.get('/api/config', (_req, res) => res.json(client.config())); app.get('/api/stops', async (req, res) => { res.json(await req.boardClient.stops()); }); app.get('/api/board', async (req, res) => { const raw = req.query.stopId; if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop'); const data = await req.boardClient.board(Number(raw)); res.json({ ...data, serverTime: new Date().toISOString() }); }); const stopId = req => { if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop'); return Number(req.query.stopId); }; app.get('/api/timetable', async (req, res) => res.json(await req.boardClient.timetable(stopId(req)))); app.get('/api/weather', async (req, res) => { const id = stopId(req); res.json(await weatherClient.weather(req.boardClient.config().cityId, id, await req.boardClient.coordinates(id))); }); app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' })); // Never return upstream bodies, stack traces or API credentials. app.use((error, _req, res, _next) => { res.status(error.type === 'entity.parse.failed' ? 400 : error.type === 'entity.too.large' ? 413 : error instanceof ApiError ? error.status : 502).json({ error: ['entity.parse.failed', 'entity.too.large'].includes(error.type) ? 'invalid_request' : error instanceof ApiError ? error.code : 'data_unavailable' }); }); return app; } async function start() { const root = fileURLToPath(new URL('..', import.meta.url)); const dev = process.argv.includes('--dev'); const app = createApp(); if (dev) { const vite = await (await import('vite')).createServer({ root, server: { middlewareMode: true }, appType: 'spa' }); app.use(vite.middlewares); } else { const dist = resolve(root, 'dist'); if (!existsSync(resolve(dist, 'index.html'))) throw new Error('Run npm run build first.'); app.use(express.static(dist, { setHeaders: (res, file) => res.setHeader('Cache-Control', file.includes('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache') })); app.get('/{*path}', (_req, res) => res.sendFile(resolve(dist, 'index.html'))); } const host = process.env.HOST || '127.0.0.1'; const port = Number(process.env.PORT || 5180); const server = app.listen(port, host, () => console.log(`RideBus Board: http://${host}:${port} (${dev ? 'development' : 'production'})`)); server.on('error', error => { console.error(error.code === 'EADDRINUSE' ? `Port ${port} is in use.` : 'Unable to start server.'); process.exitCode = 1; }); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) start().catch(error => { console.error(error.message); process.exitCode = 1; });