Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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-departures has zero dependencies: no native modules, no C/C++ toolchain, nothing to compile. npx gtfs-departures works 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).
--minify no All JSON's files will be minify (harder for human read)
-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 ./bundles

Progress 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.txt never becomes a 1 GB allocation;
  • stop_times and shapes — 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.json lists every served stop with its coordinates (this doubles as the stop-markers data source), the lines serving it, and the file of its departures shard. Each shard is that stop's departures, sorted ascending by departure_time.
  • patterns/ — schedule per pattern. A pattern is one distinct ordered stop sequence for a route_id + direction_id (the granularity OpenTripPlanner / Transitland use), the right unit for a timetable. patterns/index.json is a light list; each shard has the ordered stop_sequence and the trips with their stop_times.
  • shapes/ — one line geometry per shard, each a GeoJSON FeatureCollection with a single LineString (hand it straight to MapLibre/Leaflet). shapes/index.json carries a per-shape bbox so 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:47884 become IDFM-monomodalStopPlace-47884-<hash>.json — never percent-encoded, so an HTTP server can't mangle them by decoding the path. Case-colliding ids (RA vs ra), Windows-reserved names and over-long ids are disambiguated with a deterministic hash. Consumers must always use the file field from each index — never rebuild a filename from an id.

  • GTFS edge cases handled: unpadded times (7:30:0007:30:00, fixing sort order), times past 24:00:00 kept verbatim, arrival-only timepoints included on boards, calendar_dates.txt-only feeds, missing shapes.txt, missing colors (defaulted, warned), accented/empty agency names (safe output-folder slug), and frequencies.txt — frequency-defined trips are expanded into real per-headway departures (flagged frequency_based, with exact_times).

  • Times can exceed 24:00:00. GTFS represents after-midnight service as 25:30:00. These are preserved verbatim as strings — never parsed to Date, 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 empty realtime_* 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 → empty shapes/index.json ({}) + warning; no calendar.txt (everything in calendar_dates.txt) → calendar still resolves, omitting weekly flags; missing optional fields are defaulted (route colors → 777777 on FFFFFF), 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.

About

A lightweight GTFS parser for the web. Shards raw feeds into small, lazy-loaded files to eliminate server bloat and boost client-side performance.

Topics

Resources

Code of conduct

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages