const http = require('http'); const path = require('path'); const Koa = require('koa'); const Router = require('@koa/router'); const serve = require('koa-static'); const { WebSocketServer, OPEN } = require('ws'); const P2000 = require('./src/p2000'); const { geocode, messageToQuery } = require('./src/geocoder'); const PORT = Number(process.env.PORT) || 10000; const MAX_HISTORY = 500; // ── Koa app ────────────────────────────────────────────────────────────────── const app = new Koa(); const router = new Router(); app.use(serve(path.join(__dirname, 'public'))); router.get('/health', ctx => { ctx.body = { status: 'ok', uptime: process.uptime(), messages: history.length }; }); // Paginated history — ?offset=0&limit=30&type=all router.get('/api/messages', ctx => { const offset = Math.max(0, parseInt(ctx.query.offset) || 0); const limit = Math.min(100, Math.max(1, parseInt(ctx.query.limit) || 30)); const type = ctx.query.type || 'all'; const pool = type === 'all' ? history : history.filter(m => m.type === type); ctx.body = { messages: pool.slice(offset, offset + limit), total: pool.length, offset, limit, }; }); // Geocode a message by id — proxies Nominatim so the client never hits it directly router.get('/api/geocode', async ctx => { const msg = history.find(m => m.id === ctx.query.id); if (!msg) { ctx.status = 404; ctx.body = { error: 'not found' }; return; } const query = messageToQuery(msg); if (!query) { ctx.body = { coords: null }; return; } const coords = await geocode(query); ctx.body = { coords, query }; }); app.use(router.routes()).use(router.allowedMethods()); // ── HTTP server ─────────────────────────────────────────────────────────────── const server = http.createServer(app.callback()); // ── WebSocket server ────────────────────────────────────────────────────────── const wss = new WebSocketServer({ server, path: '/ws' }); const history = []; function broadcast(payload) { const data = JSON.stringify(payload); for (const client of wss.clients) { if (client.readyState === OPEN) client.send(data); } } wss.on('connection', ws => { // Send existing history so the UI isn't blank on first load ws.send(JSON.stringify({ type: 'history', messages: history })); ws.on('error', err => console.error('[ws] client error:', err.message)); }); // ── P-2000 poller ───────────────────────────────────────────────────────────── const p2000 = new P2000(); p2000.on('batch', messages => { history.push(...messages); if (history.length > MAX_HISTORY) history.splice(0, history.length - MAX_HISTORY); broadcast({ type: 'history', messages: history }); }); p2000.on('message', msg => { history.unshift(msg); if (history.length > MAX_HISTORY) history.pop(); broadcast({ type: 'message', message: msg }); }); p2000.start(); // ── Start ───────────────────────────────────────────────────────────────────── server.listen(PORT, () => { console.log(`[server] Listening on http://localhost:${PORT}`); }); // Graceful shutdown process.on('SIGINT', () => { p2000.stop(); server.close(() => process.exit(0)); }); process.on('SIGTERM', () => { p2000.stop(); server.close(() => process.exit(0)); });