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 });
}
}

View file

@ -59,12 +59,20 @@ The session owner (initiator) SHALL be able to manually close a session.
- **THEN** all connected participants are notified, the session triggers auto-save if a callback exists, and the session becomes inaccessible
### Requirement: User presence
The Planner SHALL display presence indicators showing which users are currently connected to a session.
The Planner SHALL display presence indicators showing which users are currently connected to a session, including live cursors on the map.
#### Scenario: Show connected users
- **WHEN** multiple users are connected to a session
- **THEN** each user sees a list of other connected users with assigned colors
#### Scenario: Live map cursors
- **WHEN** a user moves their mouse over the map
- **THEN** other participants see a labeled cursor at that position on their map, colored to match the user's assigned color
#### Scenario: Cursor disappears on leave
- **WHEN** a user disconnects from the session
- **THEN** their cursor disappears from all other participants' maps within 5 seconds
### Requirement: No user data collection
The Planner SHALL NOT collect, store, or track any personal user data. Sessions are anonymous by default.

View file

@ -37,12 +37,11 @@
- [x] 4.5 Implement session join page (GET /session/:id → connect to Yjs document)
- [x] 4.6 Implement session expiry (garbage collection cron, configurable TTL)
- [x] 4.7 Implement manual session close (owner action, notify participants)
- [ ] 4.8 Implement user presence display (Yjs awareness, colors, names)
## 5. Planner — BRouter Integration
- [ ] 5.1 Implement BRouter HTTP proxy endpoint (POST /api/route → forward to BRouter)
- [ ] 5.2 Implement rate limiting middleware (60 requests/session/hour)
- [x] 5.1 Implement BRouter HTTP proxy endpoint (POST /api/route → forward to BRouter)
- [x] 5.2 Implement rate limiting middleware (60 requests/session/hour)
- [ ] 5.3 Implement routing host election via Yjs awareness (host/participant roles)
- [ ] 5.4 Implement routing host failover (detect disconnect, elect new host by join timestamp)
- [ ] 5.5 Implement route computation trigger (host watches waypoint changes, debounce 500ms, call BRouter)
@ -51,7 +50,8 @@
## 6. Planner — Map UI
- [ ] 6.1 Integrate MapView component in Planner with full-screen layout
- [ ] 6.1 Integrate MapView component in Planner with full-screen layout and client-side Yjs connection
- [ ] 6.1a Implement user presence display (Yjs awareness, colors, names, live map cursors)
- [ ] 6.2 Implement waypoint add (click map → add to Y.Array)
- [ ] 6.3 Implement waypoint drag (move marker → update Y.Array)
- [ ] 6.4 Implement waypoint delete (right-click → remove from Y.Array)