exact time when to walk to station
Find a file
ReisMiner 5720ad729d
Some checks failed
/ Publish to Cloudflare Pages (push) Failing after 34s
Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/app/pages/station-search/station-search.ts
2026-10-05 15:02:02 +02:00
.forgejo/workflows ci: run the unit and proxy tests before deploying 2026-09-22 22:17:03 +02:00
.vscode initial commit 2026-09-21 19:03:01 +02:00
public chore(icon): update to own icon 2026-09-22 22:17:39 +02:00
scripts feat: ship the proxy for a Cloudflare Pages frontend 2026-09-22 17:15:45 +02:00
server feat: record the walk to the station and board the next departures 2026-09-22 19:18:27 +02:00
src Merge remote-tracking branch 'origin/master' 2026-10-05 15:02:02 +02:00
.editorconfig initial commit 2026-09-21 19:03:01 +02:00
.gitignore feat: proxy the OJP API so the token stays on the server 2026-09-22 16:22:09 +02:00
.prettierrc initial commit 2026-09-21 19:03:01 +02:00
angular.json feat: proxy the OJP API so the token stays on the server 2026-09-22 16:22:09 +02:00
compose.yaml fix: keep compose.yaml valid on Compose before v2.24 2026-09-22 21:52:01 +02:00
ovtimer.iml initial commit 2026-09-21 19:03:01 +02:00
package-lock.json update api url 2026-09-22 21:21:07 +02:00
package.json fix: run the proxy tests from a glob instead of a directory 2026-09-22 22:19:20 +02:00
README.md fix: keep compose.yaml valid on Compose before v2.24 2026-09-22 21:52:01 +02:00
tsconfig.app.json feat: replace the demo with OJP station search and stored connections 2026-09-22 15:51:05 +02:00
tsconfig.json initial commit 2026-09-21 19:03:01 +02:00
tsconfig.spec.json feat: replace the demo with OJP station search and stored connections 2026-09-22 15:51:05 +02:00

Ö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 key ovtimer.connections.v1 in localStorage. 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 localStorage is 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/cdk and @angular/animations to package.json,
  • registers node_modules/@sbb-esta/angular/typography.css in angular.json (the SBB theme: design tokens, typography and utility classes),
  • adds provideAnimationsAsync() to src/app/app.config.ts.

Two things were added on top of the schematic:

  • provideHttpClient() in src/app/app.config.ts — required by sbb-icon, which fetches icons from https://icons.app.sbb.ch, and by the station search client.
  • src/test-setup.ts (wired up via the setupFiles option of the test target) — polyfills matchMedia, ResizeObserver and IntersectionObserver, which jsdom does not implement and which the SBB components rely on. It also installs an in-memory localStorage, because Node 26 ships its own experimental localStorage global that shadows the jsdom one and stays undefined unless 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):

  1. a token bucket per client (default 20/min) stops bursts — a client that exceeds it gets 429 with Retry-After,
  2. a daily counter per client (default 200) stops one anonymous user from using up the day,
  3. a global daily budget (default 1000) stops the proxy before the upstream quota is drained — when it is used up the proxy answers 503 for 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_ORIGINS to your app's origin.
  • Pass OJP_TOKEN as a secret of the platform (not in a file in the image).
  • GET /health is there for the platform's health check.
  • Requests are validated (q length, control characters, limit bounds) before they cost quota, and upstream failures are mapped to 502/504 without 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_ORIGINS to your Pages origin (https://<project>.pages.dev, plus a custom domain and http://localhost:4200 while developing). A browser request from any other origin gets a 403 before 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=true only when the proxy is not reachable directly: the client IP then comes from X-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/.env and never ends up in a layer.
  • env_file uses the short syntax, so server/.env has to exist before docker compose up (cp server/.env.example server/.env). The long form (- path: …, required: false) needs Compose v2.24+ and is rejected by older versions with services.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_ORIGINS gets 403 origin_not_allowed (with the rejected origin in the proxy log) — never a 502.
  • For an allowed origin the header is set on every response, including the proxy's own 502, 503 and 504 JSON 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.ts to .gitignore. Keep the generation step before ng build, otherwise a fresh clone cannot run the production configuration.

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