mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
427 lines
13 KiB
JavaScript
427 lines
13 KiB
JavaScript
import path from 'node:path';
|
|
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
|
import { coalesceProxyRequest } from '../common/http.js';
|
|
import { naturalRegionAtPoint } from '../../../src/data/naturalEarthRegions.js';
|
|
import {
|
|
nominatimToGeocodeResult,
|
|
nominatimViewboxFromBounds,
|
|
} from '../../../src/nominatimGeocode.js';
|
|
import {
|
|
NOMINATIM_MAX_PENDING,
|
|
isNominatimBusy,
|
|
sharedNominatimGate,
|
|
} from './nominatimGate.js';
|
|
import {
|
|
NOMINATIM_CACHE_TTL_MS,
|
|
createNominatimCache,
|
|
} from './nominatimCache.js';
|
|
import {
|
|
OUTLINE_KINDS,
|
|
OUTLINE_POLYGON_THRESHOLD,
|
|
outlineBias,
|
|
selectOutlineResult,
|
|
} from './outlineSelect.js';
|
|
|
|
const NOMINATIM_SEARCH_MAX_QUERY = 200;
|
|
|
|
/** Results considered per outline ask: enough to skip a node or a wrong type. */
|
|
const OUTLINE_RESULT_LIMIT = 3;
|
|
|
|
/** Largest upstream answer read for an outline (polygons are simplified upstream). */
|
|
const OUTLINE_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
|
|
/** Bump when the cached payload shape changes, so old entries are never read. */
|
|
const CACHE_SCHEMA = 'v2';
|
|
|
|
/**
|
|
* The query text actually sent upstream: Unicode-composed, whitespace
|
|
* collapsed. Nothing else is changed, so two queries share a cache entry only
|
|
* when they are the same upstream request.
|
|
*/
|
|
export function upstreamQuery(query) {
|
|
return String(query ?? '')
|
|
.normalize('NFC')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
/** Cache key: endpoint, schema and the exact upstream parameters. */
|
|
function requestKey(gate, kind, params) {
|
|
const endpoint = gate.settings?.()?.endpoint || 'none';
|
|
return `${CACHE_SCHEMA}|${endpoint}|${kind}|${params}`;
|
|
}
|
|
|
|
function malformed() {
|
|
return Object.assign(new Error('Place answer was not understood'), {
|
|
code: 'NOMINATIM_MALFORMED',
|
|
});
|
|
}
|
|
|
|
/** Where the dev server keeps Nominatim answers and the daily count. */
|
|
export const NOMINATIM_CACHE_DIR = path.join(
|
|
process.cwd(),
|
|
'.gev-cache',
|
|
'nominatim',
|
|
);
|
|
|
|
/** Construct the offline regional context provider from bundled Natural Earth polygons. */
|
|
export function createRegionalPlaceProvider() {
|
|
return (point) => naturalRegionAtPoint(point.latitude, point.longitude);
|
|
}
|
|
|
|
export const fetchRegionalPlace = createRegionalPlaceProvider();
|
|
|
|
/**
|
|
* Snap a `"swLat,swLng|neLat,neLng"` view to a coarse grid so small camera
|
|
* moves reuse one cache entry. The viewbox is a soft preference upstream.
|
|
*/
|
|
function coarseBounds(bounds) {
|
|
const match = String(bounds || '')
|
|
.trim()
|
|
.split('|')
|
|
.map((corner) => corner.split(',').map(Number));
|
|
if (match.length !== 2 || match.some((c) => c.length !== 2)) return null;
|
|
const [[swLat, swLng], [neLat, neLng]] = match;
|
|
if (![swLat, swLng, neLat, neLng].every(Number.isFinite)) return null;
|
|
const span = Math.max(neLat - swLat, neLng - swLng, 0.01);
|
|
const step = 2 ** Math.ceil(Math.log2(span));
|
|
const cLat = Math.round((swLat + neLat) / 2 / (step / 4)) * (step / 4);
|
|
const cLng = Math.round((swLng + neLng) / 2 / (step / 4)) * (step / 4);
|
|
const clamp = (v, limit) => Math.max(-limit, Math.min(limit, v));
|
|
const fix = (v) => Number(v.toFixed(4));
|
|
return `${fix(clamp(cLat - step / 2, 90))},${fix(clamp(cLng - step / 2, 180))}|${fix(clamp(cLat + step / 2, 90))},${fix(clamp(cLng + step / 2, 180))}`;
|
|
}
|
|
|
|
/**
|
|
* Construct the forward search adapter over the shared gate.
|
|
*
|
|
* The policy asks that results be cached, and warns that a client repeating
|
|
* the same query may be treated as faulty, so answers are kept (30 days for a
|
|
* hit, 24 hours for a genuine no-result; a failure is never kept) and
|
|
* identical searches already in flight share one upstream call.
|
|
*/
|
|
export function createNominatimSearchProvider({
|
|
gate = () => sharedNominatimGate(),
|
|
cache = createNominatimCache(),
|
|
} = {}) {
|
|
const inFlight = new Map();
|
|
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
|
|
|
|
return async function fetchNominatimSearch(query, bounds, { signal } = {}) {
|
|
const coarse = coarseBounds(bounds);
|
|
const params = new URLSearchParams({
|
|
format: 'jsonv2',
|
|
q: upstreamQuery(query),
|
|
addressdetails: '1',
|
|
limit: '1',
|
|
'accept-language': 'en',
|
|
});
|
|
const viewbox = nominatimViewboxFromBounds(coarse);
|
|
if (viewbox) params.set('viewbox', viewbox);
|
|
const gateNow = currentGate();
|
|
const cacheKey = requestKey(gateNow, 'search', params);
|
|
const cached = await cache.get(cacheKey);
|
|
if (cached) return { ...cached.payload, cached: true };
|
|
const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
|
const rows = await gateNow.requestJson(
|
|
(endpoint) => `${endpoint}?${params}`,
|
|
{ signal },
|
|
);
|
|
// Only a well-formed empty list is "no such place"; anything else is a
|
|
// failure and is never cached.
|
|
if (!Array.isArray(rows)) throw malformed();
|
|
const result = nominatimToGeocodeResult(rows[0] ?? null);
|
|
if (rows.length && !result) throw malformed();
|
|
const payload = result
|
|
? { status: 'OK', results: [result] }
|
|
: { status: 'ZERO_RESULTS', results: [] };
|
|
await cache.set(
|
|
cacheKey,
|
|
payload,
|
|
result ? NOMINATIM_CACHE_TTL_MS.place : NOMINATIM_CACHE_TTL_MS.notFound,
|
|
);
|
|
return payload;
|
|
});
|
|
return await promise;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Construct the outline lookup: one guarded search with polygons for one
|
|
* explicit ask, accepting only a result whose class suits the ask kind.
|
|
* Found outlines are kept 90 days and genuine no-results 24 hours.
|
|
*/
|
|
export function createNominatimOutlineProvider({
|
|
gate = () => sharedNominatimGate(),
|
|
cache = createNominatimCache(),
|
|
} = {}) {
|
|
const inFlight = new Map();
|
|
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
|
|
|
|
return async function fetchNominatimOutline(
|
|
{ query, kind, lat, lon },
|
|
{ signal } = {},
|
|
) {
|
|
const bias = outlineBias(kind, lat, lon);
|
|
const sent = upstreamQuery(query);
|
|
const params = new URLSearchParams({
|
|
format: 'jsonv2',
|
|
q: sent,
|
|
limit: String(OUTLINE_RESULT_LIMIT),
|
|
polygon_geojson: '1',
|
|
polygon_threshold: String(OUTLINE_POLYGON_THRESHOLD[kind]),
|
|
namedetails: '1',
|
|
'accept-language': 'en',
|
|
});
|
|
if (bias) params.set('viewbox', bias.viewbox);
|
|
const gateNow = currentGate();
|
|
const cacheKey = requestKey(gateNow, `outline-${kind}`, params);
|
|
const cached = await cache.get(cacheKey);
|
|
if (cached) return { ...cached.payload, cached: true };
|
|
const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
|
const rows = await gateNow.requestJson(
|
|
(endpoint) => `${endpoint}?${params}`,
|
|
{ signal, maxBytes: OUTLINE_MAX_RESPONSE_BYTES },
|
|
);
|
|
if (!Array.isArray(rows)) throw malformed();
|
|
const { outline, skipped } = selectOutlineResult(rows, {
|
|
kind,
|
|
query: sent,
|
|
});
|
|
const payload = outline
|
|
? { status: 'OK', outline }
|
|
: { status: 'ZERO_RESULTS', outline: null, skipped };
|
|
await cache.set(
|
|
cacheKey,
|
|
payload,
|
|
outline
|
|
? NOMINATIM_CACHE_TTL_MS.outline
|
|
: NOMINATIM_CACHE_TTL_MS.notFound,
|
|
);
|
|
return payload;
|
|
});
|
|
return await promise;
|
|
};
|
|
}
|
|
|
|
export const fetchNominatimSearch = createNominatimSearchProvider();
|
|
|
|
/** A browser on another site may not spend this install's allowance. */
|
|
function crossSite(req) {
|
|
return (
|
|
String(req.headers?.['sec-fetch-site'] || '').toLowerCase() === 'cross-site'
|
|
);
|
|
}
|
|
|
|
function sendJson(res, status, body, headers = {}) {
|
|
if (res.writableEnded) return;
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
...headers,
|
|
});
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
/** Map a gate refusal to the route's answer. Never "not found". */
|
|
function sendFailure(res, error, what) {
|
|
if (error?.code === 'NOMINATIM_DISABLED') {
|
|
sendJson(res, 503, {
|
|
error: `${what} is not configured`,
|
|
code: 'NOMINATIM_DISABLED',
|
|
retryable: false,
|
|
});
|
|
return;
|
|
}
|
|
if (error?.code === 'NOMINATIM_DAILY_CAP') {
|
|
sendJson(
|
|
res,
|
|
429,
|
|
{
|
|
error: `${what} daily allowance is used`,
|
|
code: 'NOMINATIM_DAILY_CAP',
|
|
retryable: false,
|
|
},
|
|
{ 'Retry-After': '3600' },
|
|
);
|
|
return;
|
|
}
|
|
if (isNominatimBusy(error)) {
|
|
const seconds = Math.max(5, Math.ceil((error.retryAfterMs || 0) / 1000));
|
|
sendJson(
|
|
res,
|
|
429,
|
|
{ error: `${what} is busy`, code: error.code },
|
|
{ 'Retry-After': String(seconds) },
|
|
);
|
|
return;
|
|
}
|
|
sendJson(res, 503, {
|
|
error: `${what} is temporarily unavailable`,
|
|
code: 'NOMINATIM_UNAVAILABLE',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Vite plugin: last-resort place search (`/api/geocode`) and voice outlines
|
|
* (`/api/geocode/outline`) over the configured Nominatim endpoint.
|
|
*
|
|
* `storageDir` turns on the disk cache and the persisted daily count; the
|
|
* application composition supplies it, tests and embedders may omit it.
|
|
*/
|
|
export function geocodeProxy({
|
|
search,
|
|
outline,
|
|
storageDir = null,
|
|
gate = null,
|
|
} = {}) {
|
|
const currentGate = () => gate || sharedNominatimGate();
|
|
if (storageDir) {
|
|
// The shared count and pause live beside, never inside, the answer cache,
|
|
// so no cache sweep can reset them.
|
|
currentGate().persistUsage(path.join(storageDir, 'state.json'));
|
|
const diskCache = createNominatimCache({
|
|
dir: path.join(storageDir, 'answers'),
|
|
});
|
|
void diskCache.sweep();
|
|
search ||= createNominatimSearchProvider({
|
|
gate: currentGate,
|
|
cache: diskCache,
|
|
});
|
|
outline ||= createNominatimOutlineProvider({
|
|
gate: currentGate,
|
|
cache: diskCache,
|
|
});
|
|
}
|
|
search ||= gate
|
|
? createNominatimSearchProvider({ gate })
|
|
: fetchNominatimSearch;
|
|
outline ||= createNominatimOutlineProvider({ gate: currentGate });
|
|
|
|
const searchLimiter = makeRateLimiter({
|
|
windowMs: 60_000,
|
|
max: 30,
|
|
globalMax: 90,
|
|
});
|
|
// Outlines are explicit voice asks: a person cannot make many a minute.
|
|
const outlineLimiter = makeRateLimiter({
|
|
windowMs: 60_000,
|
|
max: 12,
|
|
globalMax: 30,
|
|
});
|
|
|
|
/** Common front door: method, same-site, rate limit, abandonment signal. */
|
|
function admit(req, res, limiter) {
|
|
if (req.method !== 'GET') {
|
|
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
return null;
|
|
}
|
|
if (crossSite(req)) {
|
|
sendJson(res, 403, { error: 'Cross-site requests are not accepted' });
|
|
return null;
|
|
}
|
|
if (!limiter(clientKey(req))) {
|
|
sendJson(
|
|
res,
|
|
429,
|
|
{ error: 'Rate limit exceeded' },
|
|
{ 'Retry-After': '10' },
|
|
);
|
|
return null;
|
|
}
|
|
// 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());
|
|
return abandoned.signal;
|
|
}
|
|
|
|
async function handleOutline(req, res, url) {
|
|
const signal = admit(req, res, outlineLimiter);
|
|
if (!signal) return;
|
|
const query = String(url.searchParams.get('q') || '').trim();
|
|
const kind = String(url.searchParams.get('kind') || '');
|
|
if (
|
|
!query ||
|
|
query.length > NOMINATIM_SEARCH_MAX_QUERY ||
|
|
!OUTLINE_KINDS.includes(kind)
|
|
) {
|
|
sendJson(res, 400, {
|
|
error:
|
|
'A place query of 1-200 characters and a known kind are required',
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await outline(
|
|
{
|
|
query,
|
|
kind,
|
|
lat: url.searchParams.get('lat'),
|
|
lon: url.searchParams.get('lon'),
|
|
},
|
|
{ signal },
|
|
);
|
|
sendJson(
|
|
res,
|
|
200,
|
|
{ status: payload.status, outline: payload.outline || null },
|
|
{
|
|
'Cache-Control': payload.cached ? 'private, max-age=300' : 'no-store',
|
|
},
|
|
);
|
|
} catch (error) {
|
|
sendFailure(res, error, 'Outline lookup');
|
|
}
|
|
}
|
|
|
|
async function handleSearch(req, res, url) {
|
|
const signal = admit(req, res, searchLimiter);
|
|
if (!signal) return;
|
|
const query = String(url.searchParams.get('q') || '').trim();
|
|
if (!query || query.length > NOMINATIM_SEARCH_MAX_QUERY) {
|
|
sendJson(res, 400, {
|
|
error: 'A place query of 1-200 characters is required',
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await search(query, url.searchParams.get('bounds'), {
|
|
signal,
|
|
});
|
|
sendJson(
|
|
res,
|
|
200,
|
|
{ status: payload.status, results: payload.results },
|
|
{ 'Cache-Control': payload.cached ? 'public, max-age=60' : 'no-store' },
|
|
);
|
|
} catch (error) {
|
|
sendFailure(res, error, 'Place search');
|
|
}
|
|
}
|
|
|
|
function install(middlewares) {
|
|
middlewares.use('/api/geocode', async (req, res, next) => {
|
|
const url = new URL(req.url || '', 'http://localhost');
|
|
if (url.pathname === '/outline') return handleOutline(req, res, url);
|
|
if (url.pathname === '/' || url.pathname === '')
|
|
return handleSearch(req, res, url);
|
|
if (typeof next === 'function') return next();
|
|
sendJson(res, 404, { error: 'Not Found' });
|
|
});
|
|
}
|
|
|
|
return {
|
|
name: 'geocode-proxy',
|
|
configureServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
configurePreviewServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
};
|
|
}
|
|
|
|
export { NOMINATIM_MAX_PENDING };
|