mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
chore(stack): refresh voice core and outlines parents
merge the latest #837 and #842 draft heads keep the stack parent-only so #846 shows geometry changes once
This commit is contained in:
commit
01979bba1e
346 files changed
+16384
-338
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
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. Public requests reserve one start
|
||||
* at a time across processes sharing this installation's state file, remain at
|
||||
* least 1.1 s apart, use a short queue and daily ceiling, and pause after the
|
||||
* server says to stop. Independent installations do not share one app-wide
|
||||
* limiter; each must identify itself and enforce the public policy locally.
|
||||
*/
|
||||
|
||||
/** 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 });
|
||||
}
|
||||
|
||||
const HTTP_MONTHS = Object.freeze([
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
]);
|
||||
const HTTP_WEEKDAYS = Object.freeze([
|
||||
'Sun',
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
]);
|
||||
const HTTP_WEEKDAYS_LONG = Object.freeze([
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
]);
|
||||
|
||||
/** Strict RFC 9110 HTTP-date, including the two obsolete recipient formats. */
|
||||
function parseHttpDate(raw, now) {
|
||||
let match = raw.match(
|
||||
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/,
|
||||
);
|
||||
let weekday, day, month, year, hour, minute, second;
|
||||
if (match) {
|
||||
[, weekday, day, month, year, hour, minute, second] = match;
|
||||
weekday = HTTP_WEEKDAYS.indexOf(weekday);
|
||||
} else {
|
||||
match = raw.match(
|
||||
/^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/,
|
||||
);
|
||||
if (match) {
|
||||
[, weekday, day, month, year, hour, minute, second] = match;
|
||||
weekday = HTTP_WEEKDAYS_LONG.indexOf(weekday);
|
||||
const currentYear = new Date(now).getUTCFullYear();
|
||||
if (!Number.isFinite(currentYear)) return null;
|
||||
year = Math.floor(currentYear / 100) * 100 + Number(year);
|
||||
if (year > currentYear + 50) year -= 100;
|
||||
} else {
|
||||
match = raw.match(
|
||||
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
[, weekday, month, day, hour, minute, second, year] = match;
|
||||
weekday = HTTP_WEEKDAYS.indexOf(weekday);
|
||||
}
|
||||
}
|
||||
day = Number(day);
|
||||
month = HTTP_MONTHS.indexOf(month);
|
||||
year = Number(year);
|
||||
hour = Number(hour);
|
||||
minute = Number(minute);
|
||||
second = Number(second);
|
||||
if (
|
||||
weekday < 0 ||
|
||||
month < 0 ||
|
||||
year < 1601 ||
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 59
|
||||
)
|
||||
return null;
|
||||
const at = Date.UTC(year, month, day, hour, minute, second);
|
||||
const date = new Date(at);
|
||||
return date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() === month &&
|
||||
date.getUTCDate() === day &&
|
||||
date.getUTCHours() === hour &&
|
||||
date.getUTCMinutes() === minute &&
|
||||
date.getUTCSeconds() === second &&
|
||||
date.getUTCDay() === weekday
|
||||
? at
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Seconds or an HTTP date → milliseconds from now, or null. */
|
||||
export function parseRetryAfter(value, now = Date.now()) {
|
||||
if (value == null) return null;
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const seconds = Number(raw);
|
||||
const milliseconds = seconds * 1000;
|
||||
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
|
||||
}
|
||||
// Numeric-looking but invalid delay-seconds must not be reinterpreted as a date.
|
||||
if (/^[+-]?(?:\d|\.\d)/.test(raw)) return null;
|
||||
const at = parseHttpDate(raw, now);
|
||||
return at === null ? null : Math.max(0, at - now);
|
||||
}
|
||||
|
||||
/** UTC calendar day, the unit of the daily cap. */
|
||||
function utcDay(ms) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-service state shared by every process using the same file: today's
|
||||
* request count, the next reserved start, and any pause the server asked for.
|
||||
*
|
||||
* Every change is a read-modify-write under an OS-owned SQLite transaction,
|
||||
* and the JSON file is replaced atomically, so two dev servers sharing a
|
||||
* checkout add to one count instead of overwriting each other. A process exit
|
||||
* releases only that process's transaction; there is no stale path to delete
|
||||
* and therefore no stale reclaimer that can remove a successor's live lock.
|
||||
* 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,
|
||||
lockWaitMs = 500,
|
||||
onError = (error) =>
|
||||
console.warn(
|
||||
`[Nominatim] could not persist usage state: ${error?.message || error}`,
|
||||
),
|
||||
} = {}) {
|
||||
let memory = { day: '', count: 0, pausedUntil: 0, nextStartAt: 0 };
|
||||
let lastError = null;
|
||||
|
||||
const report = (error) => {
|
||||
if (!lastError) onError(error);
|
||||
lastError = error;
|
||||
};
|
||||
|
||||
const pauseMarkerPrefix = file ? `${path.basename(file)}.pause.` : null;
|
||||
|
||||
/**
|
||||
* A refusal must remain visible even while another process owns the SQLite
|
||||
* transaction. Immutable marker names make that write atomic and monotonic:
|
||||
* concurrent writers cannot shorten one another's pause, and the next lock
|
||||
* owner folds every marker into the normal JSON state.
|
||||
*/
|
||||
function pendingPauses({ strict = false } = {}) {
|
||||
if (!file) return [];
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(path.dirname(file));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
report(error);
|
||||
if (strict) throw error;
|
||||
return [];
|
||||
}
|
||||
const pauses = [];
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(pauseMarkerPrefix)) continue;
|
||||
const encoded = name.slice(pauseMarkerPrefix.length).split('.', 1)[0];
|
||||
if (!/^\d+$/.test(encoded)) continue;
|
||||
const pausedUntil = Number(encoded);
|
||||
if (!Number.isSafeInteger(pausedUntil)) {
|
||||
const error = new Error('invalid durable Nominatim pause marker');
|
||||
report(error);
|
||||
if (strict) throw error;
|
||||
continue;
|
||||
}
|
||||
pauses.push({
|
||||
path: path.join(path.dirname(file), name),
|
||||
pausedUntil,
|
||||
});
|
||||
}
|
||||
return pauses;
|
||||
}
|
||||
|
||||
function markPause(at) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const pausedUntil = Math.ceil(at);
|
||||
const marker = `${file}.pause.${pausedUntil}.${process.pid}.${process.hrtime.bigint()}`;
|
||||
fs.writeFileSync(marker, '', { flag: 'wx' });
|
||||
return pausedUntil;
|
||||
}
|
||||
|
||||
function clearPersistedPauses(pausedUntil) {
|
||||
for (const marker of pendingPauses()) {
|
||||
if (marker.pausedUntil > pausedUntil) continue;
|
||||
try {
|
||||
fs.unlinkSync(marker.path);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') report(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),
|
||||
nextStartAt: Math.max(a.nextStartAt || 0, b.nextStartAt || 0),
|
||||
});
|
||||
|
||||
const parseState = (parsed) => {
|
||||
const validDay =
|
||||
parsed?.day === '' ||
|
||||
(typeof parsed?.day === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(parsed.day) &&
|
||||
new Date(`${parsed.day}T00:00:00.000Z`)
|
||||
.toISOString()
|
||||
.startsWith(parsed.day));
|
||||
const validCount =
|
||||
Number.isInteger(parsed?.count) &&
|
||||
parsed.count >= 0 &&
|
||||
(parsed.day !== '' || parsed.count === 0);
|
||||
const validPausedUntil =
|
||||
Number.isFinite(parsed?.pausedUntil) && parsed.pausedUntil >= 0;
|
||||
// nextStartAt was added after the original state format. Its absence is
|
||||
// the one supported migration; a present value must still be trustworthy.
|
||||
const validNextStartAt =
|
||||
parsed?.nextStartAt === undefined ||
|
||||
(Number.isFinite(parsed.nextStartAt) && parsed.nextStartAt >= 0);
|
||||
if (
|
||||
!parsed ||
|
||||
Array.isArray(parsed) ||
|
||||
typeof parsed !== 'object' ||
|
||||
!validDay ||
|
||||
!validCount ||
|
||||
!validPausedUntil ||
|
||||
!validNextStartAt
|
||||
)
|
||||
throw new Error('invalid durable Nominatim usage state');
|
||||
return {
|
||||
day: parsed.day,
|
||||
count: parsed.count,
|
||||
pausedUntil: parsed.pausedUntil,
|
||||
nextStartAt: parsed.nextStartAt ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
function readDisk({ strict = false } = {}) {
|
||||
if (!file) {
|
||||
if (strict)
|
||||
throw new Error('no durable Nominatim usage state is configured');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseState(JSON.parse(fs.readFileSync(file, 'utf8')));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
report(error);
|
||||
if (strict) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function current() {
|
||||
const disk = readDisk();
|
||||
if (disk) memory = merge(memory, disk);
|
||||
const pending = pendingPauses();
|
||||
if (pending.length)
|
||||
memory = merge(memory, {
|
||||
...memory,
|
||||
pausedUntil: Math.max(...pending.map((item) => item.pausedUntil)),
|
||||
});
|
||||
return memory;
|
||||
}
|
||||
|
||||
function withLock(work) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const waitMs = Number.isFinite(lockWaitMs)
|
||||
? Math.max(0, Math.floor(lockWaitMs))
|
||||
: 500;
|
||||
const database = new DatabaseSync(`${file}.lock.sqlite`, {
|
||||
timeout: waitMs,
|
||||
});
|
||||
let transactionOpen = false;
|
||||
try {
|
||||
database.exec('BEGIN IMMEDIATE');
|
||||
transactionOpen = true;
|
||||
const result = work();
|
||||
database.exec('COMMIT');
|
||||
transactionOpen = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (transactionOpen) {
|
||||
try {
|
||||
database.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Closing the connection below still releases this owner's lock.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
database.close();
|
||||
} catch {
|
||||
// The transaction has already been committed or rolled back. Closing
|
||||
// is best effort during exceptional teardown and never removes a lock.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeDisk(next) {
|
||||
const temp = `${file}.${process.pid}.${process.hrtime.bigint()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temp, JSON.stringify(next));
|
||||
fs.renameSync(temp, file);
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(temp);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()));
|
||||
writeDisk(next);
|
||||
memory = next;
|
||||
clearPersistedPauses(next.pausedUntil);
|
||||
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,
|
||||
nextStartAt: state.nextStartAt,
|
||||
})).count;
|
||||
},
|
||||
reserve(day, { now, dailyCap, minSpacingMs, latestStartAt = Infinity }) {
|
||||
if (!file) {
|
||||
const error = new Error(
|
||||
'no durable Nominatim usage state is configured',
|
||||
);
|
||||
report(error);
|
||||
return { status: 'unavailable', error };
|
||||
}
|
||||
try {
|
||||
let result;
|
||||
withLock(() => {
|
||||
const disk = readDisk({ strict: true });
|
||||
if (disk) memory = merge(memory, disk);
|
||||
const pending = pendingPauses({ strict: true });
|
||||
if (pending.length)
|
||||
memory = merge(memory, {
|
||||
...memory,
|
||||
pausedUntil: Math.max(...pending.map((item) => item.pausedUntil)),
|
||||
});
|
||||
const state = memory;
|
||||
if ((state.pausedUntil || 0) > now) {
|
||||
result = {
|
||||
status: 'paused',
|
||||
retryAfterMs: state.pausedUntil - now,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const count = state.day === day ? state.count : 0;
|
||||
if (count >= dailyCap) {
|
||||
result = { status: 'cap' };
|
||||
return;
|
||||
}
|
||||
const startAt = Math.max(now, state.nextStartAt || 0);
|
||||
if (startAt > latestStartAt) {
|
||||
result = { status: 'abandoned' };
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
day,
|
||||
count: count + 1,
|
||||
pausedUntil: state.pausedUntil || 0,
|
||||
nextStartAt: startAt + minSpacingMs,
|
||||
};
|
||||
writeDisk(next);
|
||||
memory = next;
|
||||
clearPersistedPauses(next.pausedUntil);
|
||||
lastError = null;
|
||||
result = { status: 'reserved', startAt, count: next.count };
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
report(error);
|
||||
return { status: 'unavailable', error };
|
||||
}
|
||||
},
|
||||
pausedUntil() {
|
||||
return current().pausedUntil || 0;
|
||||
},
|
||||
pauseUntil(at) {
|
||||
if (file) {
|
||||
try {
|
||||
at = markPause(at);
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
}
|
||||
update((state) => ({ ...state, pausedUntil: at }));
|
||||
},
|
||||
status: () => ({
|
||||
file,
|
||||
durable: Boolean(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 pending = 0;
|
||||
const pausedUntilByEndpoint = new Map();
|
||||
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);
|
||||
const key = String(config?.endpoint || '');
|
||||
if (until > (pausedUntilByEndpoint.get(key) || 0))
|
||||
pausedUntilByEndpoint.set(key, until);
|
||||
if (config?.isPublic) usageStore.pauseUntil(until);
|
||||
}
|
||||
|
||||
function pauseEnd(config) {
|
||||
const endpointPause =
|
||||
pausedUntilByEndpoint.get(String(config?.endpoint || '')) || 0;
|
||||
return config?.isPublic
|
||||
? Math.max(endpointPause, usageStore.pausedUntil())
|
||||
: endpointPause;
|
||||
}
|
||||
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One upstream request; classifies refusals and pauses the gate. */
|
||||
async function send(url, config, maxBytes, signal) {
|
||||
upstreamRequests += 1;
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically reserve the public slot, then wait until it may start. */
|
||||
async function takeTurn(config, queuedAt, signal) {
|
||||
if (signal?.aborted || now() - queuedAt > maxWaitMs)
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
const refused = admissionError(config);
|
||||
if (refused) throw refused;
|
||||
// An operator-selected endpoint is not governed by the public instance's
|
||||
// shared budget, pacing, or durable-state requirement.
|
||||
if (!config.isPublic) return;
|
||||
const reserved = usageStore.reserve(utcDay(now()), {
|
||||
now: now(),
|
||||
dailyCap: config.dailyCap,
|
||||
minSpacingMs,
|
||||
latestStartAt: queuedAt + maxWaitMs,
|
||||
});
|
||||
if (reserved.status === 'unavailable')
|
||||
throw gateError(
|
||||
'NOMINATIM_STATE_UNAVAILABLE',
|
||||
'Public place service usage state is unavailable',
|
||||
{ cause: reserved.error },
|
||||
);
|
||||
if (reserved.status === 'cap')
|
||||
throw gateError(
|
||||
'NOMINATIM_DAILY_CAP',
|
||||
'Daily place lookup allowance is used',
|
||||
);
|
||||
if (reserved.status === 'paused')
|
||||
throw gateError('NOMINATIM_PAUSED', 'Place service is pausing', {
|
||||
retryAfterMs: reserved.retryAfterMs,
|
||||
});
|
||||
if (reserved.status === 'abandoned')
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
const waitMs = Math.max(0, reserved.startAt - now());
|
||||
if (waitMs) await sleep(waitMs);
|
||||
if (signal?.aborted || now() - queuedAt > maxWaitMs)
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
// A refusal from another process after this reservation still stops it.
|
||||
// The reserved daily slot remains spent; it cannot safely be reassigned.
|
||||
if (now() < pauseEnd(config)) throw pausedError(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 public admission, daily count and pauses through one install's file. */
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
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],
|
||||
},
|
||||
});
|
||||
|
||||
const KNOWN_ADMIN_TYPES = new Set(
|
||||
Object.values(ADMIN_EVIDENCE).flatMap((evidence) => evidence.types),
|
||||
);
|
||||
|
||||
/**
|
||||
* Nominatim address types whose meaning is stable enough for the area consumer's
|
||||
* country/admin1/admin2 contract. `region`, `district` and numeric admin levels
|
||||
* vary by country, so they remain unverified instead of being guessed.
|
||||
*/
|
||||
const VERIFIED_ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
province: 'admin1',
|
||||
county: 'admin2',
|
||||
state_district: 'admin2',
|
||||
});
|
||||
|
||||
const EXPLICIT_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
province: 'admin1',
|
||||
state: 'admin1',
|
||||
region: 'admin1',
|
||||
oblast: 'admin1',
|
||||
prefecture: 'admin1',
|
||||
canton: 'admin1',
|
||||
governorate: 'admin1',
|
||||
county: 'admin2',
|
||||
district: 'admin2',
|
||||
department: 'admin2',
|
||||
zone: 'retired',
|
||||
});
|
||||
|
||||
function explicitNameEvidence(value) {
|
||||
const normalized = String(value || '')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
const prefix = normalized.match(
|
||||
/^(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)\s+of\b/,
|
||||
)?.[1];
|
||||
const suffix = normalized.match(
|
||||
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
||||
)?.[1];
|
||||
const levels = new Set(
|
||||
[prefix, suffix]
|
||||
.filter(Boolean)
|
||||
.map((type) => EXPLICIT_NAME_LEVEL[type])
|
||||
.filter(Boolean),
|
||||
);
|
||||
if (levels.size > 1) return { contradictory: true, level: null };
|
||||
return { contradictory: false, level: levels.values().next().value || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified administrative identity carried by the selected result itself.
|
||||
* Never infer it from the request, desired kind, rank, or a separate geocoder
|
||||
* anchor. A retired name is preserved without a level so downstream policy can
|
||||
* reject it explicitly; every other contradiction stays wholly unverified.
|
||||
*/
|
||||
function selectedAdminIdentity(kind, row) {
|
||||
if (kind !== 'admin') return null;
|
||||
const adminLevel =
|
||||
VERIFIED_ADMIN_LEVEL[String(row?.addresstype || '').toLowerCase()];
|
||||
const authoritativeAdminArea = String(row?.name || '').trim();
|
||||
if (!adminLevel || !authoritativeAdminArea) return null;
|
||||
// Validate the complete authoritative name. `adminArea` is truncated only
|
||||
// for display after every prefix/suffix signal has been considered.
|
||||
const { contradictory, level: nameLevel } = explicitNameEvidence(
|
||||
authoritativeAdminArea,
|
||||
);
|
||||
if (contradictory) return null;
|
||||
const adminArea = authoritativeAdminArea.slice(0, 160);
|
||||
if (nameLevel === 'retired') return { adminArea };
|
||||
if (nameLevel && nameLevel !== adminLevel) return null;
|
||||
return { adminArea, adminLevel };
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// A recognized type is direct evidence. Rank is only a fallback when the
|
||||
// upstream answer omits the type or supplies a value we do not understand;
|
||||
// the overlapping county/city rank bands must not override `county`.
|
||||
if (KNOWN_ADMIN_TYPES.has(type)) return false;
|
||||
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 adminIdentity = selectedAdminIdentity(kind, row);
|
||||
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,
|
||||
...(adminIdentity || {}),
|
||||
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
@@ -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' });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user