b3edf2c47b
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.
367 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|