feat(outlines): voice outlines without Overpass — bundled US places and neighborhoods, guarded Nominatim, OpenFreeMap streets and buildings

This commit is contained in:
Bilawal Sidhu committed 2026-09-28 22:49:23 -05:00
1 parent e7707d9a0f
commit 9559e7a282
333 files changed
+13203 -271

No files matched your search

+2 -2
View File
@@ -7,7 +7,7 @@ import { adsbdbProxy } from './aircraft/enrichment.js';
import { overpassProxy } from './overpass.js';
import { militaryInstallationsProxy } from './military-installations.js';
import { regionalBriefProxy } from './regional/briefing.js';
import { geocodeProxy } from './regional/place.js';
import { geocodeProxy, NOMINATIM_CACHE_DIR } from './regional/place.js';
import { weatherEffectsProxy } from './regional/weather-effects.js';
import { cctvProxy } from './cctv.js';
import { defaultSourceRoot } from './common/source-root.js';
@@ -39,7 +39,7 @@ function localProviderPlugins() {
overpassProxy(),
militaryInstallationsProxy(),
regionalBriefProxy(),
geocodeProxy(),
geocodeProxy({ storageDir: NOMINATIM_CACHE_DIR }),
weatherEffectsProxy(),
cctvProxy({ sourceRoot: defaultSourceRoot }),
radioBrowserProxy(),
+171
View File
@@ -0,0 +1,171 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
const DAY_MS = 86_400_000;
/** Entry files this cache writes: a 40-hex-digit key hash. */
const CACHE_FILE_RE = /^[0-9a-f]{40}\.json$/;
/** How long an answer is kept, by what it was. */
export const NOMINATIM_CACHE_TTL_MS = Object.freeze({
outline: 90 * DAY_MS,
place: 30 * DAY_MS,
notFound: DAY_MS,
});
/**
* Comparable form of a place name: Unicode-normalized (NFKC), accents
* removed, case folded, punctuation and symbols collapsed to single spaces,
* and a leading article dropped. Non-Latin scripts are kept as they are:
* "東京" stays "東京", while "São Paulo", "sao paulo" and "SÃO PAULO!" meet.
*
* @param {string} value
* @returns {string}
*/
export function normalizePlaceName(value) {
return String(value ?? '')
.normalize('NFKD')
.replace(/\p{M}+/gu, '')
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[\p{P}\p{S}\p{Z}\s]+/gu, ' ')
.trim()
.replace(/^(?:the) /, '')
.slice(0, 200);
}
/**
* Memory + disk cache for Nominatim answers.
*
* Every entry carries its own expiry. Memory is an LRU bounded by entries;
* disk is bounded by entries and bytes, with expired files removed during a
* periodic sweep. A null `dir` keeps everything in memory.
*/
export function createNominatimCache({
dir = null,
maxMemoryEntries = 256,
maxDiskEntries = 4000,
maxDiskBytes = 64 * 1024 * 1024,
maxEntryBytes = 2 * 1024 * 1024,
sweepEvery = 50,
now = Date.now,
} = {}) {
const memory = new Map();
let writesSinceSweep = 0;
let sweeping = null;
const fileFor = (key) =>
path.join(
dir,
`${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}.json`,
);
function remember(key, entry) {
memory.delete(key);
memory.set(key, entry);
while (memory.size > maxMemoryEntries)
memory.delete(memory.keys().next().value);
}
async function get(key) {
const hit = memory.get(key);
if (hit) {
if (hit.expiresAt > now()) {
remember(key, hit);
return hit;
}
memory.delete(key);
}
if (!dir) return null;
try {
const entry = JSON.parse(await fs.readFile(fileFor(key), 'utf8'));
if (entry?.key !== key || !(entry.expiresAt > now())) return null;
remember(key, entry);
return entry;
} catch {
return null;
}
}
async function set(key, payload, ttlMs) {
const entry = { key, payload, storedAt: now(), expiresAt: now() + ttlMs };
remember(key, entry);
if (!dir) return;
const body = JSON.stringify(entry);
if (Buffer.byteLength(body) > maxEntryBytes) return;
try {
await fs.mkdir(dir, { recursive: true });
const file = fileFor(key);
const temp = `${file}.${process.pid}.tmp`;
await fs.writeFile(temp, body);
await fs.rename(temp, file);
} catch {
return;
}
writesSinceSweep += 1;
if (writesSinceSweep >= sweepEvery) {
writesSinceSweep = 0;
await sweep();
}
}
/** Remove expired files, then the oldest until both disk bounds hold. */
async function sweep() {
if (!dir) return;
if (sweeping) return sweeping;
sweeping = (async () => {
let names;
try {
names = await fs.readdir(dir);
} catch {
return;
}
const files = [];
for (const name of names) {
// Only this cache's own entry files; anything else in the directory
// is left alone.
if (!CACHE_FILE_RE.test(name)) continue;
const file = path.join(dir, name);
let stat;
let text;
try {
[stat, text] = await Promise.all([
fs.stat(file),
fs.readFile(file, 'utf8'),
]);
} catch {
// Unreadable right now (gone, busy, too many open files): an error
// reading is not evidence the entry is bad, so it stays.
continue;
}
let expiresAt;
try {
expiresAt = JSON.parse(text)?.expiresAt;
} catch {
expiresAt = null; // corrupt: removed below
}
if (!(expiresAt > now())) {
await fs.rm(file, { force: true }).catch(() => {});
continue;
}
files.push({ file, size: stat.size, at: stat.mtimeMs });
}
files.sort((a, b) => a.at - b.at);
let bytes = files.reduce((sum, entry) => sum + entry.size, 0);
while (
files.length &&
(files.length > maxDiskEntries || bytes > maxDiskBytes)
) {
const oldest = files.shift();
bytes -= oldest.size;
await fs.rm(oldest.file, { force: true }).catch(() => {});
}
})().finally(() => {
sweeping = null;
});
return sweeping;
}
return { get, set, sweep, memorySize: () => memory.size };
}
+504
View File
@@ -0,0 +1,504 @@
import fs from 'node:fs';
import path from 'node:path';
import { readResponseJsonCapped } from '../common/http.js';
/**
* One gate for every request this server sends to a Nominatim instance.
*
* Place search and voice outlines share it, so the public instance sees one
* application: one request at a time, at least 1.1 s apart, a short queue, a
* daily ceiling per install, and a pause after the server says to stop.
*/
/** The public instance. Only this host is subject to the public-use limits. */
export const PUBLIC_NOMINATIM_SEARCH =
'https://nominatim.openstreetmap.org/search';
const PUBLIC_NOMINATIM_HOST = 'nominatim.openstreetmap.org';
/**
* The public usage policy asks for a User-Agent or Referer that identifies the
* application. The identity is stable: it is never rotated or varied.
*/
export 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',
});
/** Absolute policy maximum is one request per second; 1.1 s absorbs jitter. */
export const NOMINATIM_MIN_SPACING_MS = 1100;
/** Requests allowed to wait for their turn; past this, refuse at once. */
export const NOMINATIM_MAX_PENDING = 4;
/** A queued request older than this has no reader left; drop it unsent. */
export const NOMINATIM_MAX_WAIT_MS = 10_000;
/** Default public requests per install per UTC day, outlines and search together. */
export const NOMINATIM_DEFAULT_DAILY_CAP = 50;
/**
* Pause after a refusal without Retry-After. A Retry-After from the server is
* honoured in full, however long: no local maximum may cause an earlier retry.
*/
const DEFAULT_BACKOFF_MS = 60_000;
/** A 403 from the public instance means this identity is blocked: stop longer. */
const BLOCKED_BACKOFF_MS = 3_600_000;
/** Pause after a server error or a failed retry. */
const OUTAGE_BACKOFF_MS = 30_000;
const DISABLED_VALUES = new Set(['', 'off', 'none', 'disabled', '0']);
/**
* Read the search endpoint setting.
*
* Unset means the public instance under its guards. Set to empty (or `off`)
* disables every Nominatim request. Any other value must be an http(s) URL
* without credentials, query or fragment; a bare host gets `/search`. A value
* that is not usable disables the service rather than falling back to the
* public instance.
*
* @param {Record<string, string|undefined>} [env]
* @returns {{endpoint: string|null, isPublic: boolean, dailyCap: number, invalid?: boolean}}
*/
export function resolveNominatimSettings(env = process.env) {
const dailyCap = parseDailyCap(env.NOMINATIM_DAILY_CAP);
const raw = env.NOMINATIM_URL;
if (raw === undefined)
return { endpoint: PUBLIC_NOMINATIM_SEARCH, isPublic: true, dailyCap };
const value = String(raw).trim();
if (DISABLED_VALUES.has(value.toLowerCase()))
return { endpoint: null, isPublic: false, dailyCap };
let url;
try {
url = new URL(value);
} catch {
return { endpoint: null, isPublic: false, dailyCap, invalid: true };
}
if (
!['http:', 'https:'].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password ||
url.search ||
url.hash
)
return { endpoint: null, isPublic: false, dailyCap, invalid: true };
if (url.pathname === '/' || url.pathname === '') url.pathname = '/search';
return {
endpoint: url.href.replace(/\/$/, ''),
// A trailing DNS dot names the same host.
isPublic:
url.hostname.toLowerCase().replace(/\.$/, '') === PUBLIC_NOMINATIM_HOST,
dailyCap,
};
}
function parseDailyCap(raw) {
if (raw === undefined || String(raw).trim() === '')
return NOMINATIM_DEFAULT_DAILY_CAP;
const value = Number(raw);
return Number.isFinite(value) && value >= 0
? Math.floor(value)
: NOMINATIM_DEFAULT_DAILY_CAP;
}
function gateError(code, message, extra = {}) {
return Object.assign(new Error(message), { code, ...extra });
}
/** Seconds or an HTTP date → milliseconds from now, or null. */
export function parseRetryAfter(value, now = Date.now()) {
if (value == null || value === '') return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const at = Date.parse(String(value));
return Number.isFinite(at) ? Math.max(0, at - now) : null;
}
/** UTC calendar day, the unit of the daily cap. */
function utcDay(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
const sleepSync = (ms) =>
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
/**
* Public-service state shared by every process using the same file: today's
* request count and any pause the server asked for.
*
* Every change is a read-modify-write under an exclusive lock directory, and
* the file is replaced atomically, so two dev servers sharing a checkout add
* to one count instead of overwriting each other. Memory keeps the highest
* values this process has seen, so a failed or unreadable file never lowers
* the count or shortens a pause; a persistence failure is reported once and
* in `status()`.
*/
export function createGateStateStore({
file = null,
lockStaleMs = 5000,
lockWaitMs = 500,
onError = (error) =>
console.warn(
`[Nominatim] could not persist usage state: ${error?.message || error}`,
),
} = {}) {
let memory = { day: '', count: 0, pausedUntil: 0 };
let lastError = null;
const report = (error) => {
if (!lastError) onError(error);
lastError = error;
};
const merge = (a, b) => ({
day: a.day >= b.day ? a.day : b.day,
count:
a.day === b.day
? Math.max(a.count, b.count)
: a.day > b.day
? a.count
: b.count,
pausedUntil: Math.max(a.pausedUntil || 0, b.pausedUntil || 0),
});
function readDisk() {
if (!file) return null;
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
return {
day: typeof parsed?.day === 'string' ? parsed.day : '',
count: Number.isFinite(parsed?.count) ? Math.max(0, parsed.count) : 0,
pausedUntil: Number.isFinite(parsed?.pausedUntil)
? parsed.pausedUntil
: 0,
};
} catch (error) {
if (error?.code !== 'ENOENT') report(error);
return null;
}
}
function current() {
const disk = readDisk();
if (disk) memory = merge(memory, disk);
return memory;
}
function withLock(work) {
const lock = `${file}.lock`;
const deadline = Date.now() + lockWaitMs;
fs.mkdirSync(path.dirname(file), { recursive: true });
for (;;) {
try {
fs.mkdirSync(lock);
break;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
try {
if (Date.now() - fs.statSync(lock).mtimeMs > lockStaleMs)
fs.rmSync(lock, { recursive: true, force: true });
} catch {
// Another process released it meanwhile.
}
if (Date.now() > deadline)
throw new Error('usage state is locked by another process');
sleepSync(5);
}
}
try {
return work();
} finally {
fs.rmSync(lock, { recursive: true, force: true });
}
}
/** Apply `change` to the freshest state and persist it atomically. */
function update(change) {
if (!file) {
memory = merge(memory, change(memory));
return memory;
}
try {
withLock(() => {
const next = merge(memory, change(current()));
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temp, JSON.stringify(next));
fs.renameSync(temp, file);
memory = next;
lastError = null;
});
} catch (error) {
report(error);
memory = merge(memory, change(memory));
}
return memory;
}
return {
count(day) {
const state = current();
return state.day === day ? state.count : 0;
},
increment(day) {
return update((state) => ({
day,
count: state.day === day ? state.count + 1 : 1,
pausedUntil: state.pausedUntil,
})).count;
},
pausedUntil() {
return current().pausedUntil || 0;
},
pauseUntil(at) {
update((state) => ({ ...state, pausedUntil: at }));
},
status: () => ({
file,
persisted: Boolean(file) && !lastError,
error: lastError ? String(lastError.message || lastError) : null,
}),
};
}
/** Compatibility name for the state store. */
export const createUsageStore = createGateStateStore;
/**
* Construct a gate. `settings` may be a function so the environment is read
* after the dev server loads `.env`.
*
* @param {object} [options]
* @param {(() => object)|object} [options.settings]
* @param {typeof fetch} [options.fetchImpl]
* @param {() => number} [options.now]
* @param {(ms: number) => Promise<void>} [options.sleep]
* @param {ReturnType<typeof createUsageStore>} [options.usage]
*/
export function createNominatimGate({
settings = () => resolveNominatimSettings(),
fetchImpl = (...args) => globalThis.fetch(...args),
now = Date.now,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
usage = createGateStateStore(),
minSpacingMs = NOMINATIM_MIN_SPACING_MS,
maxPending = NOMINATIM_MAX_PENDING,
maxWaitMs = NOMINATIM_MAX_WAIT_MS,
timeoutMs = 9000,
} = {}) {
let queue = Promise.resolve();
let lastStartedAt = -Infinity;
let pending = 0;
let pausedUntil = 0;
let upstreamRequests = 0;
let usageStore = usage;
const current = () =>
typeof settings === 'function' ? settings() : settings;
/**
* Pause until `ms` from now. The public service's pause is also written to
* the shared state so a restart or a second process honours it.
*/
function pause(ms, config) {
const until = now() + Math.max(0, ms);
if (until > pausedUntil) pausedUntil = until;
if (config?.isPublic) usageStore.pauseUntil(pausedUntil);
}
function pauseEnd(config) {
return config?.isPublic
? Math.max(pausedUntil, usageStore.pausedUntil())
: pausedUntil;
}
function pausedError(config) {
return gateError('NOMINATIM_PAUSED', 'Place service is pausing', {
retryAfterMs: Math.max(0, pauseEnd(config) - now()),
});
}
function admissionError(config) {
if (!config.endpoint)
return gateError('NOMINATIM_DISABLED', 'Place service is not configured');
if (now() < pauseEnd(config)) return pausedError(config);
if (config.isPublic && usageStore.count(utcDay(now())) >= config.dailyCap)
return gateError(
'NOMINATIM_DAILY_CAP',
'Daily place lookup allowance is used',
);
return null;
}
/** One upstream request; classifies refusals and pauses the gate. */
async function send(url, config, maxBytes, signal) {
if (config.isPublic) usageStore.increment(utcDay(now()));
upstreamRequests += 1;
lastStartedAt = now();
const controller = new AbortController();
const onAbort = () => controller.abort();
signal?.addEventListener('abort', onAbort, { once: true });
// One deadline covers the headers AND the body: a server that answers its
// headers and then stalls must not hold the shared queue.
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await exchange(url, config, maxBytes, signal, controller.signal);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
async function exchange(url, config, maxBytes, signal, requestSignal) {
let response;
try {
response = await fetchImpl(url, {
headers: NOMINATIM_HEADERS,
redirect: 'error',
signal: requestSignal,
});
} catch (cause) {
if (signal?.aborted) throw gateError('NOMINATIM_ABANDONED', 'Abandoned');
throw gateError('NOMINATIM_TRANSPORT', 'Place service unreachable', {
cause,
transport: true,
});
}
if (!response.ok) {
void response.body?.cancel?.().catch(() => {});
const status = response.status;
const retryAfter = parseRetryAfter(
response.headers?.get?.('retry-after'),
now(),
);
if (status === 429 || status === 503) {
pause(retryAfter ?? DEFAULT_BACKOFF_MS, config);
throw gateError('NOMINATIM_REFUSED', 'Place service asked to wait', {
status,
retryAfterMs: Math.max(0, pauseEnd(config) - now()),
});
}
if (status === 403 || status === 418) {
pause(retryAfter ?? BLOCKED_BACKOFF_MS, config);
throw gateError('NOMINATIM_REFUSED', 'Place service refused', {
status,
retryAfterMs: Math.max(0, pauseEnd(config) - now()),
});
}
if (status >= 500) pause(retryAfter ?? OUTAGE_BACKOFF_MS, config);
throw gateError('NOMINATIM_UPSTREAM', `Upstream returned ${status}`, {
status,
});
}
try {
return await readResponseJsonCapped(response, maxBytes, requestSignal);
} catch (cause) {
if (cause?.code === 'RESPONSE_TOO_LARGE')
throw gateError('NOMINATIM_TOO_LARGE', 'Place answer too large');
if (signal?.aborted) throw gateError('NOMINATIM_ABANDONED', 'Abandoned');
throw gateError('NOMINATIM_TRANSPORT', 'Place answer unreadable', {
cause,
transport: true,
});
}
}
/** Wait for the spacing slot, then check the request is still wanted. */
async function takeTurn(config, queuedAt, signal) {
const spacing = config.isPublic ? minSpacingMs : 0;
const waitMs = Math.max(0, spacing - (now() - lastStartedAt));
if (waitMs) await sleep(waitMs);
if (signal?.aborted || now() - queuedAt > maxWaitMs)
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
// An outage or refusal that happened while this waited stops it here.
const refused = admissionError(config);
if (refused) throw refused;
}
/**
* Queue one JSON request for `buildUrl(endpoint)`. At most one retry, and
* only after a transport failure; a failed retry pauses the gate.
*
* @param {(endpoint: string) => string} buildUrl
* @param {{signal?: AbortSignal, maxBytes?: number}} [options]
*/
function requestJson(buildUrl, { signal, maxBytes = 2 * 1024 * 1024 } = {}) {
const config = current();
const refused = admissionError(config);
if (refused) return Promise.reject(refused);
if (signal?.aborted)
return Promise.reject(
gateError('NOMINATIM_ABANDONED', 'Place search was abandoned'),
);
if (pending >= maxPending)
return Promise.reject(
gateError('NOMINATIM_QUEUE_FULL', 'Place search queue is full'),
);
pending += 1;
const queuedAt = now();
const url = buildUrl(config.endpoint);
const task = queue.then(async () => {
try {
await takeTurn(config, queuedAt, signal);
try {
return await send(url, config, maxBytes, signal);
} catch (error) {
if (!error?.transport) throw error;
await takeTurn(config, now(), signal);
try {
return await send(url, config, maxBytes, signal);
} catch (retryError) {
if (retryError?.transport) pause(OUTAGE_BACKOFF_MS, config);
throw retryError;
}
}
} finally {
pending -= 1;
}
});
queue = task.catch(() => null);
return task;
}
return {
requestJson,
settings: current,
/** Share the daily count and pause through a file (composition chooses it). */
persistUsage(file) {
usageStore = createGateStateStore({ file });
},
stats: () => ({
upstreamRequests,
pending,
pausedForMs: Math.max(0, pauseEnd(current()) - now()),
usedToday: usageStore.count(utcDay(now())),
persistence: usageStore.status?.() || null,
}),
};
}
let sharedGate = createNominatimGate();
/** The process-wide gate: search and outlines share one budget and pacer. */
export function sharedNominatimGate() {
return sharedGate;
}
/** Replace the process-wide gate (tests, embedders with their own transport). */
export function setSharedNominatimGate(gate) {
sharedGate = gate || createNominatimGate();
return sharedGate;
}
/** Codes that mean "not now" rather than "no such place". */
export function isNominatimBusy(error) {
return [
'NOMINATIM_QUEUE_FULL',
'NOMINATIM_ABANDONED',
'NOMINATIM_PAUSED',
'NOMINATIM_REFUSED',
'NOMINATIM_DAILY_CAP',
].includes(error?.code);
}
+510
View File
@@ -0,0 +1,510 @@
import { normalizePlaceName } from './nominatimCache.js';
/**
* Which Nominatim results may answer which voice ask, and how their polygons
* are bounded. Pure: no I/O.
*
* A search returns whatever matches the words best. "Mission District" can
* come back as a car-charging station and "Bandra" as a railway station, so a
* result is accepted only when its OSM class (and, for broad classes, its
* type) suits the kind of thing that was asked for, it carries a polygon, and
* its name actually answers the words.
*/
/** Ask kinds the outline route answers. */
export const OUTLINE_KINDS = Object.freeze([
'city',
'admin',
'neighborhood',
'landmark',
'building',
]);
/** Simplification tolerance (degrees) sent upstream, per ask kind. */
export const OUTLINE_POLYGON_THRESHOLD = Object.freeze({
city: 0.0005,
admin: 0.0005,
neighborhood: 0.0005,
landmark: 0.00005,
building: 0.00005,
});
/** Total vertices returned for one outline, and parts/holes kept. */
export const OUTLINE_MAX_VERTICES = 6000;
const OUTLINE_MAX_PARTS = 24;
const OUTLINE_MAX_HOLES = 16;
const ANY = '*';
const SETTLEMENT_PLACES = [
'city',
'town',
'village',
'municipality',
'borough',
'hamlet',
];
const CIVIC_AMENITIES = [
'university',
'college',
'school',
'kindergarten',
'hospital',
'clinic',
'place_of_worship',
'monastery',
'townhall',
'courthouse',
'library',
'theatre',
'cinema',
'arts_centre',
'community_centre',
'conference_centre',
'events_venue',
'exhibition_centre',
'music_venue',
'planetarium',
'marketplace',
'prison',
'grave_yard',
'fire_station',
'police',
'embassy',
'public_building',
];
const LANDMARK_TOURISM = [
'attraction',
'theme_park',
'zoo',
'aquarium',
'museum',
'gallery',
'viewpoint',
'camp_site',
'hotel',
'resort',
];
const MAN_MADE_STRUCTURES = [
'tower',
'lighthouse',
'pier',
'bridge',
'works',
'dam',
'observatory',
'wastewater_plant',
'water_works',
'breakwater',
];
/**
* Accepted `class → types` per ask kind. `*` accepts every type of that
* class. Anything absent is rejected: roads, railways, stations, shops,
* guideposts and single facilities never answer an area ask.
*/
export const OUTLINE_ACCEPT = Object.freeze({
city: {
boundary: ['administrative'],
place: SETTLEMENT_PLACES,
},
admin: {
boundary: ['administrative'],
place: ['country', 'state', 'province', 'region', 'county', 'district'],
},
neighborhood: {
boundary: ['administrative'],
place: ['suburb', 'quarter', 'neighbourhood', 'borough', 'city_block'],
},
landmark: {
leisure: ANY,
landuse: ANY,
amenity: CIVIC_AMENITIES,
tourism: LANDMARK_TOURISM,
building: ANY,
natural: ANY,
water: ANY,
waterway: ['riverbank', 'dock'],
historic: ANY,
man_made: MAN_MADE_STRUCTURES,
office: ['government', 'diplomatic'],
aeroway: ['aerodrome', 'heliport', 'terminal'],
military: ANY,
boundary: ['national_park', 'protected_area'],
place: ['island', 'islet', 'square'],
},
// Single structures only: no campuses, grounds, parks or camp sites.
building: {
building: ANY,
amenity: [
'place_of_worship',
'townhall',
'courthouse',
'library',
'theatre',
'cinema',
'arts_centre',
'community_centre',
'conference_centre',
'exhibition_centre',
'music_venue',
'planetarium',
'fire_station',
'police',
'embassy',
'public_building',
],
tourism: ['museum', 'gallery', 'hotel', 'attraction'],
historic: ['building', 'castle', 'church', 'fort', 'palace', 'monastery'],
man_made: ['tower', 'lighthouse', 'observatory'],
office: ANY,
},
});
/**
* Administrative evidence per ask kind: an address type, or a Nominatim
* place_rank band (country 4, state 8, county 10-12, city 12-16, suburb 18-20,
* neighbourhood 20-22). An administrative boundary with neither is refused.
*/
const ADMIN_EVIDENCE = Object.freeze({
city: {
types: ['city', 'town', 'village', 'municipality', 'borough', 'hamlet'],
ranks: [12, 18],
},
admin: {
types: [
'country',
'state',
'province',
'region',
'county',
'district',
'state_district',
],
ranks: [2, 14],
},
neighborhood: {
types: ['suburb', 'quarter', 'neighbourhood', 'city_district', 'borough'],
ranks: [17, 24],
},
});
/** Whether an administrative boundary is at the level the ask names. */
export function adminLevelFits(kind, row) {
const evidence = ADMIN_EVIDENCE[kind];
if (!evidence) return true;
const type = String(row?.addresstype || '').toLowerCase();
if (type && evidence.types.includes(type)) return true;
const rank = Number(row?.place_rank);
if (Number.isFinite(rank))
return rank >= evidence.ranks[0] && rank <= evidence.ranks[1];
return false;
}
/** Largest footprint (m²) a non-`building` result may have for a building ask. */
const BUILDING_MAX_AREA_M2 = 150_000;
/** Approximate area (m²) of a [lon, lat] ring. */
function ringAreaM2(ring) {
const lat0 = ring[0][1];
const k = 111_320 * 111_320 * Math.cos((lat0 * Math.PI) / 180);
return ringArea(ring) * k;
}
/** Whether a result's class/type suits the ask kind. */
export function acceptsOutlineClass(kind, osmClass, osmType) {
const rules = OUTLINE_ACCEPT[kind];
if (!rules) return false;
const types = rules[String(osmClass || '').toLowerCase()];
if (!types) return false;
return types === ANY || types.includes(String(osmType || '').toLowerCase());
}
const STOP_WORDS = new Set(['the', 'of', 'de', 'la', 'le', 'el', 'and']);
/** Scripts written without spaces between words. */
const UNSPACED_SCRIPT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
/** Whether `needle` occurs in `hay` as whole consecutive words. */
function containsWords(hay, needle) {
for (let i = 0; i + needle.length <= hay.length; i++)
if (needle.every((word, j) => hay[i + j] === word)) return true;
return false;
}
/** Significant words of the part of a query before the first comma. */
function queryWords(query) {
const head = String(query ?? '').split(',')[0];
return normalizePlaceName(head)
.split(' ')
.filter((word) => word && !STOP_WORDS.has(word));
}
/**
* Whether a result's names answer the query. Most of the query's words must
* appear among one name's words, or one name must contain the whole query
* (for scripts written without spaces).
*/
export function outlineNameMatches(row, query) {
const words = queryWords(query);
if (!words.length) return false;
const whole = words.join(' ');
const names = new Set();
const add = (value) => {
const name = normalizePlaceName(value);
if (name) names.add(name);
};
add(row?.name);
add(String(row?.display_name || '').split(',')[0]);
if (row?.namedetails && typeof row.namedetails === 'object')
for (const value of Object.values(row.namedetails)) add(value);
const unspaced = UNSPACED_SCRIPT_RE.test(whole);
for (const name of names) {
const nameWords = name.split(' ');
// Whole words only: "York" never matches "Yorkshire Park".
if (containsWords(nameWords, words)) return true;
// Scripts without spaces compare by characters instead.
if (unspaced && name.replace(/ /g, '').includes(whole.replace(/ /g, '')))
return true;
const nameSet = new Set(nameWords);
const hits = words.filter((word) => nameSet.has(word)).length;
if (words.length > 1 && hits >= Math.ceil(words.length * 0.6)) return true;
}
return false;
}
const finiteLon = (value) =>
Number.isFinite(value) && value >= -180 && value <= 180;
const finiteLat = (value) =>
Number.isFinite(value) && value >= -90 && value <= 90;
/** A valid closed ring of [lon, lat] numbers, or null. */
function cleanRing(ring) {
if (!Array.isArray(ring)) return null;
const out = [];
for (const point of ring) {
if (!Array.isArray(point)) return null;
const lon = Number(point[0]);
const lat = Number(point[1]);
if (!finiteLon(lon) || !finiteLat(lat)) return null;
out.push([lon, lat]);
}
if (out.length < 4) return null;
const [first, last] = [out[0], out.at(-1)];
if (first[0] !== last[0] || first[1] !== last[1]) out.push([...first]);
return out;
}
/** Planar ring area in squared degrees (only used to rank parts). */
function ringArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
sum += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
return Math.abs(sum / 2);
}
/** Polygon/MultiPolygon GeoJSON → `[[outer, ...holes], …]`, largest first. */
export function polygonsFromGeoJson(geometry) {
const raw =
geometry?.type === 'Polygon'
? [geometry.coordinates]
: geometry?.type === 'MultiPolygon'
? geometry.coordinates
: null;
if (!Array.isArray(raw)) return null;
const polygons = [];
for (const polygon of raw) {
if (!Array.isArray(polygon)) continue;
const outer = cleanRing(polygon[0]);
if (!outer) continue;
const holes = polygon
.slice(1)
.map(cleanRing)
.filter(Boolean)
.sort((a, b) => ringArea(b) - ringArea(a))
.slice(0, OUTLINE_MAX_HOLES);
polygons.push({ rings: [outer, ...holes], area: ringArea(outer) });
}
if (!polygons.length) return null;
polygons.sort((a, b) => b.area - a.area);
return polygons.slice(0, OUTLINE_MAX_PARTS).map((entry) => entry.rings);
}
/** Iterative Douglas–Peucker on a closed ring (keeps it closed, ≥ 4 points). */
function simplifyClosedRing(ring, tolerance) {
if (ring.length <= 8 || tolerance <= 0) return ring;
const keep = new Uint8Array(ring.length);
keep[0] = keep[ring.length - 1] = 1;
const stack = [[0, ring.length - 1]];
const tol2 = tolerance * tolerance;
while (stack.length) {
const [start, end] = stack.pop();
const [ax, ay] = ring[start];
const [bx, by] = ring[end];
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
let best = -1;
let bestD = tol2;
for (let i = start + 1; i < end; i++) {
const [px, py] = ring[i];
let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const ex = ax + t * dx - px;
const ey = ay + t * dy - py;
const d = ex * ex + ey * ey;
if (d > bestD) {
bestD = d;
best = i;
}
}
if (best > 0) {
keep[best] = 1;
stack.push([start, best], [best, end]);
}
}
const out = ring.filter((_point, index) => keep[index]);
return out.length >= 4 ? out : ring;
}
const countVertices = (polygons) =>
polygons.reduce(
(sum, rings) => sum + rings.reduce((n, ring) => n + ring.length, 0),
0,
);
/** Simplify until the whole outline fits the vertex cap. */
export function capOutlineVertices(
polygons,
maxVertices = OUTLINE_MAX_VERTICES,
) {
let current = polygons;
let tolerance = 0.00002;
for (let round = 0; round < 12; round++) {
if (countVertices(current) <= maxVertices) return current;
current = polygons.map((rings) =>
rings.map((ring) => simplifyClosedRing(ring, tolerance)),
);
tolerance *= 2;
}
// Still over: keep the largest parts that fit, outer rings only.
const kept = [];
let total = 0;
for (const rings of current) {
const outer = rings[0];
if (total + outer.length > maxVertices) break;
kept.push([outer]);
total += outer.length;
}
return kept.length ? kept : null;
}
/**
* The first result within the limit that suits the ask, names the place and
* carries a polygon — or null with the reason each result was passed over.
*
* @param {object[]} rows Nominatim jsonv2 results, in upstream order.
* @param {{kind: string, query: string}} ask
*/
export function selectOutlineResult(rows, { kind, query }) {
const skipped = [];
for (const row of Array.isArray(rows) ? rows : []) {
const osmClass = row?.category || row?.class;
const osmType = row?.type;
const label = `${osmClass}=${osmType}`;
if (!acceptsOutlineClass(kind, osmClass, osmType)) {
skipped.push(`${label}: wrong type`);
continue;
}
if (
String(osmClass).toLowerCase() === 'boundary' &&
String(osmType).toLowerCase() === 'administrative' &&
!adminLevelFits(kind, row)
) {
skipped.push(`${label}: level`);
continue;
}
if (!outlineNameMatches(row, query)) {
skipped.push(`${label}: name`);
continue;
}
const parsed = polygonsFromGeoJson(row?.geojson);
if (!parsed) {
skipped.push(`${label}: no polygon`);
continue;
}
// A building ask needs building evidence: the building class, or a
// footprint no larger than a large building.
if (
kind === 'building' &&
String(osmClass).toLowerCase() !== 'building' &&
ringAreaM2(parsed[0][0]) > BUILDING_MAX_AREA_M2
) {
skipped.push(`${label}: not a building`);
continue;
}
const polygons = capOutlineVertices(parsed);
if (!polygons) {
skipped.push(`${label}: too large`);
continue;
}
const lat = Number(row.lat);
const lon = Number(row.lon);
return {
outline: {
name: String(row.name || '').slice(0, 160) || null,
displayName: String(row.display_name || '').slice(0, 300) || null,
class: String(osmClass),
type: String(osmType),
osm:
row.osm_type && row.osm_id != null
? `${row.osm_type}/${row.osm_id}`
: null,
center: finiteLat(lat) && finiteLon(lon) ? { lat, lon } : null,
polygons,
},
skipped,
};
}
return { outline: null, skipped };
}
/**
* Coarse view bias: the centre is snapped to a grid (1° for places, 0.05°
* for small grounds) so small camera moves reuse the same cache entry, and
* the viewbox sent upstream is built from the snapped centre.
*/
export function outlineBias(kind, lat, lon) {
// Missing is missing: Number(null) and Number('') would read as (0, 0).
const given = (value) =>
value !== null &&
value !== undefined &&
(typeof value === 'number' || String(value).trim() !== '');
if (!given(lat) || !given(lon)) return null;
const latitude = Number(lat);
const longitude = Number(lon);
if (!finiteLat(latitude) || !finiteLon(longitude)) return null;
const small = kind === 'landmark' || kind === 'building';
const step = small ? 0.05 : 1;
const half = small ? 0.25 : 2;
const snap = (value) => Math.round(value / step) * step;
const cLat = Number(snap(latitude).toFixed(2));
const cLon = Number(snap(longitude).toFixed(2));
const clampLat = (value) => Math.max(-90, Math.min(90, value));
const clampLon = (value) => Math.max(-180, Math.min(180, value));
return {
key: `${cLat},${cLon}`,
viewbox: [
clampLon(cLon - half),
clampLat(cLat + half),
clampLon(cLon + half),
clampLat(cLat - half),
]
.map((value) => Number(value.toFixed(4)))
.join(','),
};
}
+359 -198
View File
@@ -1,114 +1,69 @@
import path from 'node:path';
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { coalesceProxyRequest } from '../common/http.js';
import { fetchRegionalJson } from './http.js';
import { naturalRegionAtPoint } from '../../../src/data/naturalEarthRegions.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;
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;
// 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. Only last-resort forward searches
// use this queue.
let _nominatimQueue = Promise.resolve();
/** Results considered per outline ask: enough to skip a node or a wrong type. */
const OUTLINE_RESULT_LIMIT = 3;
let _nominatimLastRequestAt = 0;
/** Largest upstream answer read for an outline (polygons are simplified upstream). */
const OUTLINE_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
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',
});
}
/** Bump when the cached payload shape changes, so old entries are never read. */
const CACHE_SCHEMA = 'v2';
/**
* Run one piece of upstream work, never closer than the policy spacing to the
* last one.
*
* `bounded` applies queue-depth and staleness limits to interactive forward searches.
*
* @param {() => Promise<unknown>} work
* @param {{bounded?: boolean, signal?: AbortSignal}} [options]
* 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.
*/
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;
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);
@@ -117,60 +72,132 @@ export function createRegionalPlaceProvider() {
export const fetchRegionalPlace = createRegionalPlaceProvider();
/**
* Construct the forward search adapter with a trusted endpoint.
* 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 an answer is remembered and identical
* searches already in flight share one upstream call rather than queueing
* behind each other.
* 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({
endpoint = 'https://nominatim.openstreetmap.org/search',
requestJson = fetchRegionalJson,
gate = () => sharedNominatimGate(),
cache = createNominatimCache(),
} = {}) {
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);
}
};
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
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 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 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 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: [] };
cache.set(cacheKey, { payload, cachedAt: Date.now() });
trimCache();
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;
@@ -179,75 +206,209 @@ export function createNominatimSearchProvider({
export const fetchNominatimSearch = createNominatimSearchProvider();
/** Vite plugin: last-resort place search over the public Nominatim instance. */
export function geocodeProxy({ search = fetchNominatimSearch } = {}) {
const limiter = makeRateLimiter({
/** 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) => {
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;
}
middlewares.use('/api/geocode', async (req, res, next) => {
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',
}),
);
}
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' });
});
}
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ command, mode }) => {
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
cesiumToken: process.env.CESIUM_ION_TOKEN,
photonUrl: process.env.PHOTON_URL,
host: process.env.HOST,
port: process.env.PORT,
command,