mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Supersedes #350 by @sendmebits, 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: sendmebits <[email protected]>
289 lines
9.5 KiB
JavaScript
289 lines
9.5 KiB
JavaScript
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
|
import { coalesceProxyRequest } from '../common/http.js';
|
|
import { fetchRegionalJson } from './http.js';
|
|
import { normalizeRegionalPlace } from '../../../src/data/regionalModel.js';
|
|
import {
|
|
nominatimToGeocodeResult,
|
|
nominatimViewboxFromBounds,
|
|
} from '../../../src/nominatimGeocode.js';
|
|
|
|
/**
|
|
* The usage policy for the public Nominatim instance asks for a User-Agent or
|
|
* Referer that identifies the application, and states that stock library
|
|
* agents will not do. Both are sent.
|
|
*/
|
|
const NOMINATIM_HEADERS = Object.freeze({
|
|
'User-Agent':
|
|
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
|
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
|
|
});
|
|
|
|
/**
|
|
* Minimum spacing between upstream calls. The policy states an absolute
|
|
* maximum of one request per second; 1.1 s keeps clock jitter from crossing it.
|
|
*/
|
|
const NOMINATIM_MIN_SPACING_MS = 1100;
|
|
|
|
/**
|
|
* How many searches may be waiting for their turn.
|
|
*
|
|
* One request per second and an unbounded queue are incompatible: a burst of
|
|
* searches would keep the upstream busy long after everyone who asked has given
|
|
* up, which is exactly the load the policy asks callers not to create. Past
|
|
* this depth a search is refused at once instead of being promised a slot
|
|
* minutes away.
|
|
*/
|
|
const NOMINATIM_MAX_PENDING = 4;
|
|
|
|
/**
|
|
* How long a queued search may wait before it is not worth sending. The
|
|
* browser gives up well before this, so anything reaching the front later than
|
|
* this is answering nobody.
|
|
*/
|
|
const NOMINATIM_MAX_WAIT_MS = 10_000;
|
|
|
|
const NOMINATIM_SEARCH_CACHE_MS = 5 * 60_000;
|
|
|
|
const NOMINATIM_SEARCH_MAX_CACHE = 80;
|
|
|
|
const NOMINATIM_SEARCH_MAX_QUERY = 200;
|
|
|
|
// The pacer is deliberately module state while everything else here is
|
|
// per-instance. One request per second is a budget for the whole application,
|
|
// not for each provider object: two instances each pacing themselves would
|
|
// send two requests a second between them. Reverse lookups and searches
|
|
// therefore queue together.
|
|
let _nominatimQueue = Promise.resolve();
|
|
|
|
let _nominatimLastRequestAt = 0;
|
|
|
|
let _nominatimPending = 0;
|
|
|
|
/** Raised when the queue is already as deep as it is allowed to get. */
|
|
function queueFullError() {
|
|
return Object.assign(new Error('Place search queue is full'), {
|
|
code: 'NOMINATIM_QUEUE_FULL',
|
|
});
|
|
}
|
|
|
|
/** Raised when a queued search waited so long that nobody is left to answer. */
|
|
function abandonedError() {
|
|
return Object.assign(new Error('Place search was abandoned'), {
|
|
code: 'NOMINATIM_ABANDONED',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run one piece of upstream work, never closer than the policy spacing to the
|
|
* last one.
|
|
*
|
|
* `bounded` applies the queue-depth and staleness limits. The cockpit's reverse
|
|
* lookups are a slow background trickle and stay unbounded; the search route,
|
|
* which a person can fire as fast as they can type, does not.
|
|
*
|
|
* @param {() => Promise<unknown>} work
|
|
* @param {{bounded?: boolean, signal?: AbortSignal}} [options]
|
|
*/
|
|
function enqueueNominatim(work, { bounded = false, signal } = {}) {
|
|
if (bounded && _nominatimPending >= NOMINATIM_MAX_PENDING)
|
|
return Promise.reject(queueFullError());
|
|
if (bounded) _nominatimPending += 1;
|
|
const queuedAt = Date.now();
|
|
const task = _nominatimQueue.then(async () => {
|
|
try {
|
|
const waitMs = Math.max(
|
|
0,
|
|
NOMINATIM_MIN_SPACING_MS - (Date.now() - _nominatimLastRequestAt),
|
|
);
|
|
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
// Nobody is waiting for this any more: sending it would spend the one
|
|
// request per second the policy allows on an answer with no reader.
|
|
if (bounded && Date.now() - queuedAt > NOMINATIM_MAX_WAIT_MS)
|
|
throw abandonedError();
|
|
if (signal?.aborted) throw abandonedError();
|
|
_nominatimLastRequestAt = Date.now();
|
|
return await work();
|
|
} finally {
|
|
if (bounded) _nominatimPending -= 1;
|
|
}
|
|
});
|
|
_nominatimQueue = task.catch(() => null);
|
|
return task;
|
|
}
|
|
|
|
/** Construct the serialized Nominatim reverse adapter with a trusted endpoint. */
|
|
export function createRegionalPlaceProvider({
|
|
endpoint = 'https://nominatim.openstreetmap.org/reverse',
|
|
requestJson = fetchRegionalJson,
|
|
} = {}) {
|
|
function fetchRegionalPlace(point) {
|
|
return enqueueNominatim(async () => {
|
|
const params = new URLSearchParams({
|
|
format: 'jsonv2',
|
|
lat: point.latitude.toFixed(5),
|
|
lon: point.longitude.toFixed(5),
|
|
zoom: '10',
|
|
addressdetails: '1',
|
|
'accept-language': 'en',
|
|
});
|
|
const payload = await requestJson(`${endpoint}?${params}`, {
|
|
headers: NOMINATIM_HEADERS,
|
|
redirect: 'error',
|
|
});
|
|
return normalizeRegionalPlace(payload);
|
|
});
|
|
}
|
|
|
|
return fetchRegionalPlace;
|
|
}
|
|
|
|
export const fetchRegionalPlace = createRegionalPlaceProvider();
|
|
|
|
/**
|
|
* Construct the forward search adapter with a trusted endpoint.
|
|
*
|
|
* The policy asks that results be cached, and warns that a client repeating the
|
|
* same query may be treated as faulty, so an answer is remembered and identical
|
|
* searches already in flight share one upstream call rather than queueing
|
|
* behind each other.
|
|
*/
|
|
export function createNominatimSearchProvider({
|
|
endpoint = 'https://nominatim.openstreetmap.org/search',
|
|
requestJson = fetchRegionalJson,
|
|
} = {}) {
|
|
const cache = new Map();
|
|
const inFlight = new Map();
|
|
|
|
const trimCache = () => {
|
|
while (cache.size > NOMINATIM_SEARCH_MAX_CACHE) {
|
|
const oldest = cache.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
cache.delete(oldest);
|
|
}
|
|
};
|
|
|
|
return async function fetchNominatimSearch(query, bounds, { signal } = {}) {
|
|
const cacheKey = `${query.toLowerCase()}|${bounds || ''}`;
|
|
const cached = cache.get(cacheKey);
|
|
if (cached && Date.now() - cached.cachedAt <= NOMINATIM_SEARCH_CACHE_MS) {
|
|
return { ...cached.payload, cached: true };
|
|
}
|
|
const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
|
const params = new URLSearchParams({
|
|
format: 'jsonv2',
|
|
q: query,
|
|
addressdetails: '1',
|
|
limit: '1',
|
|
'accept-language': 'en',
|
|
});
|
|
const viewbox = nominatimViewboxFromBounds(bounds);
|
|
if (viewbox) params.set('viewbox', viewbox);
|
|
const rows = await enqueueNominatim(
|
|
() =>
|
|
requestJson(`${endpoint}?${params}`, {
|
|
headers: NOMINATIM_HEADERS,
|
|
redirect: 'error',
|
|
}),
|
|
{ bounded: true, signal },
|
|
);
|
|
const result = nominatimToGeocodeResult(
|
|
Array.isArray(rows) ? rows[0] : null,
|
|
);
|
|
const payload = result
|
|
? { status: 'OK', results: [result] }
|
|
: { status: 'ZERO_RESULTS', results: [] };
|
|
cache.set(cacheKey, { payload, cachedAt: Date.now() });
|
|
trimCache();
|
|
return payload;
|
|
});
|
|
return await promise;
|
|
};
|
|
}
|
|
|
|
export const fetchNominatimSearch = createNominatimSearchProvider();
|
|
|
|
/** Vite plugin: last-resort place search over the public Nominatim instance. */
|
|
export function geocodeProxy({ search = fetchNominatimSearch } = {}) {
|
|
const limiter = makeRateLimiter({
|
|
windowMs: 60_000,
|
|
max: 30,
|
|
globalMax: 90,
|
|
});
|
|
|
|
function install(middlewares) {
|
|
middlewares.use('/api/geocode', async (req, res) => {
|
|
if (req.method !== 'GET') {
|
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
return;
|
|
}
|
|
if (!limiter(clientKey(req))) {
|
|
res.writeHead(429, {
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': '10',
|
|
});
|
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
return;
|
|
}
|
|
const url = new URL(req.url || '', 'http://localhost');
|
|
const query = String(url.searchParams.get('q') || '').trim();
|
|
if (!query || query.length > NOMINATIM_SEARCH_MAX_QUERY) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
error: 'A place query of 1-200 characters is required',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
// A browser that gave up is no longer waiting; the queue reads this
|
|
// before spending its slot.
|
|
const abandoned = new AbortController();
|
|
req.on?.('aborted', () => abandoned.abort());
|
|
res.on?.('close', () => abandoned.abort());
|
|
try {
|
|
const payload = await search(query, url.searchParams.get('bounds'), {
|
|
signal: abandoned.signal,
|
|
});
|
|
if (res.writableEnded) return;
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': payload.cached ? 'public, max-age=60' : 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({ status: payload.status, results: payload.results }),
|
|
);
|
|
} catch (error) {
|
|
if (res.writableEnded) return;
|
|
const busy =
|
|
error?.code === 'NOMINATIM_QUEUE_FULL' ||
|
|
error?.code === 'NOMINATIM_ABANDONED';
|
|
res.writeHead(busy ? 429 : 503, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
...(busy ? { 'Retry-After': '5' } : {}),
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: busy
|
|
? 'Place search is busy'
|
|
: 'Place search is temporarily unavailable',
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
name: 'geocode-proxy',
|
|
configureServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
configurePreviewServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
};
|
|
}
|
|
|
|
export { NOMINATIM_MAX_PENDING };
|