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