Route Overpass through server-side proxy with cache + coalescing

Adds apps/planner/app/routes/api.overpass.ts — a same-origin, rate-limited
server-side proxy that forwards Overpass QL to the upstream endpoint
configured via OVERPASS_URL (default: overpass.private.coffee).

Motivation:
- private.coffee's best-practices require a meaningful User-Agent
  identifying the project. Browsers cannot set User-Agent on fetch()
  (forbidden header), so the request has to originate server-side.
- Collaborative sessions commonly have N clients pan/zoom the same map,
  so the same bbox query arrives multiple times within seconds.
- Setting up the proxy now lets us swap OVERPASS_URL to a self-hosted
  Overpass later without client changes.

What the proxy does:
- Sets User-Agent "trails.cool Planner (https://trails.cool; legal@trails.cool)"
- Enforces same-origin via the Origin header
- Rate-limits per client IP (120 req/min)
- In-memory LRU cache of upstream responses keyed on the form-encoded body
  (TTL 10 min, max 200 entries)
- Coalesces concurrent misses for the same key so N simultaneous clients
  in one session incur exactly one upstream call

Client change: apps/planner/app/lib/overpass.ts POSTs to /api/overpass
instead of iterating over public Overpass endpoints. Fallback list
removed; resilience is now the proxy's responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-18 02:04:50 +02:00
parent bf8cdd5cdd
commit a4df5a43f6
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 122 additions and 28 deletions

View file

@ -1,9 +1,6 @@
import type { PoiCategory } from "@trails-cool/map-core"; import type { PoiCategory } from "@trails-cool/map-core";
const OVERPASS_ENDPOINTS = [ const OVERPASS_PROXY = "/api/overpass";
"https://overpass.private.coffee/api/interpreter",
"https://overpass-api.de/api/interpreter",
];
export interface Poi { export interface Poi {
id: number; id: number;
@ -97,6 +94,10 @@ export function deduplicateById(pois: Poi[]): Poi[] {
/** /**
* Query the Overpass API for POIs within a bounding box. * Query the Overpass API for POIs within a bounding box.
*
* All queries route through the Planner's own `/api/overpass` proxy, which
* adds a User-Agent identifying trails.cool, rate-limits per IP, and enforces
* same-origin. The client never talks to a public Overpass host directly.
*/ */
export async function queryPois( export async function queryPois(
bbox: BBox, bbox: BBox,
@ -107,30 +108,7 @@ export async function queryPois(
const query = buildQuery(bbox, categories); const query = buildQuery(bbox, categories);
for (const endpoint of OVERPASS_ENDPOINTS) { const response = await fetch(OVERPASS_PROXY, {
try {
return await fetchFromEndpoint(endpoint, query, categories, signal);
} catch (err) {
// If aborted, don't try fallback
if (signal?.aborted) throw err;
// If rate limited on all endpoints, throw
if (err instanceof OverpassRateLimitError && endpoint === OVERPASS_ENDPOINTS[OVERPASS_ENDPOINTS.length - 1]) throw err;
// Try next endpoint
if (endpoint !== OVERPASS_ENDPOINTS[OVERPASS_ENDPOINTS.length - 1]) continue;
throw err;
}
}
return [];
}
async function fetchFromEndpoint(
endpoint: string,
query: string,
categories: PoiCategory[],
signal?: AbortSignal,
): Promise<Poi[]> {
const response = await fetch(endpoint, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `data=${encodeURIComponent(query)}`, body: `data=${encodeURIComponent(query)}`,

View file

@ -5,5 +5,6 @@ export default [
route("new", "routes/new.tsx"), route("new", "routes/new.tsx"),
route("api/sessions", "routes/api.sessions.ts"), route("api/sessions", "routes/api.sessions.ts"),
route("api/route", "routes/api.route.ts"), route("api/route", "routes/api.route.ts"),
route("api/overpass", "routes/api.overpass.ts"),
route("session/:id", "routes/session.$id.tsx"), route("session/:id", "routes/session.$id.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;

View file

@ -0,0 +1,114 @@
import type { Route } from "./+types/api.overpass";
import { checkRateLimit } from "~/lib/rate-limit";
const UPSTREAM_URL = process.env.OVERPASS_URL ?? "https://overpass.private.coffee/api/interpreter";
const USER_AGENT = "trails.cool Planner (https://trails.cool; legal@trails.cool)";
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 200;
interface CacheEntry {
body: string;
contentType: string;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<{ body: string; contentType: string; status: number; cacheable: boolean }>>();
function getFromCache(key: string): CacheEntry | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
// LRU touch: move to end so oldest-first eviction works
cache.delete(key);
cache.set(key, entry);
return entry;
}
function putInCache(key: string, body: string, contentType: string) {
while (cache.size >= CACHE_MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
cache.set(key, { body, contentType, expiresAt: Date.now() + CACHE_TTL_MS });
}
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const origin = request.headers.get("origin");
const requestUrl = new URL(request.url);
const expectedOrigin = `${requestUrl.protocol}//${requestUrl.host}`;
if (!origin || origin !== expectedOrigin) {
return new Response("Forbidden", { status: 403 });
}
const body = await request.text();
const cacheKey = body;
// Cache hit: skip rate limit and upstream entirely
const cached = getFromCache(cacheKey);
if (cached) {
return new Response(cached.body, {
status: 200,
headers: { "Content-Type": cached.contentType, "X-Cache": "hit" },
});
}
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const limit = checkRateLimit(`overpass:${ip}`, { maxRequests: 120, windowMs: 60 * 1000 });
if (!limit.allowed) {
return new Response("Rate limit exceeded", {
status: 429,
headers: { "Retry-After": String(limit.retryAfterSeconds ?? 60) },
});
}
// Coalesce concurrent misses for the same key so multiple clients in the
// same session don't each hit upstream for the identical bbox.
let pending = inFlight.get(cacheKey);
if (!pending) {
pending = (async () => {
try {
const upstream = await fetch(UPSTREAM_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": USER_AGENT,
},
body,
});
const responseBody = await upstream.text();
const contentType = upstream.headers.get("content-type") ?? "application/json";
const cacheable = upstream.status === 200 && !responseBody.includes("rate_limited");
return { body: responseBody, contentType, status: upstream.status, cacheable };
} finally {
inFlight.delete(cacheKey);
}
})();
inFlight.set(cacheKey, pending);
}
let result;
try {
result = await pending;
} catch {
return new Response("Upstream Overpass unavailable", { status: 502 });
}
if (result.cacheable) {
putInCache(cacheKey, result.body, result.contentType);
}
return new Response(result.body, {
status: result.status,
headers: { "Content-Type": result.contentType, "X-Cache": "miss" },
});
}

View file

@ -48,6 +48,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
BROUTER_URL: http://brouter:17777 BROUTER_URL: http://brouter:17777
OVERPASS_URL: https://overpass.private.coffee/api/interpreter
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails
NODE_ENV: production NODE_ENV: production
PORT: 3001 PORT: 3001