import { mergeGeoJsonSegments, type EnrichedRoute, type NoGoArea, type Waypoint } from "./route-merge"; import { fetchWithTimeout } from "./http.server"; export { mergeGeoJsonSegments }; export type { EnrichedRoute, NoGoArea }; const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; // The BRouter host's Caddy sidecar requires every request to carry a // shared-secret header. Missing in production = every request 403s, so // crash loudly at startup rather than during the first route request. if (process.env.NODE_ENV === "production" && !process.env.BROUTER_AUTH_TOKEN) { throw new Error( "BROUTER_AUTH_TOKEN is required in production. The BRouter Caddy " + "sidecar rejects unauthenticated requests with 403. Check the " + "SOPS-encrypted infrastructure/secrets.app.env and the cd-apps " + "env wiring.", ); } // Read at call time so tests can stub via vi.stubEnv without module reset. function authHeaders(): HeadersInit { const token = process.env.BROUTER_AUTH_TOKEN; return token ? { "X-BRouter-Auth": token } : {}; } export interface RouteRequest { waypoints: Array<{ lat: number; lon: number }>; profile?: string; alternativeIdx?: number; format?: string; noGoAreas?: NoGoArea[]; } /** * Compute a route segment-by-segment between consecutive waypoints. * This matches bikerouter.de's behavior and guarantees the route * passes through every waypoint. */ export async function computeRoute(request: RouteRequest): Promise { if (request.waypoints.length < 2) { throw new Error("At least 2 waypoints are required"); } const pairs = request.waypoints.slice(0, -1).map((from, i) => ({ from, to: request.waypoints[i + 1]!, })); const segments = await fetchSegments({ pairs, profile: request.profile, noGoAreas: request.noGoAreas, }); return mergeGeoJsonSegments(segments); } export class BRouterError extends Error { readonly statusCode: number; constructor(message: string, statusCode: number) { super(message); this.name = "BRouterError"; this.statusCode = statusCode; } } // Refuse to consume responses larger than this from BRouter. Real // per-segment GeoJSON is typically <100 KB; even a long pan-Europe // segment with surface metadata stays under 2 MB. 10 MB is the // "definitely abuse or upstream bug" threshold — beyond that we'd // rather error than OOM. const MAX_BROUTER_RESPONSE_BYTES = 10 * 1024 * 1024; // Exported only for tests — see brouter.test.ts. export async function readBodyWithCap(response: Response, maxBytes: number): Promise { // `content-length` is advisory (BRouter sets it; a misbehaving // upstream might not). Reject up front if the declared length is // already over the cap. const declared = Number(response.headers.get("content-length")); if (Number.isFinite(declared) && declared > maxBytes) { throw new BRouterError(`response too large (${declared} bytes)`, 502); } // Stream the body, abort once we've seen `maxBytes`. The read + // done-check live in the for header so the loop reads as "iterate // over reads until done"; TS narrows `chunk.value` to Uint8Array // inside the body because the false-done branch rules out // `{ done: true, value: undefined }`. const reader = response.body?.getReader(); if (!reader) return await response.text(); const decoder = new TextDecoder(); let received = 0; let out = ""; for ( let chunk = await reader.read(); !chunk.done; chunk = await reader.read() ) { received += chunk.value.byteLength; if (received > maxBytes) { try { await reader.cancel(); } catch { /* ignore */ } throw new BRouterError(`response exceeded ${maxBytes} bytes`, 502); } out += decoder.decode(chunk.value, { stream: true }); } out += decoder.decode(); return out; } async function fetchSegment(url: string): Promise> { const response = await fetchWithTimeout(url, { headers: authHeaders() }); if (!response.ok) { const body = await response.text(); throw new BRouterError(body.trim(), response.status); } const raw = await readBodyWithCap(response, MAX_BROUTER_RESPONSE_BYTES); try { return JSON.parse(raw) as Record; } catch { throw new BRouterError("invalid JSON from upstream", 502); } } /** * Fetch a set of waypoint-pair segments from BRouter in parallel. Returned * segments are in the same order as the input `pairs`. Used by the * `/api/route-segments` endpoint: the browser cache sends only the pairs * it doesn't already have, and merges the response client-side. */ export async function fetchSegments(request: { pairs: Array<{ from: Waypoint; to: Waypoint }>; profile?: string; noGoAreas?: NoGoArea[]; }): Promise[]> { const profile = request.profile ?? "trekking"; const nogoParam = request.noGoAreas?.length ? noGoAreasToParam(request.noGoAreas) : undefined; return Promise.all( request.pairs.map(({ from, to }) => { const lonlats = `${from.lon},${from.lat}|${to.lon},${to.lat}`; const params = new URLSearchParams({ lonlats, profile, alternativeidx: "0", format: "geojson", tiledesc: "true", }); if (nogoParam) params.set("polygons", nogoParam); return fetchSegment(`${BROUTER_URL}/brouter?${params}`); }), ); } /** * Convert no-go area polygons to BRouter's `polygons` parameter format. * Format: lon1,lat1,lon2,lat2,...,lonN,latN separated by `|` per polygon. */ function noGoAreasToParam(areas: NoGoArea[]): string { return areas .map((area) => { if (area.points.length < 3) return null; return area.points.map((p) => `${p.lon},${p.lat}`).join(","); }) .filter(Boolean) .join("|"); } export function getBRouterUrl(): string { return BROUTER_URL; } /** * Single-segment GPX fetch. Used by callers (notably the Journal's * demo-bot) that only need the raw GPX track for two endpoints — no * multi-waypoint merge, no way-tag enrichment. Throws BRouterError on * non-2xx so the action handler can map it to an HTTP status. */ export async function computeSegmentGpx(request: { waypoints: Array<{ lat: number; lon: number }>; profile?: string; noGoAreas?: NoGoArea[]; }): Promise { if (request.waypoints.length < 2) { throw new Error("At least 2 waypoints are required"); } const profile = request.profile ?? "trekking"; const lonlats = request.waypoints.map((w) => `${w.lon},${w.lat}`).join("|"); const params = new URLSearchParams({ lonlats, profile, alternativeidx: "0", format: "gpx", }); const nogoParam = request.noGoAreas?.length ? noGoAreasToParam(request.noGoAreas) : undefined; if (nogoParam) params.set("polygons", nogoParam); const resp = await fetchWithTimeout(`${BROUTER_URL}/brouter?${params}`, { headers: authHeaders(), }); if (!resp.ok) { const body = await resp.text(); throw new BRouterError(body.trim(), resp.status); } // Same size cap as the GeoJSON path — GPX for a multi-day route // stays well under 10 MB. const gpx = await readBodyWithCap(resp, MAX_BROUTER_RESPONSE_BYTES); if (!gpx.includes("