Files
ridebus-pwa/server/index.mjs
2026-10-07 01:48:32 +03:00

85 lines
4.1 KiB
JavaScript

import http from 'node:http';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ApiError, createRideBusClient, parseRequest } from './ridebus.mjs';
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
const dist = resolve(root, 'dist');
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.webmanifest': 'application/manifest+json' };
export function apiHandler(client = createRideBusClient()) {
return async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (!url.pathname.startsWith('/api/')) return false;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
res.writeHead(405).end(JSON.stringify({ error: 'method_not_allowed' }));
return true;
}
try {
if (url.pathname !== '/api/data') throw new ApiError(404, 'not_found');
const data = await client.get(parseRequest(url.searchParams));
res.writeHead(200).end(JSON.stringify(data));
} catch (error) {
const known = error instanceof ApiError;
// Upstream bodies, keys and configuration never reach the browser.
res.writeHead(known ? error.status : 502).end(JSON.stringify({ error: known ? error.code : 'data_unavailable' }));
}
return true;
};
}
async function start() {
const dev = process.argv.includes('--dev');
const port = Number(process.env.PORT || 5173);
const host = process.env.HOST || '127.0.0.1';
if (!dev && !existsSync(resolve(dist, 'index.html'))) throw new Error('Run npm run build before npm start');
const api = apiHandler();
const vite = dev ? await (await import('vite')).createServer({ root, server: { middlewareMode: true }, appType: 'spa' }) : null;
const server = http.createServer(async (req, res) => {
if (new URL(req.url, 'http://localhost').pathname === '/healthz') {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD');
res.writeHead(405).end();
} else {
res.writeHead(200).end(req.method === 'HEAD' ? undefined : JSON.stringify({ status: 'ok' }));
}
return;
}
if (await api(req, res)) return;
if (vite) { vite.middlewares(req, res); return; }
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405).end(); return; }
let path;
try { path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); } catch { res.writeHead(400).end(); return; }
let file = resolve(dist, `.${path}`);
if (file !== dist && !file.startsWith(dist + sep)) { res.writeHead(403).end(); return; }
if (!existsSync(file) || !statSync(file).isFile()) {
if (extname(path)) { res.writeHead(404).end(); return; }
file = resolve(dist, 'index.html');
}
res.setHeader('Content-Type', mime[extname(file)] || 'application/octet-stream');
res.setHeader('Cache-Control', file.includes(`${sep}assets${sep}`) ? 'public, max-age=31536000, immutable' : 'no-cache');
res.setHeader('X-Content-Type-Options', 'nosniff');
if (req.method === 'HEAD') { res.end(); return; }
createReadStream(file).pipe(res);
});
server.on('error', error => {
console.error(error.code === 'EADDRINUSE' ? `Port ${port} is already in use. Set PORT in .env.` : `Unable to start server: ${error.message}`);
process.exitCode = 1;
});
server.listen(port, host, () => {
console.log(`RideBus ${dev ? 'development' : 'PWA'}: http://${host}:${port}`);
console.log(`RideBus API: ${process.env.RIDEBUS_API_KEY ? 'configured' : 'add RIDEBUS_API_KEY to .env'}`);
});
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
start().catch(error => { console.error(error.message); process.exitCode = 1; });
}