194 lines
5.5 KiB
JavaScript
194 lines
5.5 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 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'
|
|
}
|
|
}
|