feat(search): Nominatim as the last-resort geocoder (#573)

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

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

Co-authored-by: sendmebits <[email protected]>
This commit is contained in:
Bilawal Sidhuandsendmebits authored and GitHub committed 2026-09-14 21:22:08 -05:00
1 parent cca6442997
commit 7fa0cfbb26
15 files changed
+1077 -48

No files matched your search

+1 -1
View File
@@ -99,7 +99,7 @@ export const DATA_CREDITS = [
{
key: 'cockpit-place-osm',
html:
'Cockpit place context: ' +
'Cockpit place context and last-resort place search: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'via Nominatim (ODbL 1.0)',
},
+171
View File
@@ -0,0 +1,171 @@
/**
* Map a Nominatim search hit into the result shape the camera framing already
* reads (types + viewport). Pure — no network.
*
* Every numeric field arrives as a string from the upstream JSON and may be
* absent, blank, or nonsense. A coordinate that is not a real coordinate must
* be refused rather than coerced: `Number(null)` is 0, so a missing pair read
* as `(0, 0)` would fly the operator to the Atlantic and look like a hit.
*/
const ADDRESS_TYPE_MAP = Object.freeze({
country: ['country', 'political'],
nation: ['country', 'political'],
state: ['administrative_area_level_1', 'political'],
province: ['administrative_area_level_1', 'political'],
region: ['administrative_area_level_1', 'political'],
county: ['administrative_area_level_2', 'political'],
district: ['administrative_area_level_2', 'political'],
municipality: ['locality', 'political'],
city: ['locality', 'political'],
town: ['locality', 'political'],
village: ['locality', 'political'],
hamlet: ['locality', 'political'],
suburb: ['neighborhood', 'political'],
neighbourhood: ['neighborhood', 'political'],
neighborhood: ['neighborhood', 'political'],
quarter: ['neighborhood', 'political'],
postcode: ['postal_code'],
road: ['route'],
highway: ['route'],
peak: ['natural_feature'],
mountain_range: ['natural_feature'],
wood: ['natural_feature'],
forest: ['natural_feature'],
park: ['park'],
nature_reserve: ['park', 'natural_feature'],
aerodrome: ['airport'],
airport: ['airport'],
university: ['university'],
stadium: ['stadium'],
island: ['natural_feature'],
});
/**
* Read a field that must be a finite number inside a range.
*
* Null, undefined, blank, whitespace, booleans, arrays and anything that does
* not parse cleanly all return null. Only a string or number is considered, so
* `Number([])` and `Number(true)` cannot become 0 and 1.
*
* @param {unknown} value
* @param {number} min
* @param {number} max
* @returns {number|null}
*/
function boundedNumber(value, min, max) {
if (typeof value !== 'number' && typeof value !== 'string') return null;
if (typeof value === 'string' && value.trim() === '') return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return parsed >= min && parsed <= max ? parsed : null;
}
/** A latitude that is really a latitude, or null. */
export function nominatimLatitude(value) {
return boundedNumber(value, -90, 90);
}
/** A longitude that is really a longitude, or null. */
export function nominatimLongitude(value) {
return boundedNumber(value, -180, 180);
}
/** Framing types for the camera, derived from the hit's class and type. */
export function typesFromNominatim(hit) {
const addresstype = String(hit?.addresstype || '').toLowerCase();
const osmType = String(hit?.type || '').toLowerCase();
const osmClass = String(hit?.class || '').toLowerCase();
const mapped =
ADDRESS_TYPE_MAP[addresstype] ||
ADDRESS_TYPE_MAP[osmType] ||
(osmClass === 'highway' ? ['route'] : null) ||
(osmClass === 'leisure' ? ['park'] : null) ||
(osmClass === 'natural' ? ['natural_feature'] : null) ||
(osmClass === 'aeroway' ? ['airport'] : null) ||
(osmClass === 'amenity' && osmType === 'university'
? ['university']
: null);
return mapped ? [...mapped] : ['point_of_interest', 'establishment'];
}
/**
* The hit's bounding box as a viewport, or null.
*
* Nominatim gives `[south, north, west, east]` as strings. A box with an edge
* outside the real world, an inverted edge, or a missing value is refused —
* framing a viewport with a longitude of 999 sends the camera to nowhere.
*
* @param {object} hit
* @returns {{southwest: {lat: number, lng: number}, northeast: {lat: number, lng: number}}|null}
*/
export function viewportFromNominatim(hit) {
const box = Array.isArray(hit?.boundingbox) ? hit.boundingbox : [];
if (box.length !== 4) return null;
const south = nominatimLatitude(box[0]);
const north = nominatimLatitude(box[1]);
const west = nominatimLongitude(box[2]);
const east = nominatimLongitude(box[3]);
if (south === null || north === null || west === null || east === null)
return null;
if (south > north) return null;
return {
southwest: { lat: south, lng: west },
northeast: { lat: north, lng: east },
};
}
/**
* Convert one Nominatim hit into a geocoding result, or null.
*
* @param {object} hit
* @returns {{geometry: object, formatted_address: string, types: string[], address_components: object[]}|null}
*/
export function nominatimToGeocodeResult(hit) {
const lat = nominatimLatitude(hit?.lat);
const lng = nominatimLongitude(hit?.lon);
if (lat === null || lng === null) return null;
const label = String(hit?.display_name || hit?.name || '').trim();
if (!label) return null;
const types = typesFromNominatim(hit);
const viewport = viewportFromNominatim(hit);
const canonical = String(hit?.name || '').trim();
return {
geometry: {
location: { lat, lng },
...(viewport ? { bounds: viewport, viewport } : {}),
},
formatted_address: label,
types,
address_components: canonical
? [{ long_name: canonical, short_name: canonical, types: [...types] }]
: [],
};
}
/**
* Translate a `swLat,swLng|neLat,neLng` bias into Nominatim's
* `viewbox=west,north,east,south`, or null when the string is unusable.
*
* Both edges are range-checked. A viewbox naming a longitude of 999 is not a
* hint the upstream can use, and sending it is a malformed request rather than
* a soft one.
*
* @param {string} bounds
* @returns {string|null}
*/
export function nominatimViewboxFromBounds(bounds) {
const match = String(bounds || '')
.trim()
.match(
/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\|(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$/,
);
if (!match) return null;
const swLat = nominatimLatitude(match[1]);
const swLng = nominatimLongitude(match[2]);
const neLat = nominatimLatitude(match[3]);
const neLng = nominatimLongitude(match[4]);
if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null;
if (swLat >= neLat) return null;
return `${swLng},${neLat},${neLng},${swLat}`;
}
+175
View File
@@ -0,0 +1,175 @@
// NOMINATIM RESULT MAPPING — what counts as a usable hit.
//
// Every numeric field arrives as a string and may be absent or nonsense, and
// `Number(null)` is 0: a hit with no coordinates read as (0, 0) would fly the
// camera to the Atlantic and look like a successful search. These cases pin
// every field that must be refused rather than coerced.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import {
nominatimLatitude,
nominatimLongitude,
nominatimToGeocodeResult,
nominatimViewboxFromBounds,
typesFromNominatim,
viewportFromNominatim,
} from './nominatimGeocode.js';
test('a coordinate field is a number in range, or nothing', () => {
assert.equal(nominatimLatitude('43.1731'), 43.1731);
assert.equal(nominatimLatitude(-33.8568), -33.8568);
assert.equal(nominatimLatitude('90'), 90);
assert.equal(nominatimLongitude('-180'), -180);
for (const value of [
null,
undefined,
'',
' ',
'abc',
NaN,
Infinity,
true,
false,
[],
['43'],
{},
'91',
'-90.5',
])
assert.equal(
nominatimLatitude(value),
null,
`${JSON.stringify(value)} is not a latitude`,
);
for (const value of [null, '', '181', '-180.5', true, []])
assert.equal(
nominatimLongitude(value),
null,
`${JSON.stringify(value)} is not a longitude`,
);
});
test('a hit without real coordinates produces no result at all', () => {
for (const hit of [
null,
undefined,
{},
{ lat: null, lon: null, display_name: 'Nowhere' },
{ lat: '', lon: '', display_name: 'Nowhere' },
{ lat: '43.17', lon: null, display_name: 'Half a place' },
{ lat: null, lon: '-79.03', display_name: 'Half a place' },
{ lat: '91', lon: '0', display_name: 'Off the pole' },
{ lat: '0', lon: '999', display_name: 'Off the world' },
// Coordinates but no name: nothing to show the operator.
{ lat: '43.17', lon: '-79.03' },
{ lat: '43.17', lon: '-79.03', display_name: ' ' },
])
assert.equal(
nominatimToGeocodeResult(hit),
null,
`${JSON.stringify(hit)} must not become a result`,
);
});
test('a real hit carries its point, label, types and box', () => {
const result = nominatimToGeocodeResult({
lat: '43.1731',
lon: '-79.0384',
display_name: 'Niagara Falls, Ontario, Canada',
name: 'Niagara Falls',
addresstype: 'city',
boundingbox: ['43.0500', '43.1500', '-79.1500', '-79.0000'],
});
assert.deepEqual(result.geometry.location, { lat: 43.1731, lng: -79.0384 });
assert.equal(result.formatted_address, 'Niagara Falls, Ontario, Canada');
assert.deepEqual(result.types, ['locality', 'political']);
assert.deepEqual(result.geometry.bounds, {
southwest: { lat: 43.05, lng: -79.15 },
northeast: { lat: 43.15, lng: -79 },
});
assert.equal(result.address_components[0].long_name, 'Niagara Falls');
});
test('a box with an impossible or inverted edge is dropped, not framed', () => {
for (const boundingbox of [
undefined,
[],
['43.05', '43.15', '-79.15'],
['43.05', '43.15', '-79.15', '999'],
['43.05', '43.15', '-999', '-79.0'],
['-91', '43.15', '-79.15', '-79.0'],
['43.05', '91', '-79.15', '-79.0'],
[null, '43.15', '-79.15', '-79.0'],
['', '43.15', '-79.15', '-79.0'],
// South above north is not a box.
['43.15', '43.05', '-79.15', '-79.0'],
]) {
assert.equal(
viewportFromNominatim({ boundingbox }),
null,
`${JSON.stringify(boundingbox)} must not become a viewport`,
);
const result = nominatimToGeocodeResult({
lat: '43.1731',
lon: '-79.0384',
display_name: 'Somewhere',
boundingbox,
});
assert.ok(result, 'the point itself still stands');
assert.equal(
result.geometry.bounds,
undefined,
'a place with no usable box is framed as a point',
);
}
});
test('types fall back to a place rather than guessing a category', () => {
assert.deepEqual(typesFromNominatim({ addresstype: 'country' }), [
'country',
'political',
]);
assert.deepEqual(typesFromNominatim({ class: 'highway', type: 'primary' }), [
'route',
]);
assert.deepEqual(typesFromNominatim({}), [
'point_of_interest',
'establishment',
]);
assert.deepEqual(typesFromNominatim(null), [
'point_of_interest',
'establishment',
]);
});
test('a viewport hint is sent only when every edge is a real coordinate', () => {
assert.equal(
nominatimViewboxFromBounds('30.1,-97.95|30.52,-97.55'),
'-97.95,30.52,-97.55,30.1',
);
for (const bounds of [
null,
undefined,
'',
'not bounds',
'30.1,-97.95',
// Out of range on each edge in turn.
'-91,-97.95|30.52,-97.55',
'30.1,-999|30.52,-97.55',
'30.1,-97.95|91,-97.55',
'30.1,-97.95|30.52,999',
// Inverted latitudes are not a box.
'30.52,-97.95|30.1,-97.55',
'30.1,-97.95|30.1,-97.55',
])
assert.equal(
nominatimViewboxFromBounds(bounds),
null,
`${JSON.stringify(bounds)} must not become a viewbox`,
);
});
+17 -2
View File
@@ -8,8 +8,8 @@ import { createPresetGeocoder } from './presetGeocoder.js';
/**
* Coordinates and bundled names first — both answer offline and with no key —
* then Google when configured, then keyless Photon. Transport stays local to
* setup.
* then Google when configured, then keyless Photon, then the local Nominatim
* route as a last resort. Transport stays local to setup.
*
* `presets` is the caller's bundled place data. It is passed in rather than
* imported so this package keeps reading no application state; with none
@@ -43,6 +43,21 @@ export function createDefaultPlaceSearch({
},
}),
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
// Last resort: the local Nominatim route, which answers with no key when
// neither of the two above did. It speaks the same result shape, so it
// rides the existing Google adapter rather than needing its own.
createGoogleGeocoder({
request(query, { bias, signal }) {
const params = new URLSearchParams({ q: query });
if (bias) params.set('bounds', bias);
return fetchImpl(
`${endpoints.nominatim || '/api/geocode'}?${params}`,
{
signal,
},
);
},
}),
],
});
return {
+368
View File
@@ -0,0 +1,368 @@
// THE LAST-RESORT PLACE SEARCH ROUTE — what it is allowed to send upstream.
//
// The public instance permits one request per second. 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. These cases pin the
// queue bound, the shared answer for identical searches, the cancellation path,
// and the chain order that only reaches here when nothing else answered.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import {
fetchNominatimSearch,
geocodeProxy,
NOMINATIM_MAX_PENDING,
} from '../../server/providers/regional/place.js';
import { fetchRegionalJson } from '../../server/providers/regional/http.js';
import { localProviderPlugins } from '../../server/providers/local.js';
import { createPlaceSearch, createGoogleGeocoder } from '../search/index.js';
import { createPhotonGeocoder } from '../keylessGeocoder.js';
const HIT = [
{
lat: '43.1731',
lon: '-79.0384',
display_name: 'Niagara Falls, Ontario, Canada',
name: 'Niagara Falls',
addresstype: 'city',
boundingbox: ['43.05', '43.15', '-79.15', '-79.0'],
},
];
/** Mount the geocode route the way the dev server does. */
let clientCounter = 0;
function mountGeocode(
install = (server) => geocodeProxy().configureServer(server),
) {
// A fresh client address per mount: the per-client rate limiter is module
// state shared by every case in this file, and one case's burst must not
// decide another case's outcome.
clientCounter += 1;
const remoteAddress = `10.0.0.${clientCounter % 250}`;
const routes = new Map();
install({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
return async function request(path) {
const requested = new URL(path, 'http://localhost');
const handler = routes.get('/api/geocode');
if (!handler) throw new Error('no /api/geocode route');
// connect strips the mount prefix before the handler sees the request.
const rest =
requested.pathname.slice('/api/geocode'.length) + requested.search;
const req = {
method: 'GET',
url: rest[0] === '/' ? rest : `/${rest}`,
headers: {},
socket: { remoteAddress },
on() {},
};
return await new Promise((resolve, reject) => {
let status = 200;
let headers = {};
const res = {
writableEnded: false,
on() {},
writeHead(code, sent) {
status = code;
headers = sent || {};
return res;
},
end(body = '') {
res.writableEnded = true;
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({ status, headers, body: parsed });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
};
}
/** Answer the upstream from a stub, counting and optionally stalling calls. */
async function withUpstream(respond, run) {
const previous = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return await respond(String(url), options, calls.length);
};
try {
return await run(calls);
} finally {
globalThis.fetch = previous;
}
}
const jsonResponse = (value) =>
new Response(JSON.stringify(value), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
test('the route is part of the application on both dev and preview', () => {
const plugin = localProviderPlugins().find(
(entry) => entry.name === 'geocode-proxy',
);
assert.ok(plugin, 'the application must compose the place-search route');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
});
test('a search reaches the upstream identified, and the answer is cached', async () => {
const request = mountGeocode();
await withUpstream(
async () => jsonResponse(HIT),
async (calls) => {
const query = `niagara-${Date.now()}`;
const first = await request(`/api/geocode?q=${query}`);
assert.equal(first.status, 200);
assert.equal(first.body.status, 'OK');
assert.deepEqual(first.body.results[0].geometry.location, {
lat: 43.1731,
lng: -79.0384,
});
assert.equal(calls.length, 1);
const headers = calls[0].options.headers;
assert.match(
String(headers['User-Agent']),
/gods-eye-view\/\d/,
'the policy asks for a User-Agent identifying the application',
);
assert.ok(headers.Referer, 'a Referer is sent alongside it');
assert.equal(
calls[0].options.redirect,
'error',
'a fixed API endpoint may not be redirected elsewhere',
);
// The policy asks that results be cached; a repeat costs no request.
const repeat = await request(`/api/geocode?q=${query}`);
assert.equal(repeat.status, 200);
assert.equal(
calls.length,
1,
'the second identical search is served from cache',
);
},
);
});
test('identical searches in flight share one upstream call', async () => {
const request = mountGeocode();
const query = `coalesce-${Date.now()}`;
await withUpstream(
async () => {
await new Promise((resolve) => setTimeout(resolve, 40));
return jsonResponse(HIT);
},
async (calls) => {
const answers = await Promise.all([
request(`/api/geocode?q=${query}`),
request(`/api/geocode?q=${query}`),
request(`/api/geocode?q=${query}`),
]);
for (const answer of answers) assert.equal(answer.status, 200);
assert.equal(calls.length, 1, 'one upstream call serves all three');
},
);
});
test('a burst past the queue bound is refused rather than queued for minutes', async () => {
const request = mountGeocode();
const stamp = Date.now();
await withUpstream(
async () => jsonResponse([]),
async (calls) => {
const answers = await Promise.all(
Array.from({ length: 20 }, (_unused, index) =>
request(`/api/geocode?q=burst-${stamp}-${index}`),
),
);
const refused = answers.filter((answer) => answer.status === 429);
assert.ok(
refused.length > 0,
'a burst this size must be refused somewhere',
);
for (const answer of refused)
assert.equal(answer.headers['Retry-After'], '5');
assert.ok(
calls.length <= NOMINATIM_MAX_PENDING + 1,
`the upstream saw ${calls.length} of 20 searches; the queue bound is ${NOMINATIM_MAX_PENDING}`,
);
assert.equal(
answers.length,
20,
'every caller is answered, none is left hanging',
);
},
);
});
test('a query that is missing or too long never reaches the upstream', async () => {
const request = mountGeocode();
await withUpstream(
async () => jsonResponse(HIT),
async (calls) => {
for (const path of [
'/api/geocode',
'/api/geocode?q=',
'/api/geocode?q=%20%20',
`/api/geocode?q=${'x'.repeat(201)}`,
]) {
const answer = await request(path);
assert.equal(answer.status, 400, path);
}
assert.deepEqual(calls, []);
},
);
});
test('an upstream failure is a service answer, not a crash', async () => {
const request = mountGeocode();
await withUpstream(
async () => new Response('upstream down', { status: 502 }),
async () => {
const answer = await request(`/api/geocode?q=down-${Date.now()}`);
assert.equal(answer.status, 503);
assert.equal(answer.headers['Cache-Control'], 'no-store');
assert.match(String(answer.body.error), /temporarily unavailable/);
},
);
});
test('a search with no hit is a clean empty answer', async () => {
const request = mountGeocode();
await withUpstream(
async () => jsonResponse([]),
async () => {
const answer = await request(`/api/geocode?q=nowhere-${Date.now()}`);
assert.equal(answer.status, 200);
assert.equal(answer.body.status, 'ZERO_RESULTS');
assert.deepEqual(answer.body.results, []);
},
);
});
test('the chain reaches this route only when Google and Photon do not answer', async () => {
const asked = [];
const request = mountGeocode();
await withUpstream(
async (url) => {
if (url.includes('photon.komoot.io')) {
asked.push('photon');
return new Response('photon is down', { status: 503 });
}
asked.push('nominatim');
return jsonResponse(HIT);
},
async () => {
const search = createPlaceSearch({
providers: [
createGoogleGeocoder({
request() {
asked.push('google');
// An unconfigured provider contributes no verdict.
return null;
},
}),
createPhotonGeocoder({
fetchImpl: (...args) => globalThis.fetch(...args),
}),
createGoogleGeocoder({
request(query) {
return request(
`/api/geocode?q=${encodeURIComponent(query)}`,
).then(
(answer) =>
new Response(JSON.stringify(answer.body), {
status: answer.status,
}),
);
},
}),
],
});
const outcome = await search.geocode(`chain-${Date.now()}`);
assert.deepEqual(
asked,
['google', 'photon', 'nominatim'],
`Google first, then Photon, then this route (saw ${asked.join(' → ')})`,
);
assert.equal(outcome.place.lat, 43.1731);
assert.equal(outcome.fallbackUsed, true);
},
);
});
test('a search whose caller gave up is dropped before it costs a slot', async () => {
const abandoned = new AbortController();
abandoned.abort();
await withUpstream(
async () => jsonResponse(HIT),
async (calls) => {
await assert.rejects(
fetchNominatimSearch(`cancelled-${Date.now()}`, null, {
signal: abandoned.signal,
}),
(error) => error.code === 'NOMINATIM_ABANDONED',
);
assert.deepEqual(calls, [], 'a search nobody is waiting for is not sent');
},
);
});
test('the upstream deadline covers the body, not just the headers', async () => {
// A response whose headers arrive and whose body then never does. Before the
// deadline covered the body, the abort timer was already cleared by the time
// the first byte was due, so this read had no deadline at all.
const stalled = () => {
let cancelled = false;
const stream = new ReadableStream({
pull() {
return new Promise(() => {});
},
cancel() {
cancelled = true;
},
});
return {
response: new Response(stream, { status: 200 }),
wasCancelled: () => cancelled,
};
};
const previous = globalThis.fetch;
const { response, wasCancelled } = stalled();
globalThis.fetch = async (_url, options) => {
// The same signal must reach the body read, or aborting stops nothing.
options.signal.addEventListener('abort', () => {
void response.body?.cancel().catch(() => {});
});
return response;
};
try {
const started = Date.now();
await assert.rejects(
fetchRegionalJson('https://example.test/stalls', { timeoutMs: 300 }),
'a stalled body must fail rather than hang',
);
const elapsed = Date.now() - started;
assert.ok(elapsed < 5000, `gave up after ${elapsed} ms`);
assert.ok(wasCancelled(), 'the abort reached the body read');
} finally {
globalThis.fetch = previous;
}
});