// ── 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 { 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 { const params: Record = { 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 { 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 { 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 { const params: Record = { 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 { 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>(body: unknown): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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): Promise { 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); } }