- Koa server on port 10000 with WebSocket live feed from 112-nu.nl RSS - PM2 watch mode for auto-restart on file changes - Dark navy UI with per-type color accents (fire/ambulance/police/rescue) - Slide-in filter panel with service type + 12 Dutch province filters - Card click opens detail modal: parsed priority (A1/A2/MGS), vehicle number, rit/bon number, alarm type, meldkamer, and eenheden - Server-side Nominatim geocoder (cached, rate-limited) powering an interactive Leaflet/OpenStreetMap map in the modal (CartoDB Voyager tiles) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
96 lines
3.8 KiB
JavaScript
96 lines
3.8 KiB
JavaScript
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)); });
|