Files
TFI-Live-G2/server.mjs
2026-06-01 16:48:03 +01:00

189 lines
5.3 KiB
JavaScript

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 stopId = url.searchParams.get('stopId') ?? ''
const stopCode = url.searchParams.get('stopCode') ?? ''
if (!stopId && !stopCode) {
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, stopId, stopCode),
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, stopId, stopCode) {
const entities = Array.isArray(payload?.entity) ? payload.entity : []
const departures = []
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 (updateStopId !== stopId && updateStopId !== stopCode) {
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)
}
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'
}
}