|
Some checks failed
/ Publish to Cloudflare Pages (push) Failing after 34s
# Conflicts: # src/app/pages/station-search/station-search.ts |
||
|---|---|---|
| .forgejo/workflows | ||
| .vscode | ||
| public | ||
| scripts | ||
| server | ||
| src | ||
| .editorconfig | ||
| .gitignore | ||
| .prettierrc | ||
| angular.json | ||
| compose.yaml | ||
| ovtimer.iml | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.app.json | ||
| tsconfig.json | ||
| tsconfig.spec.json | ||
ÖV Timer
An Angular 22 application built with
@sbb-esta/angular (v22), the
Angular component library of the Swiss Federal Railways (SBB).
What the app does
The app asks for a start and a destination station. Station names are resolved while typing through
the Open Journey Planner (OJP) 2.0 API of
opentransportdata.swiss, in an sbb-autocomplete on top of sbb-form-field / sbbInput:
- typing at least 3 characters triggers a debounced search (250 ms, one request per query,
in-flight requests are cancelled by
switchMap), - the browser asks our proxy (
server/ojp-proxy.mjs), which adds the OJP API token and forwards a location information request — the token never reaches the browser, - the response is parsed with the browser's
DOMParser, keeping the stop place reference (ch:1:sloid:…), the name, the locality, the position and the transport modes, - the picked station is shown in a summary card, together with its stop id, coordinates and the mode icons,
- a swap button exchanges start and destination, and errors (rate limits, outages) are shown as an
sbb-notification.
Stations are the only supported place type: places from the response without a StopPlace (pure
addresses, points of interest) are filtered out, because a journey has to start at a stop.
Stored connections
A connection is a stored start/destination pair. Storing it adds a tab to the sidebar, and every tab restores its pair into the form.
- Storage —
ConnectionStore(src/app/core/connections/connection-store.ts) reads and writes the list under the versioned keyovtimer.connections.v1inlocalStorage. The list is exposed as signals, so the sidebar and the page stay in sync without any additional state. - Saving — the "Save connection" button is enabled once both stations are picked. Saving a pair that is already stored selects its existing tab instead of adding a duplicate; the same pair in the opposite direction is a separate connection.
- Tabs — the sidebar shows one tab per connection plus a "New connection" tab. Selecting a tab loads its stations into the form (re-selecting the current tab reloads it, so unsaved edits are discarded); the trash button next to a tab deletes the connection.
- Robustness — everything read from
localStorageis validated (isConnection) and invalid entries are dropped, so stale or hand-edited data cannot break the app. When storage is unavailable or full (private mode, quota), the store keeps working in memory for the session.
Getting started
The app needs the proxy for station searches, so start both (two terminals):
npm install
cp server/.env.example server/.env # then put your OJP API key into OJP_TOKEN
npm run start:proxy # proxy on http://localhost:3000
npm start # dev server on http://localhost:4200/
Other useful commands:
npm run build # production build into dist/
npm test # unit tests (Vitest + jsdom)
npm run test:server # proxy tests (node:test)
Setup notes
The library was installed with ng add @sbb-esta/angular, which
- adds
@sbb-esta/angular,@angular/cdkand@angular/animationstopackage.json, - registers
node_modules/@sbb-esta/angular/typography.cssinangular.json(the SBB theme: design tokens, typography and utility classes), - adds
provideAnimationsAsync()tosrc/app/app.config.ts.
Two things were added on top of the schematic:
provideHttpClient()insrc/app/app.config.ts— required bysbb-icon, which fetches icons fromhttps://icons.app.sbb.ch, and by the station search client.src/test-setup.ts(wired up via thesetupFilesoption of thetesttarget) — polyfillsmatchMedia,ResizeObserverandIntersectionObserver, which jsdom does not implement and which the SBB components rely on. It also installs an in-memorylocalStorage, because Node 26 ships its own experimentallocalStorageglobal that shadows the jsdom one and staysundefinedunless the runner is started with--localstorage-file.
The proxy
The OJP API key is a secret and the OJP quota belongs to the key, not to a user. Both are reasons
why the browser must not talk to the OJP API directly, so server/ojp-proxy.mjs sits in between:
browser ── GET /api/stations?q=… ─────────▶ proxy ── POST (bearer token) ──▶ api.opentransportdata.swiss
── GET /api/trips?from=…&to=… ────▶ (OJP location information / trip request)
◀──── OJP XML (parsed in the browser) ────
It is written in Node.js, the same language as the app and its build scripts, and it has no
dependencies — node:http, the global fetch and node:test are enough.
| Endpoint | Purpose |
|---|---|
GET /api/stations?q=&limit= |
Station search, returns the upstream OJP XML |
GET /api/trips?from=&to=&limit= |
Next departures between two stops, OJP XML |
GET /health |
Liveness probe for deployments |
Both search endpoints forward to the same upstream service and draw on the same rate limit budget, so a browser that polls departures cannot starve the station search.
Configuration comes from the environment. Started with npm run start:proxy (from the repository
root), the proxy also reads a gitignored server/.env — next to the proxy — so the token does not
have to go through the shell history. Real environment variables win over the file, which a container
or pipeline can use to override it (server/.env.example is the template):
| Variable | Default | Meaning |
|---|---|---|
OJP_TOKEN |
— (required) | API key, added as a bearer token; never logged |
HOST |
127.0.0.1 |
Interface to bind (0.0.0.0 in a container) |
PORT |
3000 |
Listen port |
OJP_ENDPOINT |
https://api.opentransportdata.swiss/ojp20 |
Upstream endpoint |
ALLOWED_ORIGINS |
http://localhost:4200 |
Comma separated; other origins get a 403 |
TRUST_PROXY |
false |
Take the client IP from X-Forwarded-For |
REQUEST_TIMEOUT_MS |
10000 |
Upstream timeout (504 when exceeded) |
RATE_LIMIT_PER_MINUTE |
20 |
Burst per client |
RATE_LIMIT_PER_DAY |
200 |
Per client, per UTC day |
DAILY_BUDGET |
1000 |
All clients together, per UTC day |
Rate limiting
Because a key's daily quota is shared by everybody, three limits are enforced (see
server/rate-limiter.mjs):
- a token bucket per client (default 20/min) stops bursts — a client that exceeds it gets
429withRetry-After, - a daily counter per client (default 200) stops one anonymous user from using up the day,
- a global daily budget (default 1000) stops the proxy before the upstream quota is drained —
when it is used up the proxy answers
503for everybody until the next UTC day.
Set DAILY_BUDGET below the daily limit of your OJP key. Clients are identified by IP address, so
behind a load balancer set TRUST_PROXY=true only if the proxy is not reachable directly
(otherwise clients could spoof X-Forwarded-For and get a fresh quota per request).
The counters live in memory, which fits a single instance. Running several instances needs a shared store (e.g. Redis) — otherwise each instance grants the full quota.
Deployment
- Serve the proxy behind TLS and restrict
ALLOWED_ORIGINSto your app's origin. - Pass
OJP_TOKENas a secret of the platform (not in a file in the image). GET /healthis there for the platform's health check.- Requests are validated (
qlength, control characters,limitbounds) before they cost quota, and upstream failures are mapped to502/504without leaking upstream details.
Deployment
The two parts are deployed separately: the frontend is a static bundle (Cloudflare Pages) and the proxy is a small Node service that the browser calls cross origin.
Proxy
Either with npm, which is what the proxy is written for, or in a container:
cp server/.env.example server/.env # once: OJP_TOKEN and ALLOWED_ORIGINS
npm run start:proxy # http://127.0.0.1:3000 by default
# or
docker compose up --build # publishes ${PROXY_PORT:-3000}
| Piece | Role |
|---|---|
server/Dockerfile |
Proxy image: node:24-alpine, only the four .mjs files, non-root, healthcheck |
server/.dockerignore |
Keeps .env and the tests out of the build context |
compose.yaml |
The proxy service on its own; the frontend is not containerised |
Worth knowing:
- Set
ALLOWED_ORIGINSto your Pages origin (https://<project>.pages.dev, plus a custom domain andhttp://localhost:4200while developing). A browser request from any other origin gets a403before the upstream is called — and because the browser only reports a CORS error, the proxy logs the rejected origin so a misconfiguration is easy to spot. - The proxy must be reachable over HTTPS, since an HTTPS page cannot call an HTTP API. A
Cloudflare Tunnel in front of the container is the usual way; it also lets you keep the port
unpublished and set
TRUST_PROXY=true, because then the only path in is the tunnel. TRUST_PROXY=trueonly when the proxy is not reachable directly: the client IP then comes fromX-Forwarded-For, which a client could otherwise spoof to get a fresh rate-limit bucket per request. Without it, per-client limits fall back to the connection address (every request through a tunnel looks like the same client).- The image contains no token: only the proxy sources are copied, so the key arrives at runtime
through
OJP_TOKEN/server/.envand never ends up in a layer. env_fileuses the short syntax, soserver/.envhas to exist beforedocker compose up(cp server/.env.example server/.env). The long form (- path: …, required: false) needs Compose v2.24+ and is rejected by older versions withservices.proxy.env_file.0 must be a string— that fails validation for the whole file, so the stack starts nothing at all.
Frontend (Cloudflare Pages)
Pages runs the build itself. Set the build command and the output directory, and provide the environment variables of the target stage (see below) in the Pages project settings:
| Setting | Value |
|---|---|
| Build command | npm run generate:environment && npm run build |
| Output | dist/ovtimer/browser |
| Variables | APP_STAGE=prod, APP_PRODUCTION=true, APP_NAME=ÖV Timer, APP_VERSION=$COMMIT_SHA, API_BASE_URL=https://<your-proxy-host>, ENABLE_DEBUG_LOGGING=false |
API_BASE_URL is the proxy's public origin, since the app and the API are on different hosts
here. public/_redirects (/* /index.html 200) is part of the bundle and makes Pages serve the app
shell for every path, so client-side routes survive a reload.
When the browser reports a CORS error
The browser blames CORS for any response that lacks Access-Control-Allow-Origin — including the
error pages of everything in front of the proxy. Read the status code first: a 502/503/504 from
Cloudflare's own error page is not a CORS problem, and no value of ALLOWED_ORIGINS can cause it.
Two properties of the proxy make that distinction decidable:
- An origin that is not in
ALLOWED_ORIGINSgets403 origin_not_allowed(with the rejected origin in the proxy log) — never a502. - For an allowed origin the header is set on every response, including the proxy's own
502,503and504JSON errors, so whenever the proxy answers at all the app shows its message instead of a CORS error.
A missing Access-Control-Allow-Origin combined with a status the proxy never sends therefore means
the request never reached the proxy. Walk the chain outwards:
| # | Check | What it tells you |
|---|---|---|
| 1 | curl -i https://<proxy-host>/health |
/health needs no token, origin or upstream. A Cloudflare "Bad gateway" body means Cloudflare got no valid response from the origin at all. |
| 2 | docker compose ps, docker compose logs --tail=100 proxy on the host |
Whether the container runs, and whether it logs OJP_TOKEN is not set (it then exits 1 by design). |
| 3 | curl -i http://127.0.0.1:3000/health on the host ($PROXY_PORT if changed) |
200 means the proxy is healthy and the break is in NPM; a connection error means nothing listens on the published port. |
| 4 | docker compose version |
Below v2.24 the long env_file syntax fails validation, so up starts nothing — see the bullet above. |
| 5 | NPM's access log for this proxy host while requesting the public URL | Nothing logged → the break is between Cloudflare and NPM (DNS record, tunnel, port forwarding). A logged 502 → the break is NPM → container: verify Forward Hostname/Port reach the published port, and not 127.0.0.1 seen from inside NPM's own container. |
Values in front of the proxy are trimmed by nothing: a single stray space in NPM's Forward
Hostname/Port or in a Cloudflare Tunnel ingress service URL is enough that nothing is dialled at all,
and Cloudflare then answers with its own 502 page — the case in rows 1 and 5.
A 200 on /health while /api/stations still fails narrows the cause to the proxy's own
configuration (OJP_TOKEN, ALLOWED_ORIGINS) or the upstream OJP API.
Environment configuration
The build time configuration lives in src/environments/ and is a plain TypeScript file, so it is
type checked with the rest of the application:
| File | Purpose |
|---|---|
environment.model.ts |
AppEnvironment interface — the contract every environment file has to satisfy |
environment.ts |
Committed local defaults (apiBaseUrl points at the proxy on port 3000). Used by npm start and the unit tests |
environment.template.ts |
Template with placeholders that the pipeline renders. Excluded from the application build (tsconfig.app.json) |
environment.prod.ts |
Generated, one file per stage. The committed version is a placeholder so a fresh clone builds without the pipeline |
scripts/generate-environment.mjs |
The generator that renders the template from environment variables |
angular.json maps environment.ts to environment.prod.ts for the production configuration
(fileReplacements), so application code always imports environment.ts and never has to know
which stage it is built for:
import { environment } from '../environments/environment';
No secrets end up here. The OJP token belongs to the proxy; AppEnvironment has no token field,
and src/environments/environment.spec.ts fails the build if one is ever added. The header subtitle
shows the stage and the version, which makes a wrong configuration visible immediately.
Variables
| Variable | Type | Example | Description |
|---|---|---|---|
APP_STAGE |
string | prod |
Deployment stage (local, review, prod) |
APP_PRODUCTION |
boolean | true |
true for production builds |
APP_NAME |
string | ÖV Timer |
Display name of the application |
APP_VERSION |
string | 8d8df8a |
Git tag or commit sha of the artefact |
API_BASE_URL |
string | https://ovtimer-proxy.example.com |
Public origin of the proxy; empty means same origin |
ENABLE_DEBUG_LOGGING |
boolean | false |
Verbose logging in non production stages |
The OJP token is not part of this: it belongs to the proxy (OJP_TOKEN, see
The proxy).
Generating an environment file
APP_STAGE=prod \
APP_PRODUCTION=true \
APP_NAME='ÖV Timer' \
APP_VERSION="$(git rev-parse --short HEAD)" \
API_BASE_URL=https://ovtimer-proxy.example.com \
ENABLE_DEBUG_LOGGING=false \
npm run generate:environment # -> src/environments/environment.prod.ts
node scripts/generate-environment.mjs --out src/environments/environment.staging.ts writes to
another file, which is how additional stages can be added to angular.json.
The generator validates all variables up front and writes nothing when one is missing, so a
misconfigured pipeline fails instead of shipping a production: false artefact:
Missing environment variable(s): API_BASE_URL
Required by src/environments/environment.template.ts.
Nothing was written.
Placeholders inside single quotes become escaped string literals; unquoted placeholders must be a boolean or a number (values are validated, they cannot inject code).
In the pipeline
# .github/workflows/deploy.yml (app)
name: deploy-app
on:
push:
branches: [master]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Generate environment
env:
APP_STAGE: prod
APP_PRODUCTION: 'true'
APP_NAME: ÖV Timer
APP_VERSION: ${{ github.sha }}
API_BASE_URL: ${{ vars.PROXY_URL }}
ENABLE_DEBUG_LOGGING: 'false'
run: npm run generate:environment
- run: npm test
- run: npm run test:server
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: ovtimer
path: dist/ovtimer/browser
src/environments/environment.spec.ts fails the pipeline when a generated file still contains a
placeholder or has a value of the wrong type.
The proxy is deployed the same way, but it needs the secret that the app does not: run
node server/ojp-proxy.mjs with OJP_TOKEN (and ALLOWED_ORIGINS) set in its environment.
Alternative: if the pipeline should be the only writer of the generated file, add
src/environments/environment.prod.tsto.gitignore. Keep the generation step beforeng build, otherwise a fresh clone cannot run theproductionconfiguration.
Project structure
src/
├── app/
│ ├── app.ts / app.html # application shell (SBB header, sidebar with connection tabs)
│ ├── app.config.ts # providers
│ ├── app.routes.ts # lazy loaded routes
│ ├── core/connections/ # stored connections
│ │ ├── connection.models.ts # Connection model and its validator
│ │ └── connection-store.ts # localStorage backed store, exposed as signals
│ ├── core/ojp/ # station search client
│ │ ├── ojp.models.ts # StopPlace, GeoPosition, ...
│ │ └── ojp.service.ts # calls the proxy, parses the OJP XML with DOMParser
│ └── pages/station-search/ # start/destination form with station autocomplete
├── environments/ # environment configuration (see above)
│ ├── environment.model.ts # AppEnvironment contract (no secrets)
│ ├── environment.ts # committed local defaults
│ ├── environment.template.ts # template rendered by the pipeline
│ └── environment.prod.ts # generated for the production stage
├── styles.css # global styles
└── test-setup.ts # jsdom polyfills for the unit tests
server/ # OJP proxy (no dependencies)
├── ojp-proxy.mjs # HTTP server, routing, CORS, error mapping
├── ojp-client.mjs # request builder, query validation, upstream call
├── rate-limiter.mjs # per client burst/day limits and the global daily budget
├── load-env.mjs # reads server/.env when it exists
└── .env.example # template for the proxy configuration (copy to server/.env)
scripts/
└── generate-environment.mjs # renders an environment file from the pipeline variables