Add BRouter proxy, rate limiting, and spec updates (tasks 5.1-5.2) (#11)

This commit is contained in:
Ullrich Schäfer 2026-03-22 23:43:47 +01:00 committed by GitHub
parent 9deda5f125
commit 2d02ae3f9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 173 additions and 5 deletions

View file

@ -0,0 +1,37 @@
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
export interface RouteRequest {
waypoints: Array<{ lat: number; lon: number }>;
profile?: string;
alternativeIdx?: number;
format?: string;
}
export async function computeRoute(request: RouteRequest): Promise<unknown> {
if (request.waypoints.length < 2) {
throw new Error("At least 2 waypoints are required");
}
const lonlats = request.waypoints.map((wp) => `${wp.lon},${wp.lat}`).join("|");
const params = new URLSearchParams({
lonlats,
profile: request.profile ?? "trekking",
alternativeidx: String(request.alternativeIdx ?? 0),
format: request.format ?? "geojson",
});
const url = `${BROUTER_URL}/brouter?${params}`;
const response = await fetch(url);
if (!response.ok) {
const body = await response.text();
throw new Error(`BRouter error (${response.status}): ${body}`);
}
return response.json();
}
export function getBRouterUrl(): string {
return BROUTER_URL;
}

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { checkRateLimit } from "./rate-limit";
describe("checkRateLimit", () => {
it("allows requests within limit", () => {
const result = checkRateLimit("test-allow", { maxRequests: 5, windowMs: 60000 });
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
});
it("blocks requests exceeding limit", () => {
const key = "test-block";
const opts = { maxRequests: 3, windowMs: 60000 };
checkRateLimit(key, opts); // 1
checkRateLimit(key, opts); // 2
checkRateLimit(key, opts); // 3
const result = checkRateLimit(key, opts); // 4 — over limit
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.retryAfterSeconds).toBeGreaterThan(0);
});
it("uses separate counters per key", () => {
const opts = { maxRequests: 1, windowMs: 60000 };
const a = checkRateLimit("key-a", opts);
const b = checkRateLimit("key-b", opts);
expect(a.allowed).toBe(true);
expect(b.allowed).toBe(true);
});
});

View file

@ -0,0 +1,43 @@
interface RateLimitEntry {
count: number;
resetAt: number;
}
const store = new Map<string, RateLimitEntry>();
const DEFAULT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const DEFAULT_MAX_REQUESTS = 60;
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now > entry.resetAt) {
store.delete(key);
}
}
}, 60 * 1000);
export function checkRateLimit(
key: string,
options?: { windowMs?: number; maxRequests?: number },
): { allowed: boolean; remaining: number; retryAfterSeconds?: number } {
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS;
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS;
const now = Date.now();
let entry = store.get(key);
if (!entry || now > entry.resetAt) {
entry = { count: 0, resetAt: now + windowMs };
store.set(key, entry);
}
entry.count++;
if (entry.count > maxRequests) {
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
return { allowed: false, remaining: 0, retryAfterSeconds };
}
return { allowed: true, remaining: maxRequests - entry.count };
}

View file

@ -3,5 +3,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("api/sessions", "routes/api.sessions.ts"),
route("api/route", "routes/api.route.ts"),
route("session/:id", "routes/session.$id.tsx"),
] satisfies RouteConfig;

View file

@ -0,0 +1,45 @@
import { data } from "react-router";
import type { Route } from "./+types/api.route";
import { computeRoute } from "~/lib/brouter";
import { checkRateLimit } from "~/lib/rate-limit";
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") {
return data({ error: "Method not allowed" }, { status: 405 });
}
const body = await request.json();
const { waypoints, profile, sessionId } = body as {
waypoints: Array<{ lat: number; lon: number }>;
profile?: string;
sessionId?: string;
};
if (!waypoints || waypoints.length < 2) {
return data({ error: "At least 2 waypoints are required" }, { status: 400 });
}
// Rate limit by session ID or IP
const rateLimitKey = sessionId ?? request.headers.get("x-forwarded-for") ?? "unknown";
const limit = checkRateLimit(`route:${rateLimitKey}`);
if (!limit.allowed) {
return data(
{ error: "Rate limit exceeded" },
{
status: 429,
headers: { "Retry-After": String(limit.retryAfterSeconds) },
},
);
}
try {
const route = await computeRoute({ waypoints, profile });
return data(route, {
headers: { "X-RateLimit-Remaining": String(limit.remaining) },
});
} catch (e) {
const message = e instanceof Error ? e.message : "Route computation failed";
return data({ error: message }, { status: 502 });
}
}