import { createReadStream, existsSync, statSync } from 'node:fs' import { extname, join, normalize, resolve } from 'node:path' import { createServer } from 'node:http' const root = resolve('dist') const port = Number(process.env.PORT ?? 3000) const tripUpdatesUrl = process.env.NTA_TRIP_UPDATES_TARGET ?? 'https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json' const cacheMs = Number(process.env.NTA_CACHE_MS ?? 25_000) let feedCache = null createServer(async (request, response) => { try { const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`) if (request.method === 'OPTIONS') { send(response, 204, '', corsHeaders()) return } if (request.method !== 'GET') { sendJson(response, 405, { error: 'Method not allowed' }) return } if (url.pathname === '/health') { sendJson(response, 200, { ok: true }) return } if (url.pathname === '/departures') { await handleDepartures(url, response) return } await serveStatic(url.pathname, response) } catch (error) { sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown error' }) } }).listen(port, '0.0.0.0', () => { console.log(`TFI Live G2 listening on ${port}`) }) async function handleDepartures(url, response) { const stopIds = uniqueQueryValues(url.searchParams, 'stopId') const stopCodes = uniqueQueryValues(url.searchParams, 'stopCode') if (!stopIds.length && !stopCodes.length) { sendJson(response, 400, { departures: [], error: 'stopId or stopCode is required' }) return } const apiKey = process.env.NTA_API_KEY if (!apiKey) { sendJson(response, 503, { departures: [], error: 'NTA_API_KEY is not configured' }) return } const feed = await getTripUpdateFeed(apiKey) sendJson(response, 200, { departures: parseTripUpdates(feed, stopIds, stopCodes), updatedAt: Date.now(), }) } async function getTripUpdateFeed(apiKey) { if (feedCache && Date.now() - feedCache.updatedAt < cacheMs) { return feedCache.payload } const response = await fetch(tripUpdatesUrl, { headers: { Accept: 'application/json', 'Ocp-Apim-Subscription-Key': apiKey, 'x-api-key': apiKey, }, }) if (!response.ok) { throw new Error(`NTA returned ${response.status}`) } const payload = await response.json() feedCache = { payload, updatedAt: Date.now() } return payload } function parseTripUpdates(payload, stopIds, stopCodes) { const entities = Array.isArray(payload?.entity) ? payload.entity : [] const departures = [] const stopIdSet = new Set([...stopIds, ...stopCodes]) for (const entity of entities) { const tripUpdate = entity.tripUpdate || entity.trip_update || {} const trip = tripUpdate.trip || {} const route = trip.routeId || trip.route_id || 'Route' const headsign = trip.tripHeadsign || trip.trip_headsign || 'Destination' const updates = tripUpdate.stopTimeUpdate || tripUpdate.stop_time_update || [] for (const update of updates) { const updateStopId = update.stopId || update.stop_id if (!stopIdSet.has(updateStopId)) { continue } const departure = update.departure || update.arrival || {} const epoch = Number(departure.time) if (!epoch) { continue } const millis = epoch < 10_000_000_000 ? epoch * 1000 : epoch if (millis < Date.now() - 120_000) { continue } departures.push({ id: `${entity.id || route}-${millis}`, route, destination: headsign, dueMinutes: Math.max(0, Math.round((millis - Date.now()) / 60_000)), scheduledTime: new Date(millis).toLocaleTimeString('en-IE', { hour: '2-digit', minute: '2-digit', }), realtime: true, status: update.scheduleRelationship || update.schedule_relationship || undefined, }) } } return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40) } function uniqueQueryValues(params, key) { return [...new Set(params.getAll(key).flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean))] } async function serveStatic(pathname, response) { const cleanPath = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '') let filePath = join(root, cleanPath === '/' ? 'index.html' : cleanPath) if (!filePath.startsWith(root)) { send(response, 403, 'Forbidden') return } if (!existsSync(filePath) || statSync(filePath).isDirectory()) { filePath = join(root, 'index.html') } response.writeHead(200, { 'Content-Type': contentType(filePath), ...corsHeaders(), }) createReadStream(filePath).pipe(response) } function sendJson(response, status, body) { send(response, status, JSON.stringify(body), { 'Content-Type': 'application/json; charset=utf-8', ...corsHeaders(), }) } function send(response, status, body, headers = {}) { response.writeHead(status, headers) response.end(body) } function corsHeaders() { return { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Accept, Content-Type', } } function contentType(filePath) { switch (extname(filePath)) { case '.html': return 'text/html; charset=utf-8' case '.js': return 'text/javascript; charset=utf-8' case '.css': return 'text/css; charset=utf-8' case '.json': return 'application/json; charset=utf-8' case '.svg': return 'image/svg+xml' default: return 'application/octet-stream' } }