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; }); }