Files
TFI-Live-G2/worker/nta-proxy.js
2026-06-01 16:48:03 +01:00

162 lines
4.6 KiB
JavaScript

const DEFAULT_TRIP_UPDATES_URL = 'https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json'
const CACHE_SECONDS = 25
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders() })
}
try {
if (url.pathname === '/health') {
return json({ ok: true })
}
if (url.pathname === '/stops') {
return fetchStops(env)
}
if (url.pathname === '/departures') {
return fetchDepartures(url, env, ctx)
}
return json({ error: 'Not found' }, 404)
} catch (error) {
return json({ error: error instanceof Error ? error.message : 'Unknown error' }, 500)
}
},
}
async function fetchStops(env) {
if (!env.STOPS_URL) {
return json({ stops: [], error: 'STOPS_URL is not configured' }, 503)
}
const response = await fetch(env.STOPS_URL, {
headers: { Accept: 'application/json' },
cf: { cacheTtl: 3600, cacheEverything: true },
})
return withCors(response)
}
async function fetchDepartures(url, env, ctx) {
const stopId = url.searchParams.get('stopId') ?? ''
const stopCode = url.searchParams.get('stopCode') ?? ''
if (!stopId && !stopCode) {
return json({ departures: [], error: 'stopId or stopCode is required' }, 400)
}
if (!env.NTA_API_KEY) {
return json({ departures: [], error: 'NTA_API_KEY is not configured' }, 503)
}
const feed = await getTripUpdateFeed(env, ctx)
return json({
departures: parseTripUpdates(feed, stopId, stopCode),
updatedAt: Date.now(),
})
}
async function getTripUpdateFeed(env, ctx) {
const endpoint = env.NTA_TRIP_UPDATES_TARGET || DEFAULT_TRIP_UPDATES_URL
const cache = caches.default
const cacheKey = new Request(endpoint)
const cached = await cache.match(cacheKey)
if (cached) {
return cached.json()
}
const response = await fetch(endpoint, {
headers: {
Accept: 'application/json',
'Ocp-Apim-Subscription-Key': env.NTA_API_KEY,
'x-api-key': env.NTA_API_KEY,
},
})
if (!response.ok) {
throw new Error(`NTA returned ${response.status}`)
}
const payload = await response.json()
const cacheResponse = json(payload, 200, {
'Cache-Control': `public, max-age=${CACHE_SECONDS}`,
})
ctx.waitUntil(cache.put(cacheKey, cacheResponse.clone()))
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)
}
function json(body, status = 200, headers = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
...corsHeaders(),
...headers,
},
})
}
function withCors(response) {
const headers = new Headers(response.headers)
Object.entries(corsHeaders()).forEach(([key, value]) => headers.set(key, value))
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Accept, Content-Type',
}
}