trails/apps/planner/app/lib/brouter.ts
Ullrich Schäfer 10deae88c9
fix: extensionless server-side imports + lint rule to catch them
## Symptom

Grafana's \`app-crash-log\` alert fires on every deploy. The Loki query
that backs it (\`ERR_|FATAL|uncaughtException|…\`) matches:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/app/apps/journal/app/lib/logger.server' imported from
  '/app/apps/journal/app/lib/email.server.ts'

A real bug, not a false positive: \`email.server.ts\` line 2 was
\`import { logger } from \"./logger.server\"\` — no extension.

## Why typecheck didn't catch it

\`tsconfig.base.json\` sets \`moduleResolution: \"bundler\"\`. The bundler
resolver accepts extensionless relative imports because Vite / esbuild
/ webpack resolve them at build time. TypeScript was happy.

Production runs \`node --experimental-strip-types server.ts\`, which
uses Node's NodeNext ESM resolver — strict about extensions. The two
resolvers disagree silently for files Node loads directly (server.ts,
\`.server.ts\` modules dynamically imported from it, and jobs).

## Fix

1. **Added \`.ts\` extensions to the 4 broken imports** I could find:
   - \`apps/journal/app/lib/email.server.ts\` → \`./logger.server.ts\`
     (the actual deploy-blocker)
   - \`apps/planner/app/lib/brouter.ts\` → \`./route-merge.ts\`, \`./http.server.ts\`
   - \`apps/planner/app/lib/use-nearby-pois.ts\` → \`./overpass.ts\`
   - \`packages/map/src/MapView.tsx\` → \`./layers.ts\` (caught by the rule)

2. **Added \`eslint-plugin-import-x\` with \`import-x/extensions: always\`**,
   scoped to files Node actually executes raw:
   - \`apps/*/server.ts\`
   - \`apps/*/app/lib/**/*.server.ts\`
   - \`apps/*/app/jobs/**/*.ts\`
   - \`packages/*/src/**/*.{ts,tsx}\`

   Ignored: route-scoped \`*.server.ts\` files (bundled by Vite into the
   React Router build, never loaded by Node), and test files (Vitest's
   own resolver).

## What's still possible

- Non-\`.server.ts\` files in \`app/lib\` (e.g. \`legal.ts\`, \`actor-iri.ts\`)
  could still ship extensionless imports. They're not in the lint
  scope. If one breaks at runtime we can extend the glob — for now
  they tend to be tiny utility modules that don't import other
  relatives.
- The deeper fix would be \`moduleResolution: nodenext\` for server-side
  tsconfig, or bundling the server code so Node never sees raw \`.ts\`.
  Bigger surgery; the lint rule covers the failure mode for now.

Full repo: pnpm typecheck / lint / test all green after \`pnpm install\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:08:19 +02:00

207 lines
7.2 KiB
TypeScript

import { mergeGeoJsonSegments, type EnrichedRoute, type NoGoArea, type Waypoint } from "./route-merge.ts";
import { fetchWithTimeout } from "./http.server.ts";
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<EnrichedRoute> {
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<string> {
// `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<Record<string, unknown>> {
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<string, unknown>;
} 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<Record<string, unknown>[]> {
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<string> {
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("<trkpt")) throw new BRouterError("no route found", 422);
return gpx;
}