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', } }