How this project works
The full picture, in plain words. What the app does, where the data comes from, how the pipeline and hosting fit together, and how to set it up from scratch. Deep per-field notes live in data.md; the deployment checklist is SETUP.md.
1. What this is
London Buses is a website for exploring every bus route in London. You can search routes and see them on a map. You can watch live arrivals and live bus positions. You can browse operators, garages and stops. You can also dig into contracts: who runs each route, what it cost, and when it comes up for tender again.
It is an independent project. It is not affiliated with Transport for London. All data comes from public sources.
1b. Orientation — the whole system in one screen
If you are reading this page to understand the project quickly (as a person or as an automated agent), this section is the map; the rest of the page is the territory.
- System of record: the Git repository. Every dataset is a committed JSON file. The served data is
data/api/*.json(the "faux-API"); its machine-readable index isdata/api/manifest.json— one entry per dataset withsource,fetchedAt,rows,cadenceandfiles. - Runtimes (four): GitHub Actions runs the pipeline on schedules (§9); a static host serves
main(§11); two server endpoints handle live GPS and the street-works push feed; the browser fetches arrivals and service status straight from TfL (§10). - Data flow in one line: public source →
scripts/fetch-*.js(writesdata/source/caches anddata/*.jsonprimaries) →scripts/build-*.js(joins intodata/route_classifications.jsonand the overview geometry) →scripts/build-api.js(reshapes intodata/api/) →scripts/audit-data.js(gate) → commit → host →index.html. - Sources, by what they provide: TfL Unified API (routes, stops, timetables, status, arrivals, TIMS roadworks); TfL open-data files (route geometry ZIP, iBus vehicle list, QSI performance PDFs, tendering programme PDFs, tender award pages); DVLA (make, fuel, age per registration); DfT (BODS live GPS, Street Manager works, D-TRO orders, STATS19 collisions); ONS (CPI); londonbusroutes.net and bustimes.org (operator, garage, PVR, vehicle type — community references used to fill blanks); london-bus-routes.fandom.com (provisional awards); DVSA operator licences (garage capacity, hand-curated in
capacity/licences.json); fixed snapshots for crowding and low bridges. Full table in §5; field-level lineage indata.md. - Processing that changes meaning: fallback chains fill blanks (API first, scrape second, last-known-good third);
data/route-overrides.jsonwins over everything; fleet make/age are a mode/mean over a route's recurring vehicles (one-off cover buses are dropped); operator names are normalised to parent brands viadata/operator-aliases.json; contract value is a sum of accepted annual bids (an estimate); scheduled mileage is timetable × geometry (an estimate); awards from the community wiki are served flaggedprovisional; roadworks, orders and collisions are "corridor-joined" to routes by distance from the route line; every string is sanitised (no HTML, no control characters, no email addresses). - Secrets: five Actions secrets for the pipeline and three host variables for the endpoints; the browser needs none. Names and where they go are in
SETUP.md. - Where to look:
scripts/refresh.jsfor pipeline order;scripts/build-api.jsfor the shape of every served file;data.mdfor every field;tests/verify-*.mjsfor what the app promises;SETUP.mdto deploy.
2. The big idea
The data platform is one Git repository, and the site is a folder of static files served from it. Four parts do all the work:
- GitHub Actions is the scheduler and the computer. Workflows run on a timetable. They fetch data, check it, and commit the results.
- Git is the database. Every dataset is a JSON file in the repo. Every past state of the data is a commit. History comes free.
- A static host serves the repo. The JSON files under
data/api/act as the site's API. We call this the faux-API. Two small server endpoints sit beside the files: a live-GPS proxy and the street-works receiver. - Live things stay live. Arrivals, bus positions and service status are never stored. The browser fetches them fresh each time.
Why build it this way? Almost nothing has to stay online: the pipeline runs on a schedule and the site is plain files. And every data change can be reviewed in Git history.
3. The codebase — where everything lives
The repository is the single source of truth. Code, data, docs, tests and automation all live in it.
| Path | What it is |
|---|---|
index.html | The whole app. HTML, CSS and JavaScript in one file. |
data/api/ | The faux-API. The JSON files the app reads (see section 8). |
data/ | Primary datasets the pipeline builds (route classifications, overview GeoJSON, garages, stops…). |
data/source/ | Raw fetched material and long-lived caches (DVLA records, tender awards, parsed PDFs…). |
data/route-overrides.json | Hand-written corrections. Anything set here beats scraped data. |
data/audit/ | Cross-check reports comparing our data against independent sources. |
scripts/ | The data pipeline. One script per step. refresh.js runs them in order. |
.github/workflows/ | Four scheduled workflows (see section 9). |
functions/api/live/vehicles.js | The /api/live/vehicles endpoint: proxies live bus GPS (adds the secret BODS key, filters to the route). |
functions/api/streetworks.js | The /api/streetworks endpoint: receives DfT Street Manager push notifications (signature-verified) and commits each London event to the streetworks-inbox branch. |
tests/ | Browser test suites. They mock every external feed, so they pass offline. |
_headers, _redirects | Response headers and old-URL redirects, in Cloudflare Pages format (the host's equivalent must apply the same rules — see section 11). |
docs.html | This page. |
v3.html | A concept page at /v3 (not linked from the app, noindex): an operator-intelligence dashboard prototype built on the same faux-API files, used to plan the app's next evolution. It opens on the whole network, scopes to an operator or a garage, adds fleet-age-limit (14-year) tracking, and its maps open full page. |
archive/v1/ | The original map-first app. Kept working, read-only. |
README.md, data.md, SETUP.md | Project intro, the deep data reference, and the from-scratch deployment checklist. |
How GitHub is used
- Branches:
mainis production; the host serves whatever is on it. Two side branches belong to the street-works feed (streetworks-inbox,streetworks-archive) and are managed by the code, never by hand. - Secrets: the pipeline API keys (
BUS_API_KEY,DVLA_API_KEY, and the D-TRO credentialsDTRO_CLIENT_ID/DTRO_API_KEY/DTRO_CLIENT_SECRET) are stored as Actions secrets. They never appear in code or in the browser. - Actions minutes: the repo is public, so Actions minutes are unlimited and free.
- Data commits: automated commits are plain commits with clear messages, like
data: status refresh 2026-09-08T20:16, so any two states of the data can be diffed.
4. The app
The app is a single-page app in plain JavaScript. It all lives in index.html. There is no framework and no build step. You can read the file top to bottom.
The views
| URL | What you see |
|---|---|
#/ | Route search with stackable filters (type, operator, propulsion…). |
#/route/25 | One route: map (with stop, live-bus, garage, low-bridge, collision, street-works and traffic-orders layers), status, crowding, fleet, contract history, cost-per-mile chart. |
#/map | The whole network on one map. Colour by operator or type. Filter, search, compare. |
#/stops · #/stop/… | Stop search (or "near me"), then a live arrivals board that counts down each second. |
#/operators · #/operator/… | Every operator: routes, fleet size, garages, zero-emission share. |
#/garages · #/garage/… | Every garage: capacity, utilisation, routes based there, on the map. |
#/vehicle/… | One bus by registration: make, age, fuel, live position. |
#/tender | Every TfL award since 2003 and the forward programme. Each award shows its previous operator and its new operator side by side (highlighted when they differ). Search by route, operator or tranche. Filter, sort, analyse, export CSV. |
#/diversions | Day-by-day history of every TfL-notified diversion, accumulated from status snapshots. Filter and export. |
#/streetworks | The statutory DfT Street Manager record for London: every permit and work event, pushed live from DfT. Filter by borough, category, status; export. |
#/tro | The statutory D-TRO traffic regulation orders for Greater London: closures, bus lanes, banned turns, official diversion routes. Filter by authority, regulation type, status or route corridor; export. |
#/mileage | Scheduled-vs-operated mileage per route per TfL reporting period, joined with the diversion history. |
#/cpi | The ONS CPI index and the contract price adjustment rates derived from it. |
#/about | Sources, licences, and a live table of when each dataset refreshes. |
How it's built
- Routing: the URL hash picks the view. A
hashchangelistener callsnavigate(), which renders into<main>. - Data layer: one small object wraps
fetch()for each faux-API file. Each file downloads once per visit. - Async safety: each navigation bumps a token. Renderers check it after every
await. A slow fetch from an old view can never overwrite the current one. Timers are cleared and maps destroyed on every navigation. - Maps: Leaflet draws every layer. In the light theme the basemap is OpenFreeMap "Bright" (free vector tiles, no key, no usage cap), rendered by MapLibre GL through the maplibre-gl-leaflet bridge. It is split into two layers so place and road names sit above the route lines. The dark theme, and any vector failure (library blocked, no WebGL, tiles unreachable), use CARTO raster tiles instead. The MapLibre files load lazily, so pages without a map never download them. Optional layers: stops, live buses, garages, low bridges, collision heatmap, street works, traffic orders.
- Theming: CSS variables define both palettes. A tiny script sets the theme before first paint, from the saved choice or the system setting.
- Safety: every remote string that reaches
innerHTMLgoes through one escape helper. CSV exports guard against formula injection. The Leaflet and MapLibre CDN files are pinned with integrity hashes.
5. Data — where it comes from
| Source | Used for | Key needed? |
|---|---|---|
| TfL Unified API | Routes, stops, timetables, live arrivals, service status | BUS_API_KEY (free) |
| TfL geometry ZIP | Route line geometry | No |
| TfL iBus open data | Every bus registration and its operator | No |
| DVLA Vehicle Enquiry | Make, fuel type and age per registration | DVLA_API_KEY (free) |
| DfT Bus Open Data (BODS) | Live bus GPS | BODS_API_KEY (free, server endpoint only) |
| TfL tender pages + programme PDFs | Contract awards and upcoming tenders | No |
| ONS | CPI index for contract price adjustment | No |
| londonbusroutes.net | Operator, garage, PVR, vehicle type (community reference) | No |
| postcodes.io / OSM | Geocoding garages | No |
| OpenFreeMap / CARTO | Basemap tiles: OpenFreeMap vector tiles (light theme); CARTO raster tiles (dark theme and fallback) | OpenFreeMap: none. CARTO: a free-tier key, public by design, in the page |
| DfT Street Manager open data | The statutory street-works record, pushed live over SNS and filtered to London's 33 highway authorities + TfL | Free registration (push-only; the receiver needs a GitHub token — see section 11) |
| DfT D-TRO service | The statutory traffic regulation orders behind closures, bus lanes, bus gates and diversions, searched for Greater London | Free registration (OAuth id + key/secret as Actions secrets) |
| TfL Road disruptions (TIMS) | Live roadworks from TfL's traffic control centre, corridor-joined to bus routes | No |
| TfL per-route performance PDFs | Operated-mileage % and reliability per 4-week period (the QSI reports) | No |
| london-bus-routes.fandom.com | Provisional tender awards, weeks-to-months ahead of TfL's register (served flagged provisional) | No |
| DfT STATS19, TfL datastore, TfL BUSTO | Collisions, low bridges, crowding (fixed snapshots) | No |
Keys go in a local .env file (copy .env.example) or in GitHub secrets. The frontend needs no keys at all. It runs entirely from the committed JSON.
6. Data — how it's made
npm run refresh runs the steps below in order (the historical "17-step" numbering kept its shape as steps were added — 9b, 12b, 12c). Fetch steps come first, then the builds, and the quality gate closes the run. A fetch step may fail without stopping the run — the last good data stays. A build step must succeed. If the audit fails, nothing is committed.
| # | Script | What it does |
|---|---|---|
| 1 | fetch-data.js | Downloads TfL's geometry ZIP. Writes one GeoJSON per route. |
| 2 | fetch-route-destinations.js | Route names and destinations from the TfL API. |
| 3 | fetch-route-stops.js | The ordered stop list per route, plus one big stop directory. |
| 4 | fetch-garages.js | The garage list, geocoded via postcodes.io with OSM fallback. |
| 5 | fetch-frequencies.js | Reads TfL timetables. Marks each route high- or low-frequency. |
| 6 | fetch-route-details.js | Operator, garage, PVR, vehicle type and contract dates per route. |
| 7 | fetch-vehicle-fleet.js | Joins TfL's registration list with DVLA lookups. 90-day cache — only new vehicles are queried. |
| 8 | fetch-route-vehicles.js | Samples live arrivals to see which buses actually worked each route today. |
| 9 | fetch-tenders.js | Every published TfL award since 2003. Awards never change, so only new ones are fetched. |
| 9b | fetch-fandom-tenders.js | Provisional awards from the community wiki's Tender Results pages, served flagged provisional until TfL publishes the real award. |
| 10 | fetch-tender-programme.js | Parses TfL's annual programme PDFs. Which routes come up for tender, and when. |
| 11 | fetch-line-status.js | Snapshot of service status and diversions. The live feed still wins in the browser. |
| 12 | fetch-cpi.js | The ONS CPI index and the CPA rates derived from it. |
| 12b | fetch-scheduled-mileage.js | Timetable-derived scheduled km per route per day type. |
| 12c | fetch-route-performance.js | TfL's per-route QSI PDFs: operated-mileage % and reliability per period. Cached on Last-Modified, so most nights re-download almost nothing. |
| 13 | build-classifications.js | Joins everything into one master record per route. Manual overrides are applied last and always win. |
| 14 | build-overview.js | Simplifies all geometry into one network-wide GeoJSON the map can draw at once. |
| 15 | build-garage-locations.js | Garage coordinates in the shape the frontend wants. |
| 16 | build-api.js | Assembles the faux-API files plus a manifest. Missing fields keep their last good value. |
| 17 | audit-data.js | Checks every record for plausibility (including a buses-only guard — no tram data may leak in). Critical problems abort the run. |
Four more steps run only in the 2-hourly status workflow, because they accumulate history from feeds that publish no archive: build-diversion-history.js (folds each diversion snapshot into the rolling history), fetch-roadworks.js (TIMS snapshot, corridor-joined to routes, accumulated), drain-streetworks.js (folds Street Manager events pushed to the streetworks-inbox branch into the archive, converting coordinates to WGS84), and fetch-dtro.js (statutory traffic regulation orders from DfT's D-TRO service — a full Greater London search per run while the store is small, paced under the service's rate limit, switching to incremental once volumes grow).
7. Data — where it's stored
Everything is a file in Git. There is no database, no object store, no cache server. Three layers of files:
data/source/— raw material. What the fetchers pulled down, plus long-lived caches. Example: the DVLA cache holds every vehicle lookup for 90 days, so most runs only query a handful of new registrations.data/— built datasets. What the build steps produce. The most important file isroute_classifications.json: one complete record per route.data/api/— served files. The reshaped, app-facing JSON. This is what the browser actually downloads.
Some things are deliberately not stored: live feeds (always fetched fresh), API keys (env vars and secrets only), and node_modules (ignored).
Storage cost is a non-issue. The whole data set is a few tens of megabytes of JSON. Git compresses the daily diffs well.
8. The faux-API
The app reads these files from data/api/. They are plain JSON, committed to the repo, served as static files. They are public: no key, and the host serves them with access-control-allow-origin: *, so other sites can read them straight from the URL. Each row below says which script writes the file, what it is built from, how often it moves, and its top-level shape. Sizes are approximate and uncompressed.
| File | Contents | Written by | Built from | Refresh | Shape |
|---|---|---|---|---|---|
routes.json (28 KB) | Every route id, name and type (regular / night / 24-hour / school). | build-api.js | TfL /Line/Mode/bus + route classifications | nightly | [{id, name, type, …}] |
route-meta.json (244 KB) | Per route: operator, company, garage, PVR/TVR, vehicle type, propulsion, length, contract dates, fleet age. | build-api.js | route_classifications.json (londonbusroutes.net + DVLA aggregate + overrides) | nightly | {generatedAt, source, count, routes:{id→…}} |
routes-overview.json (1 MB) | Simplified network geometry for the big map. | build-api.js | data/routes-overview.geojson (TfL geometry ZIP, simplified by build-overview.js) | nightly | GeoJSON FeatureCollection, one feature per route-direction |
route-stops.json (7 MB) | Ordered stops per route and direction. | fetch-route-stops.js | TfL /Line/{id}/Route/Sequence | nightly | {generatedAt, routes:{id→{outbound:[…], inbound:[…]}}} |
route-bboxes.json (25 KB) | A bounding box per route. The live-vehicle endpoint uses it to filter GPS. | build-api.js | per-route geometry | nightly | {generatedAt, routes:{id→[w,s,e,n]}} |
garages.json (33 KB) | Garages: code, name, operator, location, licensed capacity, routes based there. | build-api.js | londonbusroutes.net garage list + postcodes.io/OSM geocode + DVSA licences (capacity/licences.json) + garage-overrides.json | nightly | {generatedAt, source, count, garages:[…]} |
fleet.json (335 KB) / vehicles.json (2 MB) | Observed vehicles per route (make, age, propulsion aggregate) / one record per registration. | build-api.js | TfL arrivals sweeps (route-vehicles.json) + iBus list + DVLA VES cache (vehicle-fleet.json) | 3× daily | {generatedAt, byRoute:{id→…}} / {generatedAt, byReg:{reg→…}} |
tenders.json (3.6 MB) | Every published TfL award since 2003: operator, bids, cost per mile, contract dates, previous operator; community-wiki awards flagged provisional. | build-api.js | data/source/tenders.json (TfL award pages) + fandom-tenders.json + programme dates | hourly in the day | {generatedAt, source, count, byId:{btID→…}, byRoute:{id→[…]}} |
tender-programme.json (339 KB) | The forward programme: tranche, route, issue/return/award/start dates, vehicle spec. | build-api.js | TfL tendering-programme PDFs, parsed with pdf.js | hourly in the day | {generatedAt, source, count, sources, entries:[…]} |
line-status.json (106 KB) / route-diversions.json (156 KB) | Service status per route / current diversions. Fallbacks for when the live call fails. | fetch-line-status.js | TfL /Line/Mode/bus/Status?detail=true | every 2 h | {capturedAt, summary, rows:[…]} / {generatedAt, count, routes:{id→[…]}} |
diversion-history.json (1.4 MB) | Rolling diversion archive: one entry per (route, reason) with the days it was observed. TfL publishes no history, so the snapshots are the history. | build-diversion-history.js | every route-diversions.json snapshot, merged | every 2 h | {generatedAt, firstObservedAt, lastObservedAt, snapshotCount, count, entries:[…]} |
roadworks.json (70 KB) / roadworks-history.json (428 KB) | TfL TIMS road disruptions, corridor-joined to routes within 30 m / the same, accumulated. | fetch-roadworks.js | TfL /Road/all/Disruption + route geometry | every 2 h | {generatedAt, joinMetres, count, disruptions:[…]} / {…, entries:[…]} |
streetworks-history.json (10 MB) | DfT Street Manager permits and works for London's 33 highway authorities + TfL: current and recently ended works, columnar-encoded. | drain-streetworks.js | SNS events committed to the streetworks-inbox branch by /api/streetworks; full archive on streetworks-archive | every 2 h | {generatedAt, totalArchived, count, fields:[…], enums:{…}, rows:[[…]]} — decode a row by zipping fields, looking up enum columns in enums |
dtro.json (11 MB) | Bus-relevant statutory traffic regulation orders for Greater London, corridor-joined to routes, with official diversion geometry where published. | fetch-dtro.js | DfT D-TRO service v4 (OAuth), polygon search + per-order fetch, watermarked | every 2 h | {generatedAt, note, count, totalOrders, publishingAuthorities, orders:[…]} |
route-performance.json (610 KB) | Operated-mileage % and reliability (EWT minutes or on-time %) per route per TfL 4-week period, with each route's minimum standards. | fetch-route-performance.js | TfL QSI per-route PDFs, parsed with pdf.js, cached on Last-Modified | nightly check | {generatedAt, periods:["P01 24/25",…], routes:{id→{operatedPct:{period→%}, reliability:{period→v}, reliabilityMetric, operatedMinStandard, reliabilityMinStandard}}} |
scheduled-mileage.json (152 KB) | Timetable-derived scheduled km and trips per route per day type. An estimate. | fetch-scheduled-mileage.js | TfL timetables × route geometry length | nightly | {generatedAt, method, count, routes:{id→{dailyKm:{weekday,saturday,sunday}, trips:{…}}}} |
cpi-cpa.json (18 KB) | The ONS CPI index (D7BT, 2015 = 100) by month and the contract price adjustment rates derived from it. | fetch-cpi.js | ONS time-series API | nightly (ONS publishes monthly) | {generatedAt, series, releaseDate, nextRelease, factor, months:[…], confirmedForward} |
crowding.json (349 KB) / crowding-profile.json (1.5 MB) | Peak load per route (busiest stop and time) / load along the route stop by stop. | seeded; copied through by build-api.js | TfL BUSTO survey (fixed snapshot) | static | {generatedAt, year, count, routes:{id→…}} |
bridges.json (225 KB) / accidents.json (2.3 MB) | Low bridges with clearance / bus- and coach-involved collisions, for the route-map layers. | seeded; copied through by build-api.js | TfL/London Datastore height restrictions (+OSM cross-check) / DfT STATS19 2021–2025 | static | {generatedAt, count, bbox, bridges:[…]} / {…, period, accidents:[…]} |
manifest.json (5 KB) | Per dataset: source, fetch time, row count, cadence, files. Powers the About page and is the index automated readers should start from. | build-api.js | the files above | every run | {generatedAt, datasets:{name→{source, fetchedAt, status, rows, files, cadence}}} |
Besides data/api/, two things are fetched by URL: data/routes/{id}.geojson (full-fidelity geometry per route, written by fetch-data.js) and data/route_destinations.json (used by the /v3 concept page).
8b. Data conventions
- Route ids are TfL line ids in upper case (
25,N25,SL7,EL1). Night routes areN-prefixed; a 24-hour route is the day id withtype: "twentyfour". Joint tender awards may key on a slash-joined id (1/N1);byRoutesplits them. - Envelope: almost every served file is
{generatedAt, source, count, …}with the payload under one key (routes,entries,byId…).generatedAtis the build time;manifest.json'sfetchedAtis when the upstream was last read, which is the honest "data as of". - Dates are ISO 8601 strings (
2026-10-05, or with time andZ). Money is pounds as a number;costPerMileis £ per live mile at award. Distances are km in the pipeline and metres in join thresholds; coordinates are WGS84 and GeoJSON order ([lon, lat]). - TfL periods are 4-week reporting periods named
P01 24/25…P13 24/25; a financial year runs April to March. - Blanks: a missing value is
null, never an empty string or 0. When a source fails, the previous value is kept (last-known-good) and the manifest row still shows the olderfetchedAt. - Estimates are labelled in their file's
source/method/notefield: scheduled mileage, contract value and corridor joins are derived, not published figures. Provisional tender rows carryprovisional: true. - Sanitisation: every string that enters a cache goes through
scripts/_lib/sanitize.js— tags stripped, entities decoded, control characters removed, email addresses redacted. The frontend escapes again beforeinnerHTML. - Stability: writers sort keys and skip rewriting when only timestamps changed, so a run with nothing new produces no diff and no commit.
9. Automations
Four GitHub Actions workflows keep the data fresh. Times are UTC. The app's About page shows the same schedule in London time.
| Workflow | Schedule (UTC) | Refreshes |
|---|---|---|
weekly-refresh.yml | daily 03:17 | The full pipeline (every fetch and build step plus the audit gate). The name is historical. |
refresh-status.yml | every 2 h, 07:41–21:41 | Service status, the diversion register + history, TIMS roadworks, the street-works drain (folds DfT Street Manager events pushed to the streetworks-inbox branch), and the D-TRO order search. |
refresh-fleet.yml | 07:20 / 15:20 / 23:20 | Arrivals sweep and DVLA fleet data. |
refresh-tenders.yml | hourly 07:20–20:20, plus after every other run | Tender awards and the programme. |
Built-in safety:
- One at a time. All four share one concurrency group, so runs never overlap.
- Commit only on change. A run that finds nothing new commits nothing. The site does not redeploy for no reason.
- Cron slips. GitHub's scheduler can run a slot late, or drop it. The tender check also fires after every other workflow finishes, which is reliable. That is what guarantees same-day pickup of new awards.
- Safe pushes. If another commit lands mid-run, the workflow retries. The nightly, tender and fleet workflows reset to the new head, keep their freshly fetched source data, and re-run the API build so generated files are rebuilt rather than merged (rebasing regenerated JSON conflicts by construction). The status workflow, whose files rarely collide, simply pulls, rebases and retries up to three times.
10. Live data
Three things are live and never stored:
- Arrivals boards. The browser calls TfL's API directly. Boards count down every second and re-sync about every 30 seconds.
- Route status. Fetched from TfL on view, then re-polled every 60 seconds. If the live call fails, the committed snapshot fills in.
- Live bus positions. The GPS feed needs a secret key, so the browser can't call it directly. It calls
/api/live/vehicles?line=25instead. That is a small server endpoint — see the next section.
Every live surface survives a bad feed. Last-good data stays on screen with a quiet "reconnecting…" note. Polling backs off and recovers on its own.
11. Hosting
The site is a folder of static files plus two small server endpoints. Any host that can do the following will run it; today that host is Cloudflare Pages, and SETUP.md walks through both Cloudflare and a plain Linux server.
- Serve the repo as static files from the
mainbranch, withindex.htmlat the root, and pick up each data commit (Cloudflare redeploys on push; a server does agit pullon a timer). - Headers.
_headerslists them: HSTS,X-Frame-Options,nosniff, a referrer policy, andCache-Control: max-age=0, must-revalidatefor HTML, JSON, JS and CSS. There are no fingerprinted filenames, so revalidation is what keeps returning visitors on the current code. The faux-API also needsaccess-control-allow-origin: *. - Redirects.
_redirectskeeps the old/v2and/changelog.htmlURLs working (301s). - The live-GPS endpoint.
functions/api/live/vehicles.jsserves/api/live/vehicles?line=25. It adds the secret BODS key, fetches the national GPS feed, filters it to the route's bounding box (read fromdata/api/route-bboxes.json), and caches the result for 10 seconds per line so heavy use never hammers the upstream feed. It needs theBODS_API_KEYenvironment variable. Written for the Cloudflare Pages Functions runtime; on another host, run the same logic as a small Node service. - The street-works receiver.
functions/api/streetworks.jsserves/api/streetworks. It accepts DfT Street Manager push notifications (AWS SNS), verifies their signatures, filters to London's highway authorities by SWA org code, and commits each event to thestreetworks-inboxbranch via the GitHub API — git is the queue. It needsGITHUB_REPO(owner/repo) andGITHUB_TOKEN(a fine-grained token for that repo only, Contents read/write). The status workflow folds queued events intostreetworks-history.json. Same runtime note as above.
12. Deployment
- What triggers a deploy: any commit on
main. That includes code changes and the automated data commits. Data commit → host picks it up → fresh JSON on the site. On Cloudflare that is a build (a minute or two); on a server it is the nextgit pull. - Commit budget: the schedules produce about 12 data commits a day. That matters only on hosts that rebuild per commit (Cloudflare's free tier allows 500 builds a month); a server pulling on a timer does not care.
- Caching: HTML, JSON, JS and CSS all revalidate on every load. Browsers send a quick check and get "not modified" or the new file. Nobody runs stale code against new data.
- Rolling back: revert the commit and push. The host serves the previous state at its next deploy or pull. Because data lives in Git, this works for data mistakes too.
13. Keeping the data honest
- Last-known-good everywhere. If a source is down, every field keeps its previous value. A flaky scrape never blanks the site.
- Fetch only what's new. Facts that never change (DVLA records, published awards) are cached. Watermarks skip work when an upstream hasn't moved.
- Manual overrides.
data/route-overrides.jsonpins hand-checked corrections. They beat any scraped value and survive every refresh. Example: route 660's vehicle type, which the upstream reference still lists wrongly. - Cross-checks. A separate audit compares our per-route vehicle verdicts against independent sources and writes disagreement reports to
data/audit/. - A hard gate. The last pipeline step checks every record for plausibility. Critical failures abort the run, so broken data is never committed.
14. Testing
The suites in tests/ run the real app in a headless browser. Every external feed is mocked, so they pass offline and never depend on TfL being up. They cover: mobile layout and overflow, dark and light themes, the tender tables and their analysis numbers, the CPI maths (checked against ONS reference values), the map hazard layers, live-feed outage and recovery, and the promise that no retired hosts are ever called. Run one with node tests/verify-<name>.mjs.
15. Set it up from scratch
The short version. SETUP.md in the repository is the full checklist, in order, with every secret, environment variable and registration spelled out.
- Get the code. It ships with working data, so the site runs immediately with no keys.
- Serve it locally.
npm install, then any static server (npx serve .) from the repo root. Everything works from the committed JSON. - Get keys (only needed to refresh data): a TfL API key, a DVLA key, and a BODS key. All free. Copy
.env.exampleto.envand fill them in. - Run the pipeline.
npm run refresh. Watch the steps run. The audit at the end must pass. Each step can also run alone — seepackage.json. - Host it. Serve
mainas static files with the headers and redirects from section 11, and run the two/apiendpoints withBODS_API_KEYset. - Automate it. Add
BUS_API_KEYandDVLA_API_KEYas GitHub Actions secrets. The four workflows then keep the data fresh on their own. Public repos get unlimited Actions minutes. - Optional — street works. The Street Manager feed is push-based, so it needs your own registration: give the receiver
GITHUB_REPOand a fine-grainedGITHUB_TOKEN, GET/api/streetworksuntil it reports ready, then register that URL at DfT's open-data onboarding. Everything else works without it. - Optional — traffic orders. Register (free) at d-tro.dft.gov.uk, then add
DTRO_CLIENT_ID,DTRO_API_KEYandDTRO_CLIENT_SECRETas Actions secrets. Without them the fetcher skips gracefully and the rest of the pipeline is unaffected. - Fix data by hand when needed. Add an entry to
data/route-overrides.json. Rebuild:npm run build-classifications, thenbuild-overview, thenbuild-api. Commit. Overrides survive every future refresh. - Verify.
npm run test:fixturesonce (it downloads the pinned map libraries the suites serve offline), then run the suites intests/. They need a Chromium and mock every feed.
16. Glossary
| PVR | Peak Vehicle Requirement. How many buses a route needs at the busiest time of day. |
| TVR | Total Vehicle Requirement. PVR plus spare buses. |
| Deck | Double-decker or single-decker. |
| Propulsion | What powers the bus: diesel, hybrid, electric or hydrogen. |
| Tender / award | TfL contracts each route through competitive bidding. The published result is the award. |
| Tranche | A numbered batch of routes tendered together. |
| £/mile | The award's cost per contracted mile. The headline price of running the route. |
| CPI-CPA | Contract Price Adjustment. TfL contracts rise with 85% of CPI inflation. |
| iBus | TfL's on-bus tracking system. Its open data lists every vehicle's registration and operator. |
| Street Manager | DfT's national register of street and road works. Every permit and work event in England flows through it; this site subscribes to the London slice. |
| SWA code | Street Works Act organisation code — the numeric ID every highway authority carries (e.g. 5300 = Enfield). Street Manager identifies authorities by these. |
| TRO / D-TRO | Traffic Regulation Order — the legal instrument behind a closure, bus lane, banned turn or parking rule. D-TRO is DfT's digital register of them; publication is mandatory for new orders from autumn 2026. |
| BODS | The DfT Bus Open Data Service. The national live bus GPS feed. |
| Faux-API | This project's term for committed JSON files served statically but read like an API. |