gtfs-departures
v1.0.1
Published
Turn a transit agency's GTFS feed into lazy-loadable departures-per-stop, schedules-per-pattern, and line geometries that a browser frontend consumes directly.
Maintainers
Readme
gtfs-departures
Turn a transit agency's GTFS feed into exactly what a mobility frontend consumes and nothing else: departures per stop, schedules per pattern (line variant), and the line geometries — each as a lazy-loadable, per-id resource — plus the resolved service calendar and a flat route index.
This is not a "dump the whole feed as JSON" converter. GTFS is an interchange format — a zip of CSVs — awkward to read in a browser, where you would parse and re-aggregate on every page load. gtfs-departures does that work once, ahead of time (via npx), and shards the result so a station page fetches one small file for one stop, a map draws one line by fetching one shape, and nothing else is downloaded. Everything GTFS also contains (agency, fares, feed_info, transfers, …) is off by default and opt-in via --include.
Requirements
- Node.js ≥ 22. That's it —
gtfs-departureshas zero dependencies: no native modules, no C/C++ toolchain, nothing to compile.npx gtfs-departuresworks out of the box on macOS, Linux and Windows. The GTFS zip is read by a built-in pure-JS extractor and CSV parser.
Usage
npx gtfs-departures --url <FEED_URL>Options:
| Flag | Required | Description |
| --- | --- | --- |
| --url <FEED_URL> | yes | Direct URL to a zipped GTFS feed. |
| --include <list> | no | Comma-separated GTFS tables to also emit as raw 1:1 JSON under raw/ (e.g. --include agency,fares). Off by default. |
| --out <DIR> | no | Output directory (default: current folder). |
| -h, --help | no | Show usage. |
Examples:
npx gtfs-departures --url https://example.com/gtfs.zip
npx gtfs-departures --url https://example.com/gtfs.zip --include agency,fares --out ./bundlesProgress is narrated on stderr as it runs; the final summary (counts, shard counts, any included raw tables, output path) goes to stdout. The output folder is <agency-slug>-gtfs-departures, from the primary agency in agency.txt.
Large feeds
The pipeline is memory-flat by design — a national feed cannot OOM it:
- the zip is streamed to disk while downloading (hashed on the way through), never held as one buffer;
- each zip entry is decompressed as a stream (pure JS), so a 1 GB+
stop_times.txtnever becomes a 1 GB allocation; stop_timesandshapes— the giant tables — are hash-partitioned into temporary disk buckets in one streaming pass, then every builder processes one bucket at a time (~1/64 of the table). Peak heap is one bucket plus the small tables, regardless of feed size.
Measured: a synthetic feed with 3.3 million stop_times builds in ~1 minute with a peak of ~225 MB RSS, comfortably inside a 256 MB heap cap. No --max-old-space-size juggling needed. Temporary bucket files (roughly the size of the uncompressed stop_times) live in the OS temp dir and are always cleaned up, including on error.
Output
Everything is UTF-8, pretty-printed JSON (2-space indent) so the bundle stays diff-able. Geometry is valid GeoJSON (RFC 7946) with coordinates in [longitude, latitude] order; everything else uses verbatim GTFS field names (stop_id, route_short_name, departure_time, …).
<slug>-gtfs-departures/
├── manifest.json # entry point: metadata, resource map, counts, warnings, includes
├── calendar.json # resolved service calendar (weekly pattern + exceptions)
├── routes.json # line index: route metadata + colors + pattern_ids[]
├── stops/
│ ├── index.json # every served stop: name, coords, routes, parent, file path
│ └── <enc(stop_id)>.json # departures for that one stop (sorted array)
├── patterns/
│ ├── index.json # every pattern: route, direction, headsigns, shape, counts, file
│ └── <enc(pattern_id)>.json # full schedule (stop sequence + trips) for that pattern
├── shapes/
│ ├── index.json # every shape: route_ids, pattern_ids, color, bbox, file
│ └── <enc(shape_id)>.json # one shape as a GeoJSON FeatureCollection (single LineString)
└── raw/ # ONLY if --include was used
└── <table>.json # faithful 1:1 mirror of the named GTFS table(s)manifest.json is the single entry point — load it first. Its resources map points at the three index files plus routes.json and calendar.json (never at individual shards). It also records the source feed URL and SHA-1, the agencies, a bounding box, counts, included_raw, and warnings.
The three sharded resources
stops/— departures per stop.stops/index.jsonlists every served stop with itscoordinates(this doubles as the stop-markers data source), the lines serving it, and thefileof its departures shard. Each shard is that stop's departures, sorted ascending bydeparture_time.patterns/— schedule per pattern. A pattern is one distinct ordered stop sequence for aroute_id+direction_id(the granularity OpenTripPlanner / Transitland use), the right unit for a timetable.patterns/index.jsonis a light list; each shard has the orderedstop_sequenceand thetripswith theirstop_times.shapes/— one line geometry per shard, each a GeoJSONFeatureCollectionwith a singleLineString(hand it straight to MapLibre/Leaflet).shapes/index.jsoncarries a per-shapebboxso the map can fit the camera before fetching the full geometry.
Index-driven lookup (safe filenames)
A stop_id, pattern_id, or shape_id can contain /, :, spaces, or accents. Shards are named with encodeURIComponent(id) (deterministic and reversible), and the real encoded path is stored in each index entry's file. The frontend reads the index and fetches file; it never rebuilds a filename from an id.
Deterministic pattern_id
pattern_id = "{route_id}-{direction_id}-{hash8}"hash8 is the first 8 hex of the SHA-1 of the ordered stop_id sequence (joined with >). It changes only when the stop sequence changes, so a frontend's per-pattern caches survive routine feed updates. Given the same feed, the same ids and the same shard filenames come out every time.
--include (opt-in raw mirror)
By default there is no raw/. --include <list> adds faithful 1:1 JSON mirrors (verbatim columns, values as strings) of the named GTFS tables — any base name (agency, feed_info, transfers, stop_times, …). The alias fares expands to every fare-related table present (fare_attributes, fare_rules, and GTFS-Fares-v2 files like fare_products, fare_leg_rules, fare_media, …). A requested table the feed lacks is skipped with a warning, never a crash.
Bundle viewer (view.html)
Every bundle ships a self-contained view.html: browse stations, see each station's departures (with a day filter), tap a departure to open the full trip schedule — with each line rendered in its official GTFS colors (route_color / route_text_color). Open it directly in Firefox, or serve the bundle folder with any static server for Chrome/Safari (npx serve .). Deterministic content, so bundle reproducibility is unaffected.
Notes / gotchas
Shard filenames are filesystem/URL-safe. GTFS ids like
IDFM:monomodalStopPlace:47884becomeIDFM-monomodalStopPlace-47884-<hash>.json— never percent-encoded, so an HTTP server can't mangle them by decoding the path. Case-colliding ids (RAvsra), Windows-reserved names and over-long ids are disambiguated with a deterministic hash. Consumers must always use thefilefield from each index — never rebuild a filename from an id.GTFS edge cases handled: unpadded times (
7:30:00→07:30:00, fixing sort order), times past24:00:00kept verbatim, arrival-only timepoints included on boards,calendar_dates.txt-only feeds, missingshapes.txt, missing colors (defaulted, warned), accented/empty agency names (safe output-folder slug), andfrequencies.txt— frequency-defined trips are expanded into real per-headway departures (flaggedfrequency_based, withexact_times).Times can exceed
24:00:00. GTFS represents after-midnight service as25:30:00. These are preserved verbatim as strings — never parsed toDate, never wrapped at 24h. Zero-padding means the string sort still orders the boards correctly.Realtime-ready, no realtime fields. Departures and patterns keep the GTFS-Realtime join keys (
trip_id,stop_id,stop_sequence), so a future realtime layer matches with no translation. No emptyrealtime_*placeholders are added.Link, don't embed. A departure/pattern stores
shape_id; coordinates live only in that shape's shard.Graceful degradation. Missing
shapes.txt→ emptyshapes/index.json({}) + warning; nocalendar.txt(everything incalendar_dates.txt) → calendar still resolves, omitting weekly flags; missing optional fields are defaulted (route colors →777777onFFFFFF), never crashed on. A leading UTF-8 BOM — common in European feeds — is detected from the file head and stripped before import.
Roadmap
Later: Transitland / agency-name lookup (so you don't have to find the zip URL), and a GTFS-Realtime layer merged over the shared join keys.
License
MIT — see LICENSE.
