feat(directions): keyless A→B directions with turn-by-turn steps and FLY (#564)

Supersedes #219 by @spcpza, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: spcpza <[email protected]>
This commit is contained in:
Bilawal Sidhuandspcpza authored and GitHub committed 2026-09-14 20:09:41 -05:00
1 parent 9fc8d1966b
commit d78696dc17
29 files changed
+4658 -65

No files matched your search

+270 -40
View File
@@ -1,17 +1,102 @@
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { haversineKm } from '../common/geo.js';
import { readResponseTextCapped } from '../common/http.js';
import { normalizeOsrmSteps } from '../../../src/data/routeSteps.js';
import {
normalizeRouteProfile,
projectRouteResult,
} from '../../../src/data/placeProviderPayloads.js';
/** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */
/**
* OSM routing (FOSSGIS OSRM) cache: profile|coords ->
* { payload, hasSteps, cachedAt }. `hasSteps` records whether the upstream
* call behind this entry asked for maneuvers, because an entry without them
* cannot answer a request that wants them.
*/
const ROUTE_CACHE_MS = 600000;
/**
* Upstream calls currently in flight, keyed by endpoint + route + step shape.
* Two identical requests that arrive before the first one answers (three rapid
* reroutes of the same A→B, a second browser tab) await the SAME upstream
* fetch. The FOSSGIS servers ask for no heavy use; the cheapest way to honour
* that is not to make the call twice. Module-level, because what it is
* deduplicating is this process's outbound traffic — the endpoint is part of
* the key, so two installations pointed at different services never share one.
* @type {Map<string, Promise<{payload: object|null, error: string|null}>>}
*/
const _routeInflight = new Map();
/** Hard cap on the OSRM route response we will buffer. */
const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB
/**
* Minimum gap between two OUTBOUND route requests, across every client and
* every profile. The FOSSGIS servers publish "one request per second max", and
* the per-client rate limiter below cannot honour that on its own: two
* different clients, or one client switching DRIVE to WALK, are two different
* cache keys and left 48 ms apart upstream. This is the gate that makes the
* app's outbound rate what the policy says it may be.
*/
export const ROUTE_UPSTREAM_MIN_INTERVAL_MS = 1000;
/**
* How many requests may be waiting for that gate at once. Past this the answer
* is an honest 429 rather than a queue that grows until everything times out.
*/
export const ROUTE_UPSTREAM_QUEUE_MAX = 8;
/** The interval actually in force; only a test ever shortens it. */
let _upstreamIntervalMs = ROUTE_UPSTREAM_MIN_INTERVAL_MS;
/** Earliest wall-clock time the next outbound request may leave. */
let _nextUpstreamAt = 0;
/** Requests currently holding or waiting for the outbound slot. */
let _upstreamQueueDepth = 0;
/** Thrown when the outbound queue is full; answered as a 429. */
class RouteBusyError extends Error {
constructor(retryAfterMs) {
super('routing busy');
this.name = 'RouteBusyError';
this.retryAfterMs = retryAfterMs;
}
}
/** Thrown when the routing service itself rate-limited us. */
class UpstreamRateLimitError extends Error {
constructor(retryAfterSec) {
super('routing service is rate limited');
this.name = 'UpstreamRateLimitError';
this.retryAfterSec = retryAfterSec;
}
}
/**
* Run one outbound request no sooner than the shared gate allows.
* @template T
* @param {() => Promise<T>} run The request.
* @returns {Promise<T>}
*/
async function throughUpstreamGate(run) {
if (_upstreamQueueDepth >= ROUTE_UPSTREAM_QUEUE_MAX) {
throw new RouteBusyError(
Math.max(0, _nextUpstreamAt - Date.now()) + _upstreamIntervalMs,
);
}
_upstreamQueueDepth += 1;
try {
const now = Date.now();
const slot = Math.max(now, _nextUpstreamAt);
_nextUpstreamAt = slot + _upstreamIntervalMs;
const wait = slot - now;
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
return await run();
} finally {
_upstreamQueueDepth -= 1;
}
}
/** Reject routes whose straight-line spans are obviously abusive (km). */
const ROUTE_MAX_LEG_KM = 600;
@@ -23,6 +108,113 @@ const _routeRateLimiter = makeRateLimiter({
globalMax: 200,
});
/** Test seam: forget the outbound state this module keeps between requests. */
export function _resetRouteUpstreamForTest() {
_routeInflight.clear();
_nextUpstreamAt = 0;
_upstreamQueueDepth = 0;
_upstreamIntervalMs = ROUTE_UPSTREAM_MIN_INTERVAL_MS;
}
/**
* Test seam: shorten the outbound gate so a queue-overflow test does not have
* to wait out the real one-per-second policy. The policy value itself is
* asserted separately.
* @param {number} ms
*/
export function _setRouteUpstreamIntervalForTest(ms) {
_upstreamIntervalMs =
Number.isFinite(ms) && ms >= 0 ? ms : ROUTE_UPSTREAM_MIN_INTERVAL_MS;
}
/** Test seam: how many upstream calls are coalescing right now. */
export function _routeInflightCountForTest() {
return _routeInflight.size;
}
/** Release a response body we are not going to read. */
async function cancelBody(response) {
try {
await response.body?.cancel();
} catch {
/* already closed */
}
}
/**
* Fetch one route from the routing service and project it.
* @param {object} request
* @param {string} request.profile foot | car | bike
* @param {string} request.osrmProfile Upstream profile name.
* @param {string} request.base Endpoint base for this profile.
* @param {string} request.coords `lon,lat;lon,lat[;...]`
* @param {boolean} request.withSteps Ask upstream for maneuvers.
* @param {Function} request.fetchImpl Injected request function.
* @returns {Promise<{payload: object|null, error: string|null}>}
*/
async function fetchRoute({
profile,
osrmProfile,
base,
coords,
withSteps,
fetchImpl,
}) {
// `steps` is opt-in per request. Asking for maneuvers on every call made the
// response several times larger for the callers that never read them (the
// voice route annotation, fly_route), on someone else's bandwidth.
const upstream =
`${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` +
`?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
let osrm;
try {
const upstreamRes = await throughUpstreamGate(() =>
fetchImpl(upstream, {
signal: controller.signal,
// The endpoint is configured above; a redirect is the one way out of
// it, so it is refused rather than followed.
redirect: 'error',
headers: { 'User-Agent': 'gods-eye-view/dev (local)' },
}),
);
if (upstreamRes.status === 429) {
await cancelBody(upstreamRes);
const retryAfter = Number(upstreamRes.headers.get('retry-after'));
throw new UpstreamRateLimitError(
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5,
);
}
if (!upstreamRes.ok) {
await cancelBody(upstreamRes);
return { payload: null, error: 'no route found' };
}
const ctype = upstreamRes.headers.get('content-type') || '';
if (!ctype.includes('json')) {
await cancelBody(upstreamRes);
return { payload: null, error: 'no route found' };
}
const text = await readResponseTextCapped(
upstreamRes,
ROUTE_MAX_RESPONSE_BYTES,
);
osrm = JSON.parse(text);
} finally {
clearTimeout(timer);
}
const route = osrm?.routes?.[0];
if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length)
return { payload: null, error: 'no route found' };
const payload = projectRouteResult(route, profile);
if (withSteps) {
const { steps, truncated } = normalizeOsrmSteps(route);
payload.steps = steps;
if (truncated) payload.stepsTruncated = true;
}
return { payload, error: null };
}
export function installRouteMiddleware(
middlewares,
{ endpoints = {}, fetchImpl = (...args) => fetch(...args) } = {},
@@ -30,19 +222,25 @@ export function installRouteMiddleware(
const _routeCache = new Map();
// Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike).
// GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...]
// GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...][&steps=1]
// `steps=1` adds turn-by-turn maneuvers (src/data/routeSteps.js) and is the
// only shape that asks the upstream for them. A response WITHOUT steps is
// byte for byte what this endpoint has always returned.
middlewares.use('/api/route', async (req, res) => {
const fail = (msg) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: msg }));
};
const rateLimited = (msg, retryAfterSec) => {
res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': String(Math.max(1, Math.round(retryAfterSec || 5))),
});
res.end(JSON.stringify({ ok: false, error: msg }));
};
try {
if (!_routeRateLimiter(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '5',
});
res.end(JSON.stringify({ ok: false, error: 'rate limited' }));
rateLimited('rate limited', 5);
return;
}
const url = new URL(req.url, 'http://localhost');
@@ -92,46 +290,78 @@ export function installRouteMiddleware(
if (totalKm > ROUTE_MAX_TOTAL_KM) return fail('route too long');
const coords = clean.join(';');
const cacheKey = `${profile}|${coords}`;
const now = Date.now();
const cached = _routeCache.get(cacheKey);
if (cached && now - cached.cachedAt <= ROUTE_CACHE_MS) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(cached.payload));
return;
}
const base =
endpoints[profile] ||
`https://routing.openstreetmap.de/routed-${profile}`;
const upstream = `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
let osrm;
try {
const upstreamRes = await fetchImpl(upstream, {
signal: controller.signal,
redirect: 'error',
headers: { 'User-Agent': 'gods-eye-view/dev (local)' },
});
if (!upstreamRes.ok) return fail('no route found');
const ctype = upstreamRes.headers.get('content-type') || '';
if (!ctype.includes('json')) return fail('no route found');
const text = await readResponseTextCapped(
upstreamRes,
ROUTE_MAX_RESPONSE_BYTES,
);
osrm = JSON.parse(text);
} finally {
clearTimeout(timer);
const now = Date.now();
const wantSteps = url.searchParams.get('steps') === '1';
// A caller that did not ask for maneuvers never sees them, even when the
// cached entry carries them for someone else.
const shapePayload = (payload) =>
wantSteps ? payload : { ...payload, steps: undefined };
const cached = _routeCache.get(cacheKey);
if (
cached &&
now - cached.cachedAt <= ROUTE_CACHE_MS &&
(!wantSteps || cached.hasSteps)
) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(shapePayload(cached.payload)));
return;
}
const route = osrm?.routes?.[0];
if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length)
return fail('no route found');
const payload = projectRouteResult(route, profile);
_routeCache.set(cacheKey, { payload, cachedAt: now });
const inflightKey = `${base}|${cacheKey}|${wantSteps ? 's' : 'n'}`;
// A stepless request can also ride a stepful call already in flight —
// it just drops the maneuvers on the way out.
let pending =
_routeInflight.get(inflightKey) ||
(wantSteps ? null : _routeInflight.get(`${base}|${cacheKey}|s`));
if (!pending) {
pending = fetchRoute({
profile,
osrmProfile,
base,
coords,
withSteps: wantSteps,
fetchImpl,
});
_routeInflight.set(inflightKey, pending);
const settle = () => {
if (_routeInflight.get(inflightKey) === pending)
_routeInflight.delete(inflightKey);
};
pending.then(settle, settle);
}
let payload;
let error;
try {
({ payload, error } = await pending);
} catch (upstreamError) {
if (upstreamError instanceof RouteBusyError) {
return rateLimited(
'routing busy — too many routes at once',
Math.ceil(upstreamError.retryAfterMs / 1000),
);
}
if (upstreamError instanceof UpstreamRateLimitError) {
// Reporting this as "no route found" would blame the map for
// something the routing service said about us.
return rateLimited(
'routing service is rate limited',
upstreamError.retryAfterSec,
);
}
throw upstreamError;
}
if (error || !payload) return fail(error || 'no route found');
_routeCache.set(cacheKey, {
payload,
hasSteps: Array.isArray(payload.steps),
cachedAt: Date.now(),
});
if (_routeCache.size > 200)
_routeCache.delete(_routeCache.keys().next().value);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
res.end(JSON.stringify(shapePayload(payload)));
} catch (e) {
console.error('[Route Proxy]', e?.message || e);
fail('route proxy error');