162 lines
4.6 KiB
JavaScript
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',
|
|
}
|
|
}
|