Files
ridebus-board/server/index.mjs
2026-10-08 22:56:41 +03:00

60 lines
3.4 KiB
JavaScript

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';
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient()) {
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', (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.get('/api/config', (_req, res) => res.json(client.config()));
app.get('/api/stops', async (_req, res) => { res.json(await client.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 client.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 client.timetable(stopId(req))));
app.get('/api/weather', async (req, res) => {
const id = stopId(req);
res.json(await weatherClient.weather(client.config().cityId, id, await client.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 instanceof ApiError ? error.status : 502).json({ error: 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; });