diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts index 91a99da..173dc3d 100644 --- a/apps/journal/app/lib/demo-bot.server.ts +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -413,9 +413,16 @@ export function pickLocale( return persona.locales[Math.floor(rand() * persona.locales.length)]!; } -// --- BRouter -------------------------------------------------------------- +// --- Route compute via the planner --------------------------------------- -const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; +/** + * The journal goes through the planner (not BRouter directly) because + * the planner is the BRouter client in this architecture — route + * compute, rate limiting, and the eventual move of BRouter off-box + * all live there. Same `PLANNER_URL` the interactive handoff routes + * use; falls back to the dev port in local work. + */ +const PLANNER_URL = process.env.PLANNER_URL ?? "http://localhost:3001"; export interface BrouterResult { gpx: string; @@ -430,12 +437,26 @@ export async function requestBrouterGpx( endpoints: Endpoints, { signal }: { signal?: AbortSignal } = {}, ): Promise { - const lonlats = `${endpoints.start[0]},${endpoints.start[1]}|${endpoints.end[0]},${endpoints.end[1]}`; - const url = `${BROUTER_URL}/brouter?lonlats=${lonlats}&profile=trekking&alternativeidx=0&format=gpx`; + const url = `${PLANNER_URL}/api/route`; + const body = { + waypoints: [ + { lon: endpoints.start[0], lat: endpoints.start[1] }, + { lon: endpoints.end[0], lat: endpoints.end[1] }, + ], + profile: "trekking", + format: "gpx" as const, + }; try { - const resp = await fetch(url, { signal }); + const resp = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal, + }); if (!resp.ok) { - if (resp.status === 500) return { kind: "no-route" }; + // Planner returns 422 for BRouter client errors (unroutable + // waypoints) and 5xx for upstream/planner failures. + if (resp.status === 422) return { kind: "no-route" }; return { kind: "upstream-error", status: resp.status }; } const gpx = await resp.text(); diff --git a/apps/journal/app/routes/api.v1.routes.compute.ts b/apps/journal/app/routes/api.v1.routes.compute.ts index 4828407..077d733 100644 --- a/apps/journal/app/routes/api.v1.routes.compute.ts +++ b/apps/journal/app/routes/api.v1.routes.compute.ts @@ -2,9 +2,9 @@ import type { Route } from "./+types/api.v1.routes.compute"; import { requireApiUser, apiError } from "~/lib/api-guard.server"; import { ComputeRouteRequestSchema, ERROR_CODES } from "@trails-cool/api"; -const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; +const PLANNER_URL = process.env.PLANNER_URL ?? "http://localhost:3001"; -/** POST /api/v1/routes/compute — proxy to BRouter */ +/** POST /api/v1/routes/compute — server-to-server proxy through the planner. */ export async function action({ request }: Route.ActionArgs) { if (request.method !== "POST") return new Response(null, { status: 405 }); await requireApiUser(request); @@ -16,19 +16,18 @@ export async function action({ request }: Route.ActionArgs) { parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); } - const { waypoints, profile } = parsed.data; - const lonlats = waypoints.map((w) => `${w.lon},${w.lat}`).join("|"); - - const brouterUrl = `${BROUTER_URL}/brouter?lonlats=${lonlats}&profile=${profile}&alternativeidx=0&format=geojson`; - try { - const resp = await fetch(brouterUrl); + const resp = await fetch(`${PLANNER_URL}/api/route`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(parsed.data), + }); if (!resp.ok) { - return apiError(502, ERROR_CODES.INTERNAL_ERROR, `BRouter returned ${resp.status}`); + return apiError(resp.status === 422 ? 422 : 502, ERROR_CODES.INTERNAL_ERROR, `Planner returned ${resp.status}`); } - const geojson = await resp.json(); - return Response.json(geojson); + const payload = await resp.json(); + return Response.json(payload); } catch { - return apiError(502, ERROR_CODES.INTERNAL_ERROR, "BRouter unavailable"); + return apiError(502, ERROR_CODES.INTERNAL_ERROR, "Planner unavailable"); } } diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index f1ab53e..a4e6810 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -284,3 +284,38 @@ function noGoAreasToParam(areas: NoGoArea[]): string { 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 fetch(`${BROUTER_URL}/brouter?${params}`); + if (!resp.ok) { + const body = await resp.text(); + throw new BRouterError(body.trim(), resp.status); + } + const gpx = await resp.text(); + if (!gpx.includes("; profile?: string; sessionId?: string; noGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; + format?: "geojson" | "gpx"; }; if (!waypoints || waypoints.length < 2) { @@ -35,6 +36,20 @@ export async function action({ request }: Route.ActionArgs) { } try { + // `format: "gpx"` returns BRouter's raw GPX without the way-tag + // enrichment the interactive planner needs. Used by server-to-server + // callers (e.g. the Journal's demo-bot) that just want the track. + if (format === "gpx") { + const gpx = await computeSegmentGpx({ waypoints, profile, noGoAreas }); + return new Response(gpx, { + status: 200, + headers: { + "content-type": "application/gpx+xml; charset=utf-8", + "x-ratelimit-remaining": String(limit.remaining), + }, + }); + } + const route = await computeRoute({ waypoints, profile, noGoAreas }); return data(route, { headers: { "X-RateLimit-Remaining": String(limit.remaining) },