import { createTransitHistory } from './transitHistory.js'; /** * GTFS-Realtime transit proxy for the Transit data layer. * * Only URLs registered in `src/data/transitFeeds.js` are ever fetched — the * browser names a registered id, never a URL. Pure request/cache mechanics * live in `src/data/transitProxy.js` so the offline suite covers them. */ import { coalesceProxyRequest, readResponseBytesCapped, } from './common/http.js'; import { makeRateLimiter } from './common/rate-limit.js'; import { publicTransitCatalog } from '../../src/data/transitFeeds.js'; import { TRANSIT_ADMISSION_MAX_GLOBAL, TRANSIT_ADMISSION_MAX_PER_FEED, TRANSIT_ADMISSION_WINDOW_MS, TRANSIT_MAX_REDIRECTS, TRANSIT_PROXY_MAX_BODY_BYTES, TRANSIT_PROXY_TIMEOUT_MS, TRANSIT_PROXY_TTL_MS, TransitFeedShapeError, buildTransitSnapshot, isAcceptableTransitUpstreamUrl, isTransitRedirectStatus, nextTransitBackoffMs, resolveTransitRoute, transitCacheState, transitRedirectDecision, transitResponseHeaders, transitUpstreamHeaders, } from '../../src/data/transitProxy.js'; /** * Fetch a registered feed, resolving each redirect hop BEFORE following it. * * `redirect: 'follow'` hands the hop to the runtime and only lets us inspect * where it landed — by which point a disallowed host has already been * contacted and has already answered. Each hop is therefore requested with * `redirect: 'manual'` and its `Location` checked against the feed's own https * origin first, the rule the CCTV frame proxy applies to camera images. * * @param {object} feed Registry entry. * @param {AbortSignal} signal Abort signal for the whole hop chain. * @param {typeof fetch} [fetchImpl] Injected for tests. * @param {{etag?: string|null, lastModified?: string|null}} [validators] Cached validators. * @returns {Promise<{response: Response, finalUrl: string}>} */ export async function fetchTransitFeed( feed, signal, fetchImpl = fetch, validators = null, ) { let current = feed.url; for (let hop = 0; hop <= TRANSIT_MAX_REDIRECTS; hop += 1) { const response = await fetchImpl(current, { signal, headers: transitUpstreamHeaders(feed, validators), redirect: 'manual', }); // Only a real redirect is followed. A 304 is the successful answer to our // own conditional request and has no Location; sending it round this loop // fails a feed that is simply unchanged. if (!isTransitRedirectStatus(response?.status)) { return { response, finalUrl: current }; } const decision = transitRedirectDecision( feed.url, current, response.headers.get('location'), ); try { await response.body?.cancel(); } catch { /* no-op */ } if (!decision.ok) throw new Error(`upstream ${decision.reason}`); if (hop === TRANSIT_MAX_REDIRECTS) { throw new Error('upstream redirected too many times'); } current = decision.url; } throw new Error('upstream redirected too many times'); } /** * Vite plugin: GTFS-Realtime VehiclePositions proxy for the Transit layer. * * GET /api/transit/feeds → public catalog (coverage + credit) * GET /api/transit/vehicles/ → decoded snapshot as JSON * * Only URLs in `src/data/transitFeeds.js` are ever fetched — the browser * names a registered id, never a URL (SECURITY.md). Redirects are resolved hop * by hop and must stay on the feed's own https origin. Bytes are capped at * TRANSIT_PROXY_MAX_BODY_BYTES and decoded server-side, so the browser never * parses protobuf. Per feed: 15 s memory cache, single-flight refresh, and * serve-stale-on-failure for up to 10 minutes (the launch-library pattern). * No disk cache — transit positions are worthless after a few minutes. * * Two backstops sit in front of every upstream request. A fixed-window * admission limiter bounds how often this process may ask any one operator, * and all of them together, no matter how many browser tabs are open. A * per-feed cooldown ladder then keeps a feed that is genuinely down from being * re-asked once per poll for a whole session: consecutive failures push the * next permitted attempt out to five minutes, and one success resets it. * * @param {{fetchImpl?: typeof fetch}} [options] * @returns {object} Vite plugin. */ export function transitProxy({ fetchImpl = fetch } = {}) { /** @type {Map} feedId → snapshot */ const cache = new Map(); const history = createTransitHistory(); const admitHistory = makeRateLimiter({ windowMs: 60000, max: 30, globalMax: 120, }); let closed = false; const inFlight = new Map(); const controllers = new Set(); /** @type {Map} */ const cooldown = new Map(); const admit = makeRateLimiter({ windowMs: TRANSIT_ADMISSION_WINDOW_MS, max: TRANSIT_ADMISSION_MAX_PER_FEED, globalMax: TRANSIT_ADMISSION_MAX_GLOBAL, }); const ttlSeconds = Math.ceil(TRANSIT_PROXY_TTL_MS / 1000); function send(res, status, body, headers) { res.writeHead(status, headers); res.end(body); } function noteFailure(feedId, reason, now) { const previous = cooldown.get(feedId); const failures = (previous?.failures || 0) + 1; const wait = nextTransitBackoffMs(failures); cooldown.set(feedId, { failures, nextAttemptAt: now + wait, reason }); return wait; } async function refresh(feed, now) { const controller = new AbortController(); controllers.add(controller); const timeout = setTimeout( () => controller.abort(), TRANSIT_PROXY_TIMEOUT_MS, ); const previous = cache.get(feed.id); try { const { response, finalUrl } = await fetchTransitFeed( feed, controller.signal, fetchImpl, previous ? { etag: previous.etag, lastModified: previous.lastModified } : null, ); if (closed) throw new Error('Transit provider closed'); if (!isAcceptableTransitUpstreamUrl(finalUrl)) { throw new Error('upstream is not https'); } // Nothing changed upstream: keep the snapshot we have and restart its // freshness window rather than asking for a body we already hold. if (response.status === 304 && previous) { try { await response.body?.cancel(); } catch { /* no-op */ } // `at` restarts the freshness window; `contactedAt` records that the // operator answered just now. They are different facts: the body still // carries the fetch time of the payload it holds, and reading that as // "when did we last hear from them" would age a feed that is answering // every single request into silence. const revalidated = { ...previous, at: now, contactedAt: now }; cache.set(feed.id, revalidated); cooldown.delete(feed.id); return revalidated; } if (!response.ok) { const error = new Error(`upstream HTTP ${response.status}`); error.upstreamStatus = response.status; throw error; } const bytes = await readResponseBytesCapped( response, TRANSIT_PROXY_MAX_BODY_BYTES, ); const snapshot = buildTransitSnapshot(feed, bytes, now); if (closed) throw new Error('Transit provider closed'); history.ingest(feed, snapshot.vehicles, Date.now()); const entry = { at: snapshot.fetchedAt, contactedAt: snapshot.fetchedAt, body: JSON.stringify(snapshot), host: new URL(finalUrl).hostname, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified'), }; cache.set(feed.id, entry); cooldown.delete(feed.id); return entry; } finally { clearTimeout(timeout); controller.abort(); controllers.delete(controller); } } function install(middlewares) { middlewares.use('/api/transit', async (req, res) => { if (req.method !== 'GET') { send( res, 405, JSON.stringify({ error: 'Method Not Allowed' }), transitResponseHeaders('NONE'), ); return; } const route = resolveTransitRoute(req.url); if (!route) { send(res, 404, JSON.stringify({ error: 'Unknown transit feed' }), { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }); return; } if (route.route === 'feeds') { send(res, 200, JSON.stringify({ feeds: publicTransitCatalog() }), { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=3600', }); return; } const { feed } = route; const now = Date.now(); if (route.route === 'trail') { if (!admitHistory(feed.id)) { send( res, 429, JSON.stringify({ error: 'Transit history read limit reached' }), { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Retry-After': '60', }, ); return; } send( res, 200, JSON.stringify(history.get(feed.id, route.vehicleId, now)), { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, ); return; } const cached = cache.get(feed.id); const state = transitCacheState(cached, now); if (state === 'fresh') { send( res, 200, cached.body, transitResponseHeaders('HIT', cached.host, cached.contactedAt), ); return; } // Answer without contacting the operator: the cached snapshot when there // is one, otherwise an honest "unavailable, come back in N seconds". const answerWithoutUpstream = (reason, retryAfterS) => { if (state === 'stale' && cached) { // The contact time stays as old as it is: a feed being served from // cache during an outage has NOT answered, and saying otherwise // would keep a dead feed on the globe. send( res, 200, cached.body, transitResponseHeaders( 'STALE-ERROR', cached.host, cached.contactedAt, ), ); return; } send( res, 503, JSON.stringify({ error: 'Transit feed unavailable', feedId: feed.id, retryInSec: retryAfterS, }), { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-GEV-Cache': 'NONE', 'Retry-After': String(retryAfterS), 'X-Transit-Backoff': reason, }, ); }; const waiting = cooldown.get(feed.id); if (waiting && waiting.nextAttemptAt > now && !inFlight.has(feed.id)) { answerWithoutUpstream( 'cooldown', Math.max(1, Math.ceil((waiting.nextAttemptAt - now) / 1000)), ); return; } if (!inFlight.has(feed.id) && !admit(feed.id)) { answerWithoutUpstream('rate-limited', ttlSeconds); return; } const request = coalesceProxyRequest(inFlight, feed.id, () => refresh(feed, now), ); try { const fresh = await request.promise; send( res, 200, fresh.body, transitResponseHeaders( request.shared ? 'INFLIGHT' : 'MISS', fresh.host, fresh.contactedAt, ), ); } catch (error) { const differential = error instanceof TransitFeedShapeError; // A shape fault condemns what we already hold. The cached copy was // decoded under the assumption this feed is a full snapshot, and that // assumption has just been shown false — leaving it in place would let // the very next request serve it back as a 200. if (differential) cache.delete(feed.id); if (!request.shared) { const wait = noteFailure( feed.id, error?.message || String(error), Date.now(), ); console.warn( `[transit-proxy] ${feed.id} unavailable: ${error?.message || error} — next attempt in ${Math.round(wait / 1000)}s`, ); } // A differential feed is a shape fault, not an outage: serving the old // snapshot would hide a feed this decoder must not render at all. if (state === 'stale' && cached && !differential) { // The contact time is the last SUCCESSFUL one. This request failed, // so it does not advance — otherwise a dead feed would look like one // answering on time. send( res, 200, cached.body, transitResponseHeaders( 'STALE-ERROR', cached.host, cached.contactedAt, ), ); return; } const pending = cooldown.get(feed.id); const retryAfterS = pending ? Math.max(1, Math.ceil((pending.nextAttemptAt - Date.now()) / 1000)) : ttlSeconds; send( res, differential || error?.code === 'RESPONSE_TOO_LARGE' || Number.isInteger(error?.upstreamStatus) ? 502 : 504, JSON.stringify({ error: differential ? 'Transit feed is differential and is not supported' : 'Transit feed unavailable', feedId: feed.id, retryInSec: retryAfterS, }), { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-GEV-Cache': 'NONE', 'Retry-After': String(retryAfterS), }, ); } }); } function close() { closed = true; for (const controller of controllers) controller.abort(); controllers.clear(); history.clear(); cache.clear(); cooldown.clear(); } return { closeBundle: close, name: 'transit-proxy', configureServer(server) { install(server.middlewares); server.httpServer?.once('close', close); }, configurePreviewServer(server) { install(server.middlewares); server.httpServer?.once('close', close); }, }; }