This commit is contained in:
Katharina 2026-07-10 10:54:05 +02:00
commit 28a45440aa
18 changed files with 4276 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
result

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# MultiTrain
Search train connections from **multiple start stations to multiple destination stations** in one query. Results are merged, deduplicated, and ranked; each station can carry a **penalty in minutes** (e.g. "Wien Meidling +10 because it takes me longer to get there") that is added to a journey's duration for ranking only — displayed times stay real.
Data comes from [Transitous](https://transitous.org), a community-run [MOTIS](https://github.com/motis-project/motis) instance aggregating open timetable data from many carriers (ÖBB, DB, and many more). Please respect their fair-use policy: set a `USER_AGENT` that identifies you.
## Development
```bash
nix develop # provides node 22 + npm + typescript-language-server
npm install
npm run dev # dev server on http://127.0.0.1:3000
```
Note: `tsx watch` does not detect file changes on Windows-mounted drives (`/mnt/*` in WSL) — restart manually there.
## Architecture
Lightweight hexagonal: `src/model` is the pure domain core (domain model, `JourneySource` port, search use case — fan-out over station pairs, merge, dedupe, penalty scoring). `src/adapter` holds the Fastify HTTP adapter and the Transitous adapter (the only place touching the MOTIS client libraries). `src/main.ts` wires everything by hand.
## API
- `GET /api/locations?query=wien` — station suggestions
- `POST /api/search` — body:
```json
{
"from": [{ "query": "Wien Hbf", "penalty": 0 }, { "query": "Wien Meidling", "penalty": 10 }],
"to": [{ "query": "München Hbf", "penalty": 0 }],
"departure": "2026-07-11T08:00:00Z"
}
```
`departure` and `arrival` are mutually exclusive; omit both to depart now.
## Configuration (environment)
| Variable | Default | Purpose |
|---|---|---|
| `PORT` | `3000` | HTTP port |
| `HOST` | `127.0.0.1` | Bind address |
| `USER_AGENT` | `multitrain` | Sent to the MOTIS API — please personalize |
| `MOTIS_BASE_URL` | api.transitous.org | Self-hosted MOTIS instance |
## Deployment on NixOS
```nix
# flake inputs
inputs.multitrain.url = "github:you/multitrain"; # or a path/git url
# NixOS configuration
{
imports = [ inputs.multitrain.nixosModules.default ];
services.multitrain = {
enable = true;
port = 3000;
host = "127.0.0.1";
userAgent = "multitrain (you@example.org)";
# motisBaseUrl = "https://motis.example.org";
};
}
```
`nix build` produces the server as `result/bin/multitrain`. After changing `package-lock.json`, refresh `npmDepsHash` in `flake.nix` (`nix run nixpkgs#prefetch-npm-deps -- package-lock.json`).

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1783389287,
"narHash": "sha256-0xIy4dVLqq47rA+mRy0hXDfjhQd4E5PoIns/RmB7nR4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0ad6f47ea4fe188f4bc8f0380f93ae8523337c6c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

123
flake.nix Normal file
View file

@ -0,0 +1,123 @@
{
description = "MultiTrain multi-origin/multi-destination train connection search";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
typescript-language-server
];
};
});
packages = forAllSystems (pkgs: {
default = (pkgs.buildNpmPackage.override { nodejs = pkgs.nodejs_22; }) {
pname = "multitrain";
version = "0.1.0";
src = self;
npmDepsHash = "sha256-HtdR6XNMXgMYdbty7mWxPvpRdQLbnja+fPNA9fUCqZM=";
nativeBuildInputs = [ pkgs.makeWrapper ];
installPhase = ''
runHook preInstall
npm prune --omit=dev
mkdir -p $out/lib/multitrain $out/bin
cp -r dist public node_modules package.json $out/lib/multitrain/
makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/multitrain \
--add-flags "$out/lib/multitrain/dist/main.js"
runHook postInstall
'';
};
});
nixosModules.default = { config, lib, pkgs, ... }:
let
cfg = config.services.multitrain;
in
{
options.services.multitrain = {
enable = lib.mkEnableOption "MultiTrain train connection search";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "multitrain from its flake";
description = "The MultiTrain package to run.";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port the HTTP server listens on.";
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address the HTTP server binds to.";
};
userAgent = lib.mkOption {
type = lib.types.str;
default = "multitrain";
description = "User-Agent sent to the MOTIS API (identify yourself per Transitous fair-use policy, e.g. with a contact address).";
};
motisBaseUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://motis.example.org";
description = "Base URL of a self-hosted MOTIS instance; null uses api.transitous.org.";
};
};
config = lib.mkIf cfg.enable {
systemd.services.multitrain = {
description = "MultiTrain train connection search";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
PORT = toString cfg.port;
HOST = cfg.host;
USER_AGENT = cfg.userAgent;
} // lib.optionalAttrs (cfg.motisBaseUrl != null) {
MOTIS_BASE_URL = cfg.motisBaseUrl;
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/multitrain";
DynamicUser = true;
Restart = "on-failure";
CapabilityBoundingSet = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
};
};
};
};
};
}

3020
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "multitrain",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Multi-origin/multi-destination train connection search",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js"
},
"dependencies": {
"@fastify/static": "^9.3.0",
"@motis-project/motis-fptf-client": "^6.5.2",
"fastify": "^5.10.0"
},
"devDependencies": {
"@types/hafas-client": "^6.3.1",
"@types/node": "^26.1.1",
"tsx": "^4.23.0",
"typescript": "^7.0.2"
}
}

252
public/app.js Normal file
View file

@ -0,0 +1,252 @@
"use strict";
let datalistCounter = 0;
let lastResults = [];
let arriveBy = false;
function addStationRow(container, values = {}) {
const row = document.createElement("div");
row.className = "station-row";
const input = document.createElement("input");
input.type = "text";
input.required = true;
input.placeholder = "Station name";
input.autocomplete = "off";
input.value = values.query ?? "";
const datalist = document.createElement("datalist");
datalist.id = `stations-${datalistCounter++}`;
input.setAttribute("list", datalist.id);
wireAutocomplete(input, datalist);
const penalty = document.createElement("input");
penalty.type = "number";
penalty.className = "penalty";
penalty.min = "0";
penalty.step = "1";
penalty.value = values.penalty ?? "0";
penalty.title = "Penalty in minutes (added to this station's journeys for ranking)";
const unit = document.createElement("span");
unit.className = "unit";
unit.textContent = "min";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "remove";
remove.textContent = "×";
remove.title = "Remove station";
remove.addEventListener("click", () => {
if (container.querySelectorAll(".station-row").length > 1) row.remove();
});
row.append(input, penalty, unit, remove, datalist);
container.appendChild(row);
return input;
}
function wireAutocomplete(input, datalist) {
let timer;
input.addEventListener("input", () => {
clearTimeout(timer);
const query = input.value.trim();
if (query.length < 2) return;
timer = setTimeout(async () => {
try {
const res = await fetch(`/api/locations?query=${encodeURIComponent(query)}`);
if (!res.ok) return;
const stations = await res.json();
datalist.replaceChildren(
...stations.map((s) => {
const option = document.createElement("option");
option.value = s.name;
return option;
}),
);
} catch {
/* autocomplete is best-effort */
}
}, 250);
});
}
function collectSide(containerId) {
return [...document.querySelectorAll(`#${containerId} .station-row`)]
.map((row) => ({
query: row.querySelector("input[type=text]").value.trim(),
penalty: Number(row.querySelector(".penalty").value) || 0,
}))
.filter((s) => s.query !== "");
}
async function runSearch(event) {
event.preventDefault();
const button = document.getElementById("search-button");
const feedback = document.getElementById("feedback");
const resultsSection = document.getElementById("results-section");
const body = { from: collectSide("from-rows"), to: collectSide("to-rows") };
const mode = document.getElementById("time-mode").value;
const timeValue = document.getElementById("time-value").value;
if (mode !== "now") {
if (!timeValue) {
showFeedback([`Please pick a ${mode === "departure" ? "departure" : "arrival"} time.`], "error");
return;
}
body[mode] = new Date(timeValue).toISOString();
}
arriveBy = mode === "arrival";
button.disabled = true;
button.textContent = "Searching…";
feedback.hidden = true;
try {
const res = await fetch("/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showFeedback([data.error ?? `Search failed (${res.status})`], "error");
resultsSection.hidden = true;
return;
}
lastResults = data.journeys;
const notes = [...(data.warnings ?? []), ...resolutionNotes(data.resolved)];
if (notes.length > 0) showFeedback(notes, "warning");
renderResults();
resultsSection.hidden = false;
} catch (error) {
showFeedback([`Search failed: ${error.message}`], "error");
} finally {
button.disabled = false;
button.textContent = "Search";
}
}
function resolutionNotes(resolved) {
const notes = [];
for (const side of ["from", "to"]) {
for (const r of resolved?.[side] ?? []) {
if (r.station && r.station.name.toLowerCase() !== r.query.toLowerCase()) {
notes.push(`"${r.query}" was interpreted as "${r.station.name}"`);
}
}
}
return notes;
}
function showFeedback(messages, kind) {
const feedback = document.getElementById("feedback");
feedback.className = kind;
feedback.replaceChildren(
...messages.map((m) => {
const p = document.createElement("p");
p.textContent = m;
return p;
}),
);
feedback.hidden = false;
}
function renderResults() {
const sortMode = document.getElementById("sort-mode").value;
const sorted = [...lastResults].sort((a, b) => {
if (sortMode === "time") {
return arriveBy ? b.arrival.localeCompare(a.arrival) : a.departure.localeCompare(b.departure);
}
return a.score - b.score;
});
const list = document.getElementById("results");
list.replaceChildren(...sorted.map(journeyCard));
if (sorted.length === 0) {
const empty = document.createElement("p");
empty.textContent = "No connections found.";
list.replaceChildren(empty);
}
}
function journeyCard(j) {
const item = document.createElement("li");
item.className = "journey";
const head = document.createElement("div");
head.className = "journey-head";
const times = document.createElement("span");
times.className = "times";
times.textContent = `${fmtTime(j.departure)}${fmtTime(j.arrival)}`;
const meta = document.createElement("span");
meta.className = "meta";
const penalty = j.fromPenalty + j.toPenalty;
meta.textContent =
`${fmtDuration(j.durationMinutes)} · ${j.transfers} transfer${j.transfers === 1 ? "" : "s"}` +
(penalty > 0 ? ` · +${penalty} min penalty → score ${j.score}` : "");
head.append(times, meta);
const route = document.createElement("div");
route.className = "route";
route.textContent = `${j.fromStation.name}${j.toStation.name}`;
const lines = document.createElement("div");
lines.className = "lines";
lines.replaceChildren(
...j.journey.legs
.filter((leg) => !leg.walking && leg.line)
.map((leg) => {
const badge = document.createElement("span");
badge.className = "line";
badge.textContent = leg.line.name;
return badge;
}),
);
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "Legs";
details.appendChild(summary);
for (const leg of j.journey.legs) {
const p = document.createElement("p");
p.className = "leg";
const what = leg.walking
? "walk"
: `${leg.line?.name ?? "?"}${leg.direction ? `${leg.direction}` : ""}`;
p.textContent = `${fmtTime(leg.departure)} ${leg.origin.name}${what}${fmtTime(leg.arrival)} ${leg.destination.name}`;
details.appendChild(p);
}
item.append(head, route, lines, details);
return item;
}
function fmtTime(iso) {
const date = new Date(iso);
const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const today = new Date();
const sameDay =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
return sameDay ? time : `${date.toLocaleDateString([], { day: "numeric", month: "numeric" })} ${time}`;
}
function fmtDuration(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h} h ${m} min` : `${m} min`;
}
document.getElementById("search-form").addEventListener("submit", runSearch);
document.getElementById("sort-mode").addEventListener("change", renderResults);
document.getElementById("time-mode").addEventListener("change", (event) => {
document.getElementById("time-value").hidden = event.target.value === "now";
});
for (const button of document.querySelectorAll("button.add")) {
button.addEventListener("click", () =>
addStationRow(document.getElementById(button.dataset.target)),
);
}
addStationRow(document.getElementById("from-rows"));
addStationRow(document.getElementById("to-rows"));

56
public/index.html Normal file
View file

@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MultiTrain</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>MultiTrain</h1>
<p class="subtitle">Train connections from many starts to many destinations, ranked with station penalties.</p>
<form id="search-form">
<div class="stations">
<fieldset>
<legend>From</legend>
<div id="from-rows" class="rows" data-side="from"></div>
<button type="button" class="add" data-target="from-rows">+ add start</button>
</fieldset>
<fieldset>
<legend>To</legend>
<div id="to-rows" class="rows" data-side="to"></div>
<button type="button" class="add" data-target="to-rows">+ add destination</button>
</fieldset>
</div>
<div class="time-row">
<select id="time-mode">
<option value="now" selected>Depart now</option>
<option value="departure">Depart at</option>
<option value="arrival">Arrive by</option>
</select>
<input type="datetime-local" id="time-value" hidden>
<button type="submit" id="search-button">Search</button>
</div>
</form>
<section id="feedback" hidden></section>
<section id="results-section" hidden>
<div class="results-header">
<h2>Connections</h2>
<label>Sort by
<select id="sort-mode">
<option value="score" selected>best (incl. penalty)</option>
<option value="time">departure time</option>
</select>
</label>
</div>
<ol id="results"></ol>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

153
public/style.css Normal file
View file

@ -0,0 +1,153 @@
:root {
color-scheme: light dark;
--bg: #fafafa;
--card: #ffffff;
--text: #1a1a1a;
--muted: #666;
--accent: #c8102e;
--border: #ddd;
--warning-bg: #fff6e0;
--error-bg: #ffe5e5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16181c;
--card: #1f2228;
--text: #eceff4;
--muted: #9aa2ad;
--accent: #ff5a70;
--border: #34383f;
--warning-bg: #3a3323;
--error-bg: #40272b;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
line-height: 1.45;
}
main {
max-width: 44rem;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
h1 { margin-bottom: 0.2rem; }
.subtitle { margin-top: 0; color: var(--muted); }
.stations {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 38rem) {
.stations { grid-template-columns: 1fr; }
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
background: var(--card);
}
.station-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.station-row input[type="text"] { flex: 1; min-width: 0; }
input, select, button {
font: inherit;
color: inherit;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.6rem;
}
.penalty { width: 4.2rem; }
.unit { color: var(--muted); font-size: 0.85rem; }
button { cursor: pointer; }
button.add { background: none; border: 1px dashed var(--border); width: 100%; color: var(--muted); }
button.remove { padding: 0.2rem 0.55rem; color: var(--muted); }
.time-row {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
#search-button {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
padding: 0.45rem 1.4rem;
margin-left: auto;
}
#search-button:disabled { opacity: 0.6; }
#feedback { border-radius: 8px; padding: 0.6rem 0.9rem; margin-top: 1rem; }
#feedback.warning { background: var(--warning-bg); }
#feedback.error { background: var(--error-bg); }
#feedback p { margin: 0.2rem 0; }
.results-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 1.5rem;
}
.results-header label { color: var(--muted); font-size: 0.9rem; }
#results { list-style: none; padding: 0; margin: 0; }
.journey {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 0.7rem;
}
.journey-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.3rem;
}
.times { font-weight: 700; font-variant-numeric: tabular-nums; }
.meta { color: var(--muted); font-size: 0.9rem; }
.route { color: var(--muted); font-size: 0.9rem; margin-top: 0.15rem; }
.lines { margin-top: 0.4rem; display: flex; flex-wrap: wrap; gap: 0.3rem; }
.line {
background: var(--accent);
color: #fff;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
}
details { margin-top: 0.5rem; }
summary { cursor: pointer; color: var(--muted); font-size: 0.9rem; }
.leg { margin: 0.25rem 0 0.25rem 1rem; font-size: 0.9rem; }

View file

@ -0,0 +1,27 @@
import type { FastifyInstance } from "fastify";
import type { SearchRequest } from "../../model/model.js";
import type { JourneySource } from "../../model/ports.js";
import { SearchError, SearchService } from "../../model/search.js";
export function registerRoutes(
app: FastifyInstance,
deps: { search: SearchService; source: JourneySource },
): void {
app.get<{ Querystring: { query?: string } }>("/api/locations", async (request) => {
const query = request.query.query?.trim() ?? "";
if (query.length < 2) return [];
return deps.source.suggestStations(query, 8);
});
app.post<{ Body: SearchRequest }>("/api/search", async (request, reply) => {
try {
return await deps.search.search(request.body ?? ({} as SearchRequest));
} catch (error) {
if (error instanceof SearchError) {
reply.code(400);
return { error: error.message };
}
throw error;
}
});
}

View file

@ -0,0 +1,130 @@
import { geocode } from "@motis-project/motis-client";
import { createClient } from "@motis-project/motis-fptf-client";
import { profile as transitousProfile } from "@motis-project/motis-fptf-client/p/transitous/index.js";
import type { Journey, Station } from "../../model/model.js";
import type { JourneySource, JourneysOptions } from "../../model/ports.js";
import { toGeocodeStation, toJourney } from "./mapper.js";
export interface TransitousConfig {
userAgent: string;
/** override api.transitous.org, e.g. for a self-hosted MOTIS */
baseUrl?: string;
stationCacheTtlMs?: number;
}
const SUGGESTIONS = 8;
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
export function createTransitousSource(config: TransitousConfig): JourneySource {
const baseUrl = config.baseUrl ?? (transitousProfile.baseUrl as string);
const profile = { ...transitousProfile, baseUrl };
// enrichStations would load a full station dataset into memory — not needed here
const client = createClient(profile, config.userAgent, { enrichStations: false });
const cache = new TtlCache<Station[]>(config.stationCacheTtlMs ?? DEFAULT_TTL_MS);
async function suggestStations(query: string, limit: number): Promise<Station[]> {
const key = query.trim().toLowerCase();
let stations = cache.get(key);
if (!stations) {
// straight to the MOTIS geocoder: the FPTF locations() parse drops the
// area info needed for recognizable station names
const result = await geocode({
throwOnError: true,
baseUrl,
headers: { "User-Agent": config.userAgent },
query: { text: query, type: ["STOP"] },
});
stations = (result.data ?? [])
.map(toGeocodeStation)
.filter((station): station is Station => station !== undefined);
cache.set(key, stations);
}
return stations.slice(0, limit);
}
return {
suggestStations,
async resolveStation(query: string): Promise<Station | undefined> {
return pickBestMatch(query, await suggestStations(query, SUGGESTIONS));
},
async journeys(from: Station, to: Station, options: JourneysOptions): Promise<Journey[]> {
// the client treats key *presence* as intent, so undefined keys must be omitted
const opt: Parameters<typeof client.journeys>[2] = {
results: options.results,
stopovers: false,
};
if (options.departure) opt.departure = options.departure;
if (options.arrival) opt.arrival = options.arrival;
const result = await client.journeys(from.id, to.id, opt);
return (result.journeys ?? [])
.map(toJourney)
.filter((journey): journey is Journey => journey !== undefined);
},
};
}
/**
* The geocoder's first hit is not always the intended station (e.g. "München
* Hbf" ranking "Hauptbahnhof Süd" first) prefer the candidate whose name
* matches the query best.
*/
function pickBestMatch(query: string, stations: Station[]): Station | undefined {
const queryTokens = tokenize(query);
let best: Station | undefined;
let bestScore = -Infinity;
for (const [index, station] of stations.entries()) {
const name = tokenize(station.name);
let score = 0;
if (name.join(" ") === queryTokens.join(" ")) score += 100;
score += 10 * queryTokens.filter((token) => name.includes(token)).length;
score -= name.length - queryTokens.length; // prefer names without extra words
score -= index * 0.1; // geocoder relevance as tie-breaker
if (score > bestScore) {
bestScore = score;
best = station;
}
}
return best;
}
const TOKEN_ALIASES: Record<string, string> = {
hbf: "hauptbahnhof",
bf: "bahnhof",
};
function tokenize(value: string): string[] {
return value
.toLowerCase()
.split(/[\s,()./-]+/)
.filter((token) => token !== "")
.map((token) => TOKEN_ALIASES[token] ?? token);
}
class TtlCache<V> {
private readonly entries = new Map<string, { value: V; expires: number }>();
constructor(
private readonly ttlMs: number,
private readonly maxEntries = 1000,
) {}
get(key: string): V | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expires) {
this.entries.delete(key);
return undefined;
}
return entry.value;
}
set(key: string, value: V): void {
if (this.entries.size >= this.maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest !== undefined) this.entries.delete(oldest);
}
this.entries.set(key, { value, expires: Date.now() + this.ttlMs });
}
}

View file

@ -0,0 +1,62 @@
import type { Match } from "@motis-project/motis-client";
import type * as fptf from "hafas-client";
import type { Journey, Leg, Line, Station } from "../../model/model.js";
export function toStation(
location: fptf.Station | fptf.Stop | fptf.Location | undefined,
): Station | undefined {
if (!location) return undefined;
const { id, name } = location;
if (!id && !name) return undefined;
return { id: id ?? name ?? "", name: name ?? id ?? "" };
}
/**
* Stop names in the feeds often lack the city ("Hauptbahnhof Süd" for München
* Hbf) append the geocoder's display area so names are recognizable.
*/
export function toGeocodeStation(match: Match): Station | undefined {
if (!match.id || !match.name) return undefined;
const city = match.areas.find((area) => area.default)?.name ?? match.areas[0]?.name;
const name =
city && !match.name.toLowerCase().includes(city.toLowerCase())
? `${match.name}, ${city}`
: match.name;
return { id: match.id, name };
}
function toLine(line: fptf.Line | undefined): Line | undefined {
if (!line?.name) return undefined;
return {
name: line.name,
mode: typeof line.mode === "string" ? line.mode : undefined,
product: line.product,
};
}
function toLeg(leg: fptf.Leg): Leg | undefined {
const origin = toStation(leg.origin);
const destination = toStation(leg.destination);
const departure = leg.departure ?? leg.plannedDeparture;
const arrival = leg.arrival ?? leg.plannedArrival;
if (!origin || !destination || !departure || !arrival) return undefined;
return {
origin,
destination,
departure,
arrival,
walking: leg.walking === true,
direction: leg.direction ?? undefined,
line: toLine(leg.line),
};
}
export function toJourney(journey: fptf.Journey): Journey | undefined {
const legs: Leg[] = [];
for (const raw of journey.legs ?? []) {
const leg = toLeg(raw);
if (!leg) return undefined;
legs.push(leg);
}
return legs.length > 0 ? { legs } : undefined;
}

View file

@ -0,0 +1,13 @@
declare module "@motis-project/motis-fptf-client" {
import type { HafasClient } from "hafas-client";
export function createClient(
profile: unknown,
userAgent: string,
opt?: { enrichStations?: boolean },
): HafasClient;
}
declare module "@motis-project/motis-fptf-client/p/transitous/index.js" {
export const profile: Record<string, unknown>;
}

26
src/main.ts Normal file
View file

@ -0,0 +1,26 @@
import fastifyStatic from "@fastify/static";
import Fastify from "fastify";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { registerRoutes } from "./adapter/http/routes.js";
import { createTransitousSource } from "./adapter/transitous/client.js";
import { SearchService } from "./model/search.js";
const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? "127.0.0.1";
const userAgent = process.env.USER_AGENT ?? "multitrain";
const baseUrl = process.env.MOTIS_BASE_URL;
const source = createTransitousSource({ userAgent, baseUrl });
const search = new SearchService(source);
const app = Fastify({ logger: true });
app.register(fastifyStatic, {
root: path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"),
});
registerRoutes(app, { search, source });
app.listen({ port, host }).catch((error) => {
app.log.error(error);
process.exit(1);
});

67
src/model/model.ts Normal file
View file

@ -0,0 +1,67 @@
export interface Station {
id: string;
name: string;
}
export interface Line {
name: string;
mode?: string;
product?: string;
}
export interface Leg {
origin: Station;
destination: Station;
/** ISO 8601, realtime where known */
departure: string;
/** ISO 8601, realtime where known */
arrival: string;
/** absent for walking legs */
line?: Line;
direction?: string;
walking: boolean;
}
export interface Journey {
legs: Leg[];
}
export interface StationQuery {
query: string;
/** minutes added to the score of journeys using this station */
penalty: number;
}
export interface SearchRequest {
from: StationQuery[];
to: StationQuery[];
/** ISO 8601; mutually exclusive with arrival; neither = depart now */
departure?: string;
arrival?: string;
}
export interface ResolvedStation {
query: string;
penalty: number;
station?: Station;
}
export interface ScoredJourney {
journey: Journey;
fromStation: Station;
toStation: Station;
fromPenalty: number;
toPenalty: number;
departure: string;
arrival: string;
durationMinutes: number;
transfers: number;
/** durationMinutes + fromPenalty + toPenalty — ranking only */
score: number;
}
export interface SearchResult {
journeys: ScoredJourney[];
resolved: { from: ResolvedStation[]; to: ResolvedStation[] };
warnings: string[];
}

14
src/model/ports.ts Normal file
View file

@ -0,0 +1,14 @@
import type { Journey, Station } from "./model.js";
export interface JourneysOptions {
departure?: Date;
arrival?: Date;
results?: number;
}
/** Driven port: where journeys and stations come from. */
export interface JourneySource {
suggestStations(query: string, limit: number): Promise<Station[]>;
resolveStation(query: string): Promise<Station | undefined>;
journeys(from: Station, to: Station, options: JourneysOptions): Promise<Journey[]>;
}

201
src/model/search.ts Normal file
View file

@ -0,0 +1,201 @@
import type {
Journey,
ResolvedStation,
ScoredJourney,
SearchRequest,
SearchResult,
Station,
StationQuery,
} from "./model.js";
import type { JourneySource, JourneysOptions } from "./ports.js";
/** Invalid input from the caller — the HTTP adapter maps this to a 400. */
export class SearchError extends Error {}
const MAX_STATIONS_PER_SIDE = 5;
const RESULTS_PER_PAIR = 4;
interface Pair {
from: ResolvedStation & { station: Station };
to: ResolvedStation & { station: Station };
}
export class SearchService {
constructor(
private readonly source: JourneySource,
private readonly concurrency = 4,
) {}
async search(request: SearchRequest): Promise<SearchResult> {
validate(request);
const warnings: string[] = [];
const [from, to] = await Promise.all([
this.resolveAll(request.from),
this.resolveAll(request.to),
]);
for (const r of [...from, ...to]) {
if (!r.station) warnings.push(`No station found for "${r.query}"`);
}
const pairs: Pair[] = [];
for (const f of from) {
for (const t of to) {
if (!f.station || !t.station) continue;
if (f.station.id === t.station.id) {
warnings.push(`Skipped ${f.station.name}${t.station.name}: same station`);
continue;
}
pairs.push({ from: f, to: t } as Pair);
}
}
if (pairs.length === 0) {
throw new SearchError("No usable station pair — check the station names");
}
const options: JourneysOptions = {
departure: request.departure
? new Date(request.departure)
: request.arrival
? undefined
: new Date(),
arrival: request.arrival ? new Date(request.arrival) : undefined,
results: RESULTS_PER_PAIR,
};
const settled = await mapWithConcurrency(pairs, this.concurrency, (pair) =>
this.source.journeys(pair.from.station, pair.to.station, options),
);
const best = new Map<string, ScoredJourney>();
settled.forEach((result, i) => {
const pair = pairs[i];
if (result.status === "rejected") {
warnings.push(
`${pair.from.station.name}${pair.to.station.name}: ${errorMessage(result.reason)}`,
);
return;
}
for (const journey of result.value) {
const scored = score(journey, pair);
if (!scored) continue;
const key = dedupeKey(journey);
const existing = best.get(key);
if (!existing || scored.score < existing.score) best.set(key, scored);
}
});
const arriveBy = request.arrival !== undefined;
const journeys = [...best.values()].sort((a, b) => {
if (a.score !== b.score) return a.score - b.score;
// ties: leave as late as possible when arriving by, else depart early
return arriveBy
? b.departure.localeCompare(a.departure)
: a.departure.localeCompare(b.departure);
});
return { journeys, resolved: { from, to }, warnings };
}
private resolveAll(queries: StationQuery[]): Promise<ResolvedStation[]> {
return Promise.all(
queries.map(async (q) => ({
query: q.query,
penalty: q.penalty,
station: await this.source.resolveStation(q.query),
})),
);
}
}
function validate(request: SearchRequest): void {
for (const [side, queries] of [
["from", request.from],
["to", request.to],
] as const) {
if (!Array.isArray(queries) || queries.length === 0) {
throw new SearchError(`"${side}" needs at least one station`);
}
if (queries.length > MAX_STATIONS_PER_SIDE) {
throw new SearchError(`"${side}" allows at most ${MAX_STATIONS_PER_SIDE} stations`);
}
for (const q of queries) {
if (typeof q.query !== "string" || q.query.trim() === "") {
throw new SearchError(`"${side}" contains an empty station name`);
}
if (typeof q.penalty !== "number" || !Number.isFinite(q.penalty) || q.penalty < 0) {
throw new SearchError(`Penalty for "${q.query}" must be a non-negative number of minutes`);
}
}
}
if (request.departure && request.arrival) {
throw new SearchError('Only one of "departure" and "arrival" may be set');
}
for (const [name, value] of [
["departure", request.departure],
["arrival", request.arrival],
] as const) {
if (value !== undefined && Number.isNaN(Date.parse(value))) {
throw new SearchError(`"${name}" is not a valid ISO 8601 time`);
}
}
}
function score(journey: Journey, pair: Pair): ScoredJourney | undefined {
const legs = journey.legs;
if (legs.length === 0) return undefined;
const departure = legs[0].departure;
const arrival = legs[legs.length - 1].arrival;
const durationMinutes = Math.round((Date.parse(arrival) - Date.parse(departure)) / 60_000);
if (!Number.isFinite(durationMinutes) || durationMinutes < 0) return undefined;
return {
journey,
fromStation: pair.from.station,
toStation: pair.to.station,
fromPenalty: pair.from.penalty,
toPenalty: pair.to.penalty,
departure,
arrival,
durationMinutes,
transfers: Math.max(0, legs.filter((l) => !l.walking).length - 1),
score: durationMinutes + pair.from.penalty + pair.to.penalty,
};
}
/**
* Overlapping station queries (e.g. a "Wien" meta-station and "Wien Hbf") can
* yield the same physical journey twice identify it by its timing and lines.
*/
function dedupeKey(journey: Journey): string {
const legs = journey.legs;
const lines = legs
.filter((l) => !l.walking)
.map((l) => l.line?.name ?? "?")
.join(",");
return `${legs[0].departure}|${legs[legs.length - 1].arrival}|${lines}`;
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<PromiseSettledResult<R>[]> {
const results: PromiseSettledResult<R>[] = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++;
try {
results[i] = { status: "fulfilled", value: await fn(items[i]) };
} catch (reason) {
results[i] = { status: "rejected", reason };
}
}
});
await Promise.all(workers);
return results;
}

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}