Build TFI Live G2 deployable app
This commit is contained in:
188
server.mjs
Normal file
188
server.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user