Files
time_to_leave/packages/api-client/src/client.ts
T
fegger b3edf2c47b Support multiple API base URLs with fallback
Support Multiple API Base URLs With Fallback

Make ApiClient accept multiple base URLs and try the next when
responses indicate unavailability (408, 429, 502, 503, 504 or any >=500)
or on network errors. Add fetchApi helper and selection logic, and use a
Tailscale dev server as a fallback in the mobile service. Also update
EventHeader to show a live leave-by countdown.
2026-05-18 12:43:04 +02:00

367 lines
12 KiB
TypeScript

// ── API Client ──
// Thin wrapper around the server-side API routes. Handles journey search,
// geocoding, bike/walk routing, calendar fetching, and WienerLinien monitoring.
import type {
GeocodeResult,
NearbyStop,
BikeRoute,
WalkRoute,
CalendarEvent,
Journey,
Station,
WienerLinienDeparture,
} from "@timetoleave/core";
import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core";
const DEFAULT_BASE_URL = "";
const UNAVAILABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
type SearchJourneyOptions = {
arriveBy?: boolean;
};
/**
* Fallback window (2 hours) to search backwards when an arrive-by query
* returns no journeys. HAFAS sometimes fails to find connections if the
* target time is too tight.
*/
const ARRIVE_BY_FALLBACK_WINDOW_MS = 2 * 60 * 60 * 1000;
function buildUrl(base: string, path: string, params: Record<string, string> = {}): string {
const queryString = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const full = `${base}${path}`;
return queryString ? `${full}?${queryString}` : full;
}
function buildTripSearchBody(
fromStationExtId: string,
toStationExtId: string,
hafasDate: string,
hafasTime: string,
arriveBy: boolean,
numF = 5,
) {
return {
svcReqL: [
{
meth: "TripSearch",
req: {
depLocL: [{ type: "S", extId: fromStationExtId }],
arrLocL: [{ type: "S", extId: toStationExtId }],
outDate: hafasDate,
outTime: hafasTime,
outFrwd: !arriveBy,
numF,
},
},
],
};
}
/**
* Normalize HAFAS coordinates. HAFAS may return coordinates in either
* decimal degrees or micro-degrees (1e6 factor). This detects and fixes it.
*/
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
if (value == null) return undefined;
return Math.abs(value) > 1000 ? value / 1e6 : value;
}
type HafasLocationWithCoords = Station & {
type: string;
lon?: number;
crd?: {
x?: number;
y?: number;
};
};
function toStation(location: HafasLocationWithCoords): Station {
return {
name: location.name,
extId: location.extId,
lat: normalizeHafasCoordinate(location.lat ?? location.crd?.y),
lng: normalizeHafasCoordinate(location.lng ?? location.lon ?? location.crd?.x),
};
}
/**
* Client for the TimeToLeave server API. Proxy requests to HAFAS, OSRM,
* Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app.
*/
export class ApiClient {
private readonly baseUrls: string[];
constructor(baseUrl?: string | string[]) {
const baseUrls = Array.isArray(baseUrl) ? baseUrl : [baseUrl ?? DEFAULT_BASE_URL];
this.baseUrls = Array.from(new Set(baseUrls.map((url) => url?.trim()).filter((url): url is string => Boolean(url))));
if (this.baseUrls.length === 0) {
this.baseUrls = [DEFAULT_BASE_URL];
}
}
async getHealth(): Promise<{ status: "ok"; uptime: number }> {
const res = await this.fetchApi("/api/health");
if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
return res.json();
}
async geocode(name: string, countrycodes?: string): Promise<GeocodeResult[]> {
const params: Record<string, string> = { name };
if (countrycodes) params.countrycodes = countrycodes;
const res = await this.fetchApi("/api/geocode", undefined, params);
if (!res.ok) throw new Error(`Geocode failed: ${res.status}`);
const result: GeocodeResult = await res.json();
return [result];
}
async getBikeRoute(
fromLat: number,
fromLng: number,
toLat: number,
toLng: number,
): Promise<BikeRoute> {
const res = await this.fetchApi("/api/bike-route", undefined, {
fromLat: String(fromLat),
fromLng: String(fromLng),
toLat: String(toLat),
toLng: String(toLng),
});
if (!res.ok) throw new Error(`Bike route failed: ${res.status}`);
return res.json();
}
async getWalkRoute(
fromLat: number,
fromLng: number,
toLat: number,
toLng: number,
): Promise<WalkRoute> {
const res = await this.fetchApi("/api/walk-route", undefined, {
fromLat: String(fromLat),
fromLng: String(fromLng),
toLat: String(toLat),
toLng: String(toLng),
});
if (!res.ok) throw new Error(`Walk route failed: ${res.status}`);
return res.json();
}
async fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]> {
const params: Record<string, string> = { url };
if (days) params.days = String(days);
const res = await this.fetchApi("/api/calendar", undefined, params);
if (!res.ok) throw new Error(`Calendar fetch failed: ${res.status}`);
return res.json();
}
async parseCalendarIcs(content: string): Promise<CalendarEvent[]> {
const res = await this.fetchApi("/api/calendar/parse", {
method: "POST",
headers: { "Content-Type": "text/calendar" },
body: content,
});
if (!res.ok) throw new Error(`Calendar parse failed: ${res.status}`);
return res.json();
}
/**
* Send an arbitrary HAFAS protocol body to the /api/hafas endpoint.
* This handles TripSearch, LocMatch, and any other HAFAS methods.
*/
async hafasRequest<T = Record<string, unknown>>(body: unknown): Promise<T> {
const res = await this.fetchApi("/api/hafas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HAFAS request failed: ${res.status}`);
return res.json();
}
async searchJourneys(
fromStationExtId: string,
toStationExtId: string,
date: Date,
options: SearchJourneyOptions = {},
): Promise<Journey[]> {
const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
const arriveBy = options.arriveBy === true;
const journeys = await this.fetchTripSearch(
buildTripSearchBody(fromStationExtId, toStationExtId, hafasDate, hafasTime, arriveBy),
hafasDate,
date,
);
if (!arriveBy || journeys.length > 0) {
return journeys;
}
const fallbackDate = new Date(date.getTime() - ARRIVE_BY_FALLBACK_WINDOW_MS);
const { date: fallbackHafasDate, time: fallbackHafasTime } = hafasDateTime(fallbackDate);
return this.fetchTripSearch(
buildTripSearchBody(fromStationExtId, toStationExtId, fallbackHafasDate, fallbackHafasTime, false, 10),
fallbackHafasDate,
fallbackDate,
);
}
private async fetchTripSearch(body: unknown, hafasDate: string, queryDate: Date): Promise<Journey[]> {
const res = await this.fetchApi("/api/hafas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`);
const data = await res.json();
return parseHafasJourneys(data, hafasDate, queryDate);
}
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
const res = await this.fetchApi("/api/geocode/reverse", undefined, {
lat: String(lat),
lng: String(lng),
});
if (!res.ok) return null;
return res.json();
}
async findNearbyStops(lat: number, lng: number, radius: number = 1000): Promise<NearbyStop[]> {
const res = await this.fetchApi("/api/wienerlinien/stops", undefined, {
lat: String(lat),
lng: String(lng),
radius: String(radius),
});
if (!res.ok) throw new Error(`Nearby stops failed: ${res.status}`);
const data = await res.json();
return data.stops ?? [];
}
async searchStation(query: string): Promise<Station[]> {
const result = await this.hafasRequest<{
svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
}>({
svcReqL: [
{
meth: "LocMatch",
req: { input: { loc: { name: query, type: "S" }, maxLoc: 5, field: "S" } },
},
],
});
const locL = result?.svcResL?.[0]?.res?.match?.locL ?? [];
return locL
.filter((l) => l.type === "S")
.map(toStation);
}
async findStationByExtId(extId: string): Promise<Station | null> {
const result = await this.hafasRequest<{
svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
}>({
svcReqL: [
{
meth: "LocMatch",
req: { input: { loc: { extId, type: "S" }, maxLoc: 1, field: "S" } },
},
],
});
const station = result?.svcResL?.[0]?.res?.match?.locL?.find((location) => location.type === "S");
return station ? toStation(station) : null;
}
/**
* Find the nearest station to given GPS coordinates using HAFAS LocMatch.
* This is a reliable fallback that works even when the nearby-stops proxy
* is unavailable or the base URL is empty.
*/
async findNearestStationByCoords(lat: number, lng: number): Promise<Station | null> {
const result = await this.hafasRequest<{
svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
}>({
svcReqL: [
{
meth: "LocMatch",
req: {
input: {
loc: {
crd: {
x: Math.round(lng * 1e6),
y: Math.round(lat * 1e6),
},
type: "S",
},
maxLoc: 5,
field: "S",
},
},
},
],
});
const stations: HafasLocationWithCoords[] = result?.svcResL?.[0]?.res?.match?.locL ?? [];
if (stations.length === 0) return null;
// Filter to only "S" (station) type results, then pick the closest
const stationResults = stations.filter((s: HafasLocationWithCoords) => s.type === "S");
if (stationResults.length === 0) return null;
// Find the closest station by Euclidean distance
const closest = stationResults.reduce((best: HafasLocationWithCoords, candidate: HafasLocationWithCoords) => {
const bestLat = normalizeHafasCoordinate(best.lat ?? best.crd?.y) ?? lat;
const bestLng = normalizeHafasCoordinate(best.lng ?? best.lon ?? best.crd?.x) ?? lng;
const candidateLat = normalizeHafasCoordinate(candidate.lat ?? candidate.crd?.y) ?? lat;
const candidateLng = normalizeHafasCoordinate(candidate.lng ?? candidate.lon ?? candidate.crd?.x) ?? lng;
const bestDist = Math.hypot(bestLat - lat, bestLng - lng);
const candDist = Math.hypot(candidateLat - lat, candidateLng - lng);
return candDist < bestDist ? candidate : best;
}, stationResults[0]);
return toStation(closest);
}
async monitorStops(stopIds: string[]): Promise<WienerLinienDeparture[]> {
if (stopIds.length === 0) return [];
const params = new URLSearchParams();
for (const id of stopIds) {
params.append("stopIds", id);
}
const res = await this.fetchApi(`/api/wienerlinien/monitor?${params.toString()}`);
if (!res.ok) throw new Error(`Monitor failed: ${res.status}`);
const data: { departures?: WienerLinienDeparture[] } = await res.json();
return data.departures ?? [];
}
private async fetchApi(path: string, init?: RequestInit, params?: Record<string, string>): Promise<Response> {
let lastError: unknown;
let lastResponse: Response | null = null;
for (const baseUrl of this.baseUrls) {
try {
const res = await fetch(buildUrl(baseUrl, path, params), init);
if (res.ok || !this.shouldTryNextBaseUrl(res.status)) {
return res;
}
lastResponse = res;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("API request failed");
}
private shouldTryNextBaseUrl(status: number): boolean {
return status >= 500 || UNAVAILABLE_STATUSES.has(status);
}
}