Merge pull request #239 from trails-cool/feat/overpass-proxy
Route Overpass through server-side proxy with cache + coalescing
This commit is contained in:
commit
febdf6a9d3
4 changed files with 122 additions and 28 deletions
|
|
@ -1,9 +1,6 @@
|
|||
import type { PoiCategory } from "@trails-cool/map-core";
|
||||
|
||||
const OVERPASS_ENDPOINTS = [
|
||||
"https://overpass.private.coffee/api/interpreter",
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
];
|
||||
const OVERPASS_PROXY = "/api/overpass";
|
||||
|
||||
export interface Poi {
|
||||
id: number;
|
||||
|
|
@ -97,6 +94,10 @@ export function deduplicateById(pois: Poi[]): Poi[] {
|
|||
|
||||
/**
|
||||
* 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(
|
||||
bbox: BBox,
|
||||
|
|
@ -107,30 +108,7 @@ export async function queryPois(
|
|||
|
||||
const query = buildQuery(bbox, categories);
|
||||
|
||||
for (const endpoint of OVERPASS_ENDPOINTS) {
|
||||
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, {
|
||||
const response = await fetch(OVERPASS_PROXY, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@ export default [
|
|||
route("new", "routes/new.tsx"),
|
||||
route("api/sessions", "routes/api.sessions.ts"),
|
||||
route("api/route", "routes/api.route.ts"),
|
||||
route("api/overpass", "routes/api.overpass.ts"),
|
||||
route("session/:id", "routes/session.$id.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
114
apps/planner/app/routes/api.overpass.ts
Normal file
114
apps/planner/app/routes/api.overpass.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ services:
|
|||
restart: unless-stopped
|
||||
environment:
|
||||
BROUTER_URL: http://brouter:17777
|
||||
OVERPASS_URL: https://overpass.private.coffee/api/interpreter
|
||||
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue