Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
data
public
.git
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ jobs:
run: /usr/bin/time -f "%e s, %M KB peak memory" npm run build:api
env:
NODE_OPTIONS: --max-old-space-size=12288
- name: Search the real bundles
run: |
day=$(date -u -d tomorrow +%F)
TRAIN_API=public/v2 npm run --silent search -- paris madrid "$day" 07:00
TRAIN_API=public/v2 npm run --silent search -- madrid amsterdam "$day" 06:00
TRAIN_API=public/v2 OPERATORS=sncf,renfe npm run --silent search -- paris barcelona "$day"
- name: Sizes
run: |
du -sh public/v1/* public/v2/*
Expand Down
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# The journey search API: docker build -t train-api . && docker run -p 8080:8080 train-api
# It loads the published timetables at start and every 6 hours; see docs/developers.md.
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
ENV NODE_ENV=production PORT=8080
EXPOSE 8080
CMD ["npx", "tsx", "src/server.ts"]
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ Free, no key, CORS open, refreshed daily: start with the [developer guide](docs/
(endpoints, JS and Python examples, licences and credits, stability promise) and the
[OpenAPI spec](docs/openapi.yaml).

## Search journeys

`src/search.ts` finds journeys with changes across every operator, on the trip bundles. It
runs in the browser on downloaded bundles (free, offline once downloaded), from the command
line (`npm run search -- paris madrid 2026-10-12`), or as an HTTP API you host with the
`Dockerfile` (`/v2/places`, `/v2/journeys`, `/v2/reachable`). See the [developer guide](docs/developers.md#journey-search).

## How it works

1. `npm run fetch` downloads every source to `data/raw/`: the SNCF `tgvmax` feed (every
Expand Down
37 changes: 37 additions & 0 deletions docs/developers.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,43 @@ npm ci && npm run fetch && npm run build:api # writes public/v1/ and public/v2
The national feeds need about 12 GB of memory (`NODE_OPTIONS=--max-old-space-size=12288`).
Serve `public/` from any static host.

## Journey search

The trip bundles come with a journey search, `src/search.ts`: from a town or station to
another on a date, with changes across operators. Stations are matched across operators by
their coordinates (and by name where a source has none), and the stations of one town are
grouped, so a change can cross town. The same file runs in three places:

- **In the browser, free.** Copy `src/search.ts` (it has no imports), download the bundles
of the countries a trip needs from GitHub Pages, and search on the device. Paris to Madrid
needs `sncf` and `renfe`. Once downloaded, searches work offline.

```js
import { Network } from "./search.js";
const base = "https://offware-apps.github.io/train-api/v2/";
const bundles = await Promise.all(["sncf", "renfe"].map((op) => fetch(`${base}${op}/interrail.json`).then((r) => r.json())));
const net = new Network(bundles);
const journeys = net.journeys(net.resolve("paris"), net.resolve("madrid"), "2026-10-12", { time: "07:00" });
```

- **On the command line:** `npm run search -- paris madrid 2026-10-12 07:00`. Set
`TRAIN_API` to a folder (such as `public/v2` after a build, or a downloaded copy) to search
offline, and `OPERATORS=sncf,renfe` to load only some operators.

- **As an HTTP API you host:** `docker build -t train-api . && docker run -p 8080:8080 train-api`
(or `npm start`). No key, CORS open.

| Path | Content |
|------|---------|
| `/v2/places?q=madr` | towns and stations matching a name: `id`, `name`, `kind` (`city` or `station`), and each operator's name for them |
| `/v2/journeys?from=madrid&to=amsterdam&date=2026-10-12` | journeys leaving that day, earliest first; optional `time=08:00`, `results` (1 to 10, default 5), `maxTrains` (1 to 6, default 4). Each has `departure`, `arrival` (local times), `duration` in minutes, `changes` and its `legs`, plus the `sources` to credit |
| `/v2/reachable?from=madrid&date=2026-10-12` | every place reachable that day, each with its fastest journey, fastest first; optional `time` and `maxTrains` (1 to 4, default 3) |
| `/health` | whether the timetables are loaded, and how fresh |

`from` and `to` take an id from `/v2/places` or a name. Configuration: `PORT` (8080),
`TRAIN_API` (the bundles' base URL or folder; the public API by default), `OPERATORS`,
`PASS` (`interrail`), `RELOAD_HOURS` (6). All of Europe fits in a few hundred MB of memory.

## Help and requests

Open an issue on [offware-apps/train-api](https://github.com/offware-apps/train-api/issues)
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
"name": "train-api",
"version": "0.1.0",
"private": true,
"description": "Open train data for rail passes (Interrail first), built on SNCF open data and served as static JSON.",
"description": "Open train data for rail passes (Interrail first), served as static JSON, with a journey search that runs in the browser or as a self-hosted API.",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"fetch": "tsx scripts/fetch.ts",
"build:api": "tsx scripts/build-api.ts",
"search": "tsx scripts/search.ts",
"start": "tsx src/server.ts",
"check": "npm run typecheck && npm test"
},
"devDependencies": {
Expand Down
39 changes: 39 additions & 0 deletions scripts/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Search journeys from the command line, on the published bundles or on a folder of them:
* npm run search -- madrid amsterdam 2026-10-12 [08:00]
* TRAIN_API=public/v2 npm run search -- paris madrid 2026-10-12
* OPERATORS=sncf,renfe loads only those. Prints how each name matched, then the journeys.
*/
import { DEFAULT_API, loadBundles } from "../src/load";
import { Network } from "../src/search";

const [fromQ = "", toQ = "", date = new Date().toISOString().slice(0, 10), time] = process.argv.slice(2);
const operators = process.env.OPERATORS?.split(",").map((s) => s.trim()).filter(Boolean);
const t0 = Date.now();
const { index, bundles } = await loadBundles(process.env.TRAIN_API ?? DEFAULT_API, { operators });
const t1 = Date.now();
const network = new Network(bundles);
console.log(
`timetables ${index.updatedAt}: ${bundles.map((b) => b.operator).join(", ")}; ${network.places.length} places ` +
`(loaded in ${t1 - t0} ms, indexed in ${Date.now() - t1} ms)`,
);
const from = network.resolve(fromQ);
const to = network.resolve(toQ);
for (const [q, m] of [[fromQ, from], [toQ, to]] as const) {
const names = m ? m.places.map((i) => network.places[i]?.name).join("; ") : "";
console.log(`"${q}" → ${m ? `${m.kind} ${m.name} (${names})` : "no match"}`);
}
if (!from || !to) process.exit(1);
for (const round of ["first search", "again"]) {
const t2 = Date.now();
const journeys = network.journeys(from, to, date, { time });
console.log(`${round}: ${journeys.length} journeys in ${Date.now() - t2} ms`);
if (round !== "first search") break;
for (const j of journeys) {
console.log(`\n${j.departure} → ${j.arrival} ${Math.floor(j.duration / 60)}h${String(j.duration % 60).padStart(2, "0")}, ${j.changes} change(s)`);
for (const l of j.legs) {
console.log(` ${l.departure.slice(11)} ${l.from} → ${l.arrival.slice(11)} ${l.to} [${l.operator} ${l.category ?? ""} ${l.trainNo}]`);
}
}
console.log();
}
53 changes: 53 additions & 0 deletions src/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Load trip bundles (v2) for the search, from a published API (a URL) or from a folder of
* the same files on disk (`public/v2` after `npm run build:api`, or a download). Node only.
*/
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { ApiIndexV2 } from "./api";
import type { SearchBundle } from "./search";

export const DEFAULT_API = "https://offware-apps.github.io/train-api/v2/";

async function getJson<T>(base: string, path: string): Promise<T> {
if (/^https?:\/\//.test(base)) {
const url = new URL(path, base.endsWith("/") ? base : `${base}/`);
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return (await res.json()) as T;
}
return JSON.parse(await readFile(join(base, path), "utf-8")) as T;
}

export interface Loaded {
index: ApiIndexV2;
bundles: SearchBundle[];
/** Attribution per operator, for responses. */
sources: Map<string, string>;
}

/**
* The index, then the bundle of each operator that publishes the pass (all of them, or
* those in `operators`). An operator that fails to load is skipped.
*/
export async function loadBundles(base = DEFAULT_API, { pass = "interrail", operators }: { pass?: string; operators?: string[] } = {}): Promise<Loaded> {
const index = await getJson<ApiIndexV2>(base, "index.json");
const wanted = index.operators.filter(
(o) => o.passes.some((p) => p.id === pass) && (!operators?.length || operators.includes(o.id)),
);
const loaded = await Promise.all(
wanted.map(async (o) => {
try {
return await getJson<SearchBundle>(base, `${o.id}/${pass}.json`);
} catch (err) {
console.error(`[load] ${o.id}: skipped (${String(err)})`);
return null;
}
}),
);
return {
index,
bundles: loaded.filter((b): b is SearchBundle => b !== null),
sources: new Map(index.operators.map((o) => [o.id, o.source])),
};
}
Loading
Loading