Files
TFI-Live-G2/worker/nta-proxy.js

167 lines
4.8 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 stopIds = uniqueQueryValues(url.searchParams, 'stopId')
const stopCodes = uniqueQueryValues(url.searchParams, 'stopCode')
if (!stopIds.length && !stopCodes.length) {
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, stopIds, stopCodes),
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, 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))]
}
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',
}
}