feat(rail): add a Rail layer with keyless live trains

Live trains from 30 keyless operator feeds: GPS positions from 29 and
Irish Rail's station reports, shown as the operators publish them. 28
work out of the box; Renfe long and medium distance and KTMB appear once
their pack is built, because their vehicles name no route.

Packs are opt-in (scripts/build-rail-paths.mjs): the operator's own
route lines, stop names, next stops and trip-update delays for live
trains. Nothing downloads when the app starts, no train is placed from a
timetable, and no request carries a deployer's key.
This commit is contained in:
Denis Groshev committed 2026-11-11 16:06:22 +01:00
1 parent 14db73f201
commit 830c080803
114 files changed
+26639 -26

No files matched your search

+518
View File
@@ -0,0 +1,518 @@
/**
* @module railProxy
* @description Pure server-side mechanics for the `/api/rail` proxy.
*
* The upstream mechanics are the Transit proxy's (src/data/transitProxy.js):
* the same decoder, timestamp repair, redirect rule, conditional headers, cache
* windows and backoff ladder. What differs is which feeds are reachable and
* what leaves the server: only vehicles a feed's classifier recognises as
* trains, each tagged with its category, mode and position source. On top of
* that come the Irish Rail XML and SIRI-VM adapters and trip updates. Every
* train is placed where its operator reports it. Every feed is open: no
* request carries a credential.
*/
import {
TRANSIT_ADMISSION_MAX_GLOBAL,
TRANSIT_PROXY_MAX_BODY_BYTES,
TRANSIT_PROXY_STALE_MAX_MS,
TRANSIT_PROXY_TTL_MS,
TransitFeedShapeError,
buildTransitSnapshot,
} from './transitProxy.js';
import { GTFS_INCREMENTALITY_FULL_DATASET } from './gtfsRealtime.js';
import {
RAIL_FEED_REGISTRY,
classifyTrain,
getRailFeed,
railFeedHasTripUpdates,
railFeedPositions,
} from './railFeeds.js';
import { decodeIrishRailTrains } from './irishRail.js';
import { decodeSiriVehicleMonitoring } from './railSiriVm.js';
import { decodeTripUpdates } from './railTripUpdates.js';
import { isNoTrainPositionPoint } from './railNoPositions.js';
/**
* Text formats with their own adapter, by registry `format`. Each turns the
* document and the fetch time (epoch s) into `{entityCount, truncated,
* vehicles, feedTimestamp?}`.
*/
const TEXT_ADAPTERS = Object.freeze({
'irish-rail-xml': decodeIrishRailTrains,
'siri-vm': decodeSiriVehicleMonitoring,
});
export {
TRANSIT_ADMISSION_MAX_PER_FEED as RAIL_ADMISSION_MAX_PER_FEED,
TRANSIT_ADMISSION_WINDOW_MS as RAIL_ADMISSION_WINDOW_MS,
TRANSIT_PROXY_TIMEOUT_MS as RAIL_PROXY_TIMEOUT_MS,
TransitFeedShapeError as RailFeedShapeError,
isAcceptableTransitUpstreamUrl as isAcceptableRailUpstreamUrl,
nextTransitBackoffMs as nextRailBackoffMs,
} from './transitProxy.js';
/**
* Largest path or schedule pack the proxy serves or reads (bytes). The
* biggest national packs built so far were 14 to 16 MB; anything far beyond
* that is a build fault, not a pack to ship. A pack over the cap is refused
* with its own answer, not taken for a missing one.
*/
export const RAIL_PATH_PACK_MAX_BYTES = 24 * 1024 * 1024;
/**
* Default freshness window for trip updates. Predictions move more slowly
* than positions, and several operators refresh them every 30 to 60 s.
*/
export const RAIL_TRIP_UPDATES_TTL_MS = 30_000;
/** Shortest window a registry entry may ask for. */
export const RAIL_MIN_TTL_MS = 5_000;
/** Trips one `/updates` answer carries, after the rail filter. */
export const RAIL_UPDATES_MAX_TRIPS = 3_000;
/** Stop updates one `/updates` answer carries, across all its trips. */
export const RAIL_UPDATES_MAX_STOPS = 40_000;
/**
* Freshness window for one route of a feed: its own interval when the
* registry sets one (never below RAIL_MIN_TTL_MS), else the default.
* @param {object} feed
* @param {'trains'|'updates'} route
* @returns {number} ms
*/
export function railFeedTtlMs(feed, route = 'trains') {
const own =
route === 'updates' ? feed?.tripUpdatesIntervalMs : feed?.pollIntervalMs;
if (Number.isFinite(own) && own > 0) return Math.max(RAIL_MIN_TTL_MS, own);
return route === 'updates' ? RAIL_TRIP_UPDATES_TTL_MS : TRANSIT_PROXY_TTL_MS;
}
/**
* Largest byte cap a feed may set for itself (`maxBodyBytes`): nothing
* larger is read into memory.
*/
export const RAIL_PROXY_MAX_BODY_CEILING_BYTES = 64 * 1024 * 1024;
/**
* Upstream byte cap for a feed: its own cap when it sets one (never above
* RAIL_PROXY_MAX_BODY_CEILING_BYTES), else the Transit proxy's 8 MiB.
* @param {object} feed
* @returns {number}
*/
export function railMaxBodyBytes(feed) {
const own = feed?.maxBodyBytes;
return Number.isFinite(own) && own > 0
? Math.min(own, RAIL_PROXY_MAX_BODY_CEILING_BYTES)
: TRANSIT_PROXY_MAX_BODY_BYTES;
}
/**
* Classify a cache entry against a freshness window: the Transit policy with
* a per-feed TTL. The serve-stale window is the same ten minutes.
* @param {{at: number}|null|undefined} entry
* @param {number} now
* @param {number} [ttlMs]
* @returns {'none'|'fresh'|'stale'|'expired'}
*/
export function railCacheStateFor(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) {
if (!entry || !Number.isFinite(entry.at)) return 'none';
const age = now - entry.at;
if (age < ttlMs) return 'fresh';
if (age < Math.max(TRANSIT_PROXY_STALE_MAX_MS, ttlMs)) return 'stale';
return 'expired';
}
/**
* Resolve `/trains/<feedId>`, `/paths/<feedId>`, `/schedule/<feedId>` or
* `/updates/<feedId>` (the path after the `/api/rail` mount) to a pollable
* feed. Anything else (another route, an unknown or disabled id, an
* `/updates` request for a feed with no trip updates, path tricks) resolves
* to null and the caller 404s.
* @param {string} url Request URL relative to the mount point.
* @returns {{ route: 'feeds' } |
* { route: 'trains'|'paths'|'schedule'|'updates', feed: object } | null}
*/
export function resolveRailRoute(url) {
const pathname = String(url || '').split('?')[0];
if (pathname === '/feeds' || pathname === '/feeds/')
return { route: 'feeds' };
const match = /^\/(trains|paths|schedule|updates)\/([^/]+)\/?$/.exec(
pathname,
);
if (!match) return null;
let id;
try {
id = decodeURIComponent(match[2]);
} catch {
return null;
}
const feed = getRailFeed(id);
if (!feed) return null;
if (match[1] === 'updates' && !railFeedHasTripUpdates(feed)) return null;
return { route: match[1], feed };
}
/**
* Classify decoded vehicles and keep only trains. A position where no train
* is placed (src/data/railNoPositions.js: Ukraine) is dropped first, so it
* is only counted and never leaves the server.
*/
function keepTrains(feed, vehicles, routeFor = null) {
const positionSource = railFeedPositions(feed) === 'event' ? 'event' : 'gps';
const trains = [];
for (const vehicle of vehicles) {
if (isNoTrainPositionPoint(vehicle.lat, vehicle.lon)) continue;
const routeId = resolvedRouteId(feed, vehicle, routeFor);
const kind = classifyTrain(feed, routeId, { tripId: vehicle.tripId });
if (!kind) continue;
trains.push({
...vehicle,
routeId,
category: kind.category,
mode: kind.mode,
...(kind.tourist ? { tourist: true } : {}),
positionSource,
});
}
return trains;
}
/**
* The route a record is classified by. A feed with `routesFrom: 'schedule'`
* publishes trips whose route is missing or not the one its classifier reads;
* `routeFor(tripId)` answers from the feed's schedule pack, whose route ids
* the build wrote to carry the product. A trip the pack does not know keeps
* what the feed said, which its classifier then drops.
*/
function resolvedRouteId(feed, record, routeFor) {
if (feed?.routesFrom !== 'schedule' || typeof routeFor !== 'function')
return record.routeId;
return (record.tripId && routeFor(record.tripId)) || record.routeId;
}
/**
* For a feed whose positions name each train by its number
* (`positionsByTrainNumber`, Irish Rail's train codes), the record takes
* the schedule pack's trip that number is running now (`tripFor`, given the
* number and the record, whose stated delay moves its timetable), so the
* train is drawn on its line and its card lists its stops; the number stays
* as `trainNumber`. A number the timetable does not place keeps its own id.
*/
function byTrainNumber(feed, vehicles, tripFor) {
if (feed?.positionsByTrainNumber !== true || typeof tripFor !== 'function')
return vehicles;
return vehicles.map((vehicle) => {
const tripId = vehicle.tripId ? tripFor(vehicle.tripId, vehicle) : null;
return tripId
? { ...vehicle, tripId, trainNumber: vehicle.tripId }
: vehicle;
});
}
/**
* For a feed whose header clock cannot be trusted (`headerClock:
* 'untrusted'`, Renfe: local time written as UTC), a time the Transit
* ladder took from the header is replaced by the fetch time and labelled so.
*/
function distrustHeaderClock(feed, vehicles, now) {
if (feed?.headerClock !== 'untrusted') return vehicles;
const fetchedS = Math.floor(now / 1000);
return vehicles.map((vehicle) =>
vehicle.timestampSource === 'header'
? { ...vehicle, timestamp: fetchedS, timestampSource: 'fetch' }
: vehicle,
);
}
/**
* An upstream answer that is no document of the feed's: a failed fetch, not
* a reason to distrust the last good snapshot, which the service keeps
* serving while it is young enough (`code: 'UPSTREAM_UNREADABLE'`, a 502).
* @param {string} detail
* @returns {Error & {code: string}}
*/
function unreadableUpstream(detail) {
const failure = new Error(`upstream document unreadable: ${detail}`);
failure.code = 'UPSTREAM_UNREADABLE';
return failure;
}
/**
* Run a GTFS-RT decode and refuse what is not a FeedMessage: bytes protobuf
* cannot read (an HTML or JSON error page served as 200), and a message
* without a FeedHeader or its gtfs_realtime_version, which every
* FeedMessage carries. An empty body (a 200 or 204 with nothing in it)
* decodes to exactly that, and would otherwise read as a full snapshot with
* no trains. A DIFFERENTIAL feed's refusal passes through as it is.
* @template {{version: string|null}} T
* @param {() => T} decode
* @returns {T}
*/
function decodeGtfsRealtime(decode) {
let decoded;
try {
decoded = decode();
} catch (error) {
if (error instanceof TransitFeedShapeError) throw error;
throw unreadableUpstream(error?.message || String(error));
}
if (decoded?.version === null || decoded?.version === undefined)
throw unreadableUpstream('no FeedHeader with a gtfs_realtime_version');
return decoded;
}
/**
* Decode upstream bytes into the JSON snapshot the Rail layer consumes.
*
* For GTFS-RT feeds, decoding, the refusal of DIFFERENTIAL feeds and the
* report-time ladder are the Transit proxy's; a body that is no FeedMessage
* (no header, no version, not protobuf) is refused as unreadable
* (decodeGtfsRealtime). Irish Rail's XML and SIRI-VM go through their
* adapters. The classifier then runs on every vehicle: a vehicle it does
* not recognise (a bus in a mixed feed, a shuttle, an out-of-service vehicle
* with no route in a mixed feed) is dropped and only counted, so the browser
* receives trains and nothing else. Every train carries `positionSource`:
* `gps` for GTFS-RT positions, `event` for signalling events.
*
* @param {object} feed Rail registry entry.
* @param {Uint8Array|ArrayBuffer} bytes Raw upstream body.
* @param {number} [now=Date.now()] Fetch time (ms epoch).
* @param {{routeFor?: Function|null, tripFor?: Function|null}} [lookups]
* `routeFor` resolves a trip's route from the schedule pack, and
* `tripFor(number, record)` a train number's running trip
* (`positionsByTrainNumber`).
* @returns {{ feedId: string, name: string, fetchedAt: number, feedTimestamp: number|null,
* version: string|null, entityCount: number, truncated: boolean, count: number,
* dropped: number, positions: string, trains: object[] }}
*/
export function buildRailSnapshot(
feed,
bytes,
now = Date.now(),
{ routeFor = null, tripFor = null } = {},
) {
const adapter = Object.hasOwn(TEXT_ADAPTERS, feed?.format)
? TEXT_ADAPTERS[feed.format]
: null;
if (adapter) {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const text = new TextDecoder('utf-8').decode(view);
let decoded;
try {
decoded = adapter(text, Math.floor(now / 1000));
} catch (error) {
// An unreadable document (an error page served as 200, a truncated
// body) is a failed fetch, not a reason to distrust the last good one.
throw unreadableUpstream(error?.message || String(error));
}
const trains = keepTrains(
feed,
byTrainNumber(feed, decoded.vehicles, tripFor),
routeFor,
);
return {
feedId: feed.id,
name: feed.name,
fetchedAt: now,
feedTimestamp: Number.isFinite(decoded.feedTimestamp)
? decoded.feedTimestamp
: null,
version: null,
entityCount: decoded.entityCount,
truncated: decoded.truncated,
count: trains.length,
dropped: decoded.vehicles.length - trains.length,
positions: railFeedPositions(feed),
trains,
};
}
const decoded = decodeGtfsRealtime(() =>
buildTransitSnapshot(feed, bytes, now),
);
const trains = keepTrains(
feed,
distrustHeaderClock(feed, decoded.vehicles, now),
routeFor,
);
return {
feedId: decoded.feedId,
name: decoded.name,
fetchedAt: decoded.fetchedAt,
feedTimestamp:
feed.headerClock === 'untrusted' ? null : decoded.feedTimestamp,
version: decoded.version,
entityCount: decoded.entityCount,
truncated: decoded.truncated,
count: trains.length,
dropped: decoded.vehicles.length - trains.length,
positions: railFeedPositions(feed),
trains,
};
}
/**
* Decode a TripUpdates feed into the `/api/rail/updates/<feedId>` body: one
* record per rail trip, filtered with the same classifier as the positions
* (a feed's buses never leave the server), capped in trips and stops.
*
* A DIFFERENTIAL feed is refused, for the same reason positions are: an
* unmentioned trip would read as one with no prediction. A body that is no
* FeedMessage is refused as unreadable (decodeGtfsRealtime).
*
* @param {object} feed Rail registry entry.
* @param {Uint8Array|ArrayBuffer} bytes Raw GTFS-RT FeedMessage.
* @param {number} [now=Date.now()] Fetch time (ms epoch).
* @param {{routeFor?: ((tripId: string) => string|null)|null}} [lookups]
* `routeFor` is the schedule pack's trip-to-route lookup, for a feed
* whose trips do not carry the route its classifier reads (`routesFrom:
* 'schedule'`).
* @returns {{ feedId: string, name: string, fetchedAt: number,
* feedTimestamp: number|null, entityCount: number, truncated: boolean,
* count: number, dropped: number, trips: object[] }}
*/
export function buildRailTripUpdatesSnapshot(
feed,
bytes,
now = Date.now(),
{ routeFor = null } = {},
) {
const decoded = decodeGtfsRealtime(() => decodeTripUpdates(bytes));
if (decoded.incrementality !== GTFS_INCREMENTALITY_FULL_DATASET) {
throw new TransitFeedShapeError(
`feed is differential (incrementality ${decoded.incrementality})`,
'differential',
);
}
const trips = [];
let truncated = decoded.truncated;
let stops = 0;
let rail = 0;
for (const trip of decoded.trips) {
const routeId = resolvedRouteId(feed, trip, routeFor);
const kind = classifyTrain(feed, routeId, { tripId: trip.tripId });
if (!kind) continue;
rail += 1;
if (
trips.length >= RAIL_UPDATES_MAX_TRIPS ||
stops + trip.stops.length > RAIL_UPDATES_MAX_STOPS
) {
truncated = true;
continue;
}
stops += trip.stops.length;
trips.push({
...trip,
routeId,
category: kind.category,
mode: kind.mode,
...(kind.tourist ? { tourist: true } : {}),
});
}
return {
feedId: feed.id,
name: feed.name,
fetchedAt: now,
feedTimestamp: decoded.timestamp,
entityCount: decoded.entityCount,
truncated,
count: trips.length,
dropped: decoded.trips.length - rail,
trips,
};
}
/**
* Upstream request headers. Same obligations as Transit's (conditional
* validators, gzip, a User-Agent that says who is asking, any identify-yourself
* header the registry names), with this proxy's own name in the User-Agent.
* A feed's own `headers` (an Accept for an XML feed) come last.
* @param {object} feed Registry entry.
* @param {{etag?: string|null, lastModified?: string|null}} [validators]
* @returns {Record<string, string>}
*/
export function railUpstreamHeaders(feed, validators = null) {
return {
'User-Agent':
'gods-eye-view-rail-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
Accept: 'application/x-protobuf, application/octet-stream;q=0.9, */*;q=0.1',
'Accept-Encoding': 'gzip',
...(validators?.etag ? { 'If-None-Match': validators.etag } : {}),
...(validators?.lastModified
? { 'If-Modified-Since': validators.lastModified }
: {}),
...(feed?.headers || {}),
};
}
/**
* Upstream requests one minute may need when every feed in the registry is
* polled at once: one per freshness window for each feed's positions and
* trip updates.
* @param {ReadonlyArray<object>} [feeds]
* @returns {number}
*/
export function railNaturalRequestsPerMinute(feeds = RAIL_FEED_REGISTRY) {
let total = 0;
for (const feed of feeds) {
if (feed?.defaultEnabled !== true) continue;
if (feed.url) total += Math.ceil(60_000 / railFeedTtlMs(feed, 'trains'));
if (railFeedHasTripUpdates(feed))
total += Math.ceil(60_000 / railFeedTtlMs(feed, 'updates'));
}
return total;
}
/**
* The process-wide admission cap (requests per admission window, all
* operators): the natural load of every enabled feed, half again on top for
* retries on the backoff ladder, never below the Transit proxy's own cap
* (TRANSIT_ADMISSION_MAX_GLOBAL, 40). It is a backstop against a bug or a
* runaway client, not the protection an operator relies on: that is each
* feed's freshness window and the per-feed admission count. Transit's fixed
* cap alone sat below the natural load once the registry passed about ten
* feeds polled every 15 s, so a viewer of several regions got 503s from a
* healthy proxy.
* @param {ReadonlyArray<object>} [feeds]
* @returns {number}
*/
export function railAdmissionGlobalMax(feeds = RAIL_FEED_REGISTRY) {
return Math.max(
TRANSIT_ADMISSION_MAX_GLOBAL,
Math.ceil(railNaturalRequestsPerMinute(feeds) * 1.5),
);
}
/**
* Response headers for a snapshot. `X-GEV-Cache` mirrors the other proxies
* (HIT / MISS / INFLIGHT / STALE-ERROR); `X-Rail-Contact` is when the operator
* last ANSWERED, which a revalidated (304) feed advances without changing the
* body's own fetch time. `max-age` is what is left of the feed's own
* freshness window once the snapshot is `ageMs` old, so a HIT near the end
* of the window is not kept a whole window more downstream.
* @param {'HIT'|'MISS'|'INFLIGHT'|'STALE-ERROR'} cacheState
* @param {string} [upstreamHost]
* @param {number|null} [contactedAt]
* @param {number} [ttlMs]
* @param {number} [ageMs] How old the snapshot is (ms); 0 for a fresh one.
* @returns {Record<string, string>}
*/
export function railResponseHeaders(
cacheState,
upstreamHost = '',
contactedAt = null,
ttlMs = TRANSIT_PROXY_TTL_MS,
ageMs = 0,
) {
const age = Number.isFinite(ageMs) && ageMs > 0 ? ageMs : 0;
return {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control':
cacheState === 'STALE-ERROR'
? 'no-store'
: `public, max-age=${Math.max(0, Math.ceil((ttlMs - age) / 1000))}`,
'X-GEV-Cache': cacheState,
...(upstreamHost ? { 'X-Rail-Upstream': upstreamHost } : {}),
...(Number.isFinite(contactedAt)
? { 'X-Rail-Contact': String(Math.floor(contactedAt)) }
: {}),
};
}