refactor: separate Nominatim transport from search composition

This commit is contained in:
Sameh Khamis committed 2026-09-14 22:14:51 -07:00
1 parent 2b09352463
commit a79208181f
6 files changed
+157 -126

No files matched your search

+3
View File
@@ -591,6 +591,9 @@ Replacing that service with a different query language requires operation-level
feature/geometry adapters, not just changing its URL. Rendering and geometry feature/geometry adapters, not just changing its URL. Rendering and geometry
selection remain consumers of those results. selection remain consumers of those results.
`./sources/nominatim` exports the lower-level JSONv2 client and normalizers for
server adapters without importing search composition.
`./sources/http-body` exports bounded text/JSON readers using web primitives; `./sources/http-body` exports bounded text/JSON readers using web primitives;
`./sources/overpass` exports the quoted-string/comment lexer. The lexer alone is `./sources/overpass` exports the quoted-string/comment lexer. The lexer alone is
not a query validator: spatial bounds, timeouts and other policy remain in the not a query validator: spatial bounds, timeouts and other policy remain in the
+2 -1
View File
@@ -229,6 +229,7 @@
"./layers/submarine-cables/source": "./src/layers/submarineCables/bundledSource.js", "./layers/submarine-cables/source": "./src/layers/submarineCables/bundledSource.js",
"./sources/http-body": "./src/sources/httpBody.js", "./sources/http-body": "./src/sources/httpBody.js",
"./sources/overpass": "./src/sources/overpass.js", "./sources/overpass": "./src/sources/overpass.js",
"./search/nominatim": "./src/search/nominatim.js" "./search/nominatim": "./src/search/nominatim.js",
"./sources/nominatim": "./src/sources/nominatim.js"
} }
} }
+2 -1
View File
@@ -674,5 +674,6 @@
"src/search/nominatim.js", "src/search/nominatim.js",
"src/search/nominatim.test.mjs", "src/search/nominatim.test.mjs",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/sources/overpass.js" "src/sources/overpass.js",
"src/sources/nominatim.js"
] ]
+21 -5
View File
@@ -298,7 +298,8 @@
"src/search/nominatim.js", "src/search/nominatim.js",
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/sources/nominatim.js"
], ],
"external": [] "external": []
}, },
@@ -1636,7 +1637,8 @@
"src/sources/reference.js", "src/sources/reference.js",
"src/search/nominatim.js", "src/search/nominatim.js",
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/sources/nominatim.js"
], ],
"external": [ "external": [
"@mapbox/vector-tile", "@mapbox/vector-tile",
@@ -1992,7 +1994,8 @@
"src/search/nominatim.js", "src/search/nominatim.js",
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/sources/nominatim.js"
], ],
"external": [ "external": [
"cesium", "cesium",
@@ -2040,7 +2043,8 @@
"src/search/nominatim.js", "src/search/nominatim.js",
"src/nominatimGeocode.js", "src/nominatimGeocode.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/sources/nominatim.js"
], ],
"external": [ "external": [
"cesium", "cesium",
@@ -2287,7 +2291,8 @@
"src/search/google.js", "src/search/google.js",
"src/search/geospatial.js", "src/search/geospatial.js",
"src/data/regionalModel.js", "src/data/regionalModel.js",
"src/sources/httpBody.js" "src/sources/httpBody.js",
"src/sources/nominatim.js"
], ],
"external": [] "external": []
}, },
@@ -2300,5 +2305,16 @@
"exports": ["./sources/overpass"], "exports": ["./sources/overpass"],
"modules": ["src/sources/overpass.js"], "modules": ["src/sources/overpass.js"],
"external": [] "external": []
},
"nominatim-source": {
"exports": ["./sources/nominatim"],
"modules": [
"src/sources/nominatim.js",
"src/nominatimGeocode.js",
"src/search/geospatial.js",
"src/data/regionalModel.js",
"src/sources/httpBody.js"
],
"external": []
} }
} }
+9 -119
View File
@@ -1,124 +1,14 @@
import { import {
nominatimToGeocodeResult, createNominatimClient,
nominatimViewboxFromBounds, normalizeNominatimResult,
} from '../nominatimGeocode.js'; normalizeNominatimReverse,
} from '../sources/nominatim.js';
import { normalizeGooglePlace } from './google.js'; import { normalizeGooglePlace } from './google.js';
import { validCoordinate } from './geospatial.js'; export {
import { normalizeRegionalPlace } from '../data/regionalModel.js'; createNominatimClient,
import { readResponseJsonCapped } from '../sources/httpBody.js'; normalizeNominatimResult,
normalizeNominatimReverse,
/** Retain the established framing/type rules for JSON and JSONv2 results. */ };
export function normalizeNominatimResult(hit) {
return nominatimToGeocodeResult(
hit && { ...hit, class: hit.class || hit.category },
);
}
export function normalizeNominatimReverse(hit) {
const result = normalizeNominatimResult(hit);
const region = normalizeRegionalPlace(hit);
if (!result || !region) return null;
const clean = (value) =>
String(value || '')
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const street = clean(hit.address?.road || hit.address?.pedestrian);
return {
formattedAddress: result.formatted_address,
locality: region.locality,
region: region.region,
country: region.country,
types: result.types,
labels: [clean(result.formatted_address)].filter(Boolean),
streetLabels: street ? [street] : [],
};
}
/**
* Nominatim JSONv2 transport for explicitly configured endpoints. Callers own
* scheduling and request headers; this does not select a public instance.
*/
export function createNominatimClient({
searchEndpoint,
reverseEndpoint,
fetchImpl = (...args) => fetch(...args),
} = {}) {
for (const endpoint of [searchEndpoint, reverseEndpoint]) {
if (endpoint == null) continue;
if (typeof endpoint !== 'string' || !endpoint || /[?#]/.test(endpoint))
throw new TypeError('A query-free Nominatim endpoint is required');
const url = new URL(endpoint, 'http://localhost');
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
!(
(endpoint.startsWith('/') && !endpoint.startsWith('//')) ||
/^https?:\/\//.test(endpoint)
)
)
throw new TypeError('Invalid Nominatim endpoint');
}
async function request(endpoint, params, signal) {
signal?.throwIfAborted();
const response = await fetchImpl(
`${endpoint}?${new URLSearchParams({
format: 'jsonv2',
addressdetails: '1',
...params,
})}`,
{ signal, redirect: 'error' },
);
signal?.throwIfAborted();
if (!response.ok) {
await response.body?.cancel?.().catch(() => {});
throw new Error('Geocoding service unavailable');
}
return readResponseJsonCapped(response, 512 * 1024, signal);
}
return {
...(searchEndpoint
? {
async search(query, { bias, signal } = {}) {
const viewbox = nominatimViewboxFromBounds(bias);
const rows = await request(
searchEndpoint,
{
q: query,
limit: '1',
...(viewbox ? { viewbox, bounded: '0' } : {}),
},
signal,
);
if (!Array.isArray(rows))
throw new Error('Invalid geocoding response');
if (rows.length && !normalizeNominatimResult(rows[0]))
throw new Error('Invalid geocoding result');
return rows;
},
}
: {}),
...(reverseEndpoint
? {
async reverse(latitude, longitude, { signal } = {}) {
if (!validCoordinate([longitude, latitude])) return null;
const row = await request(
reverseEndpoint,
{ lat: latitude, lon: longitude },
signal,
);
// A successful, explicit no-result response is distinct from malformed data.
if (row?.error === 'Unable to geocode') return null;
if (!normalizeNominatimReverse(row))
throw new Error('Invalid reverse geocoding result');
return row;
},
}
: {}),
};
}
/** Forward and reverse operations only; feature queries and routing are separate. */ /** Forward and reverse operations only; feature queries and routing are separate. */
export function createNominatimProvider(options = {}) { export function createNominatimProvider(options = {}) {
+120
View File
@@ -0,0 +1,120 @@
import {
nominatimToGeocodeResult,
nominatimViewboxFromBounds,
} from '../nominatimGeocode.js';
import { validCoordinate } from '../search/geospatial.js';
import { normalizeRegionalPlace } from '../data/regionalModel.js';
import { readResponseJsonCapped } from './httpBody.js';
/** Retain the established framing/type rules for JSON and JSONv2 results. */
export function normalizeNominatimResult(hit) {
return nominatimToGeocodeResult(
hit && { ...hit, class: hit.class || hit.category },
);
}
export function normalizeNominatimReverse(hit) {
const result = normalizeNominatimResult(hit);
const region = normalizeRegionalPlace(hit);
if (!result || !region) return null;
const clean = (value) =>
String(value || '')
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const street = clean(hit.address?.road || hit.address?.pedestrian);
return {
formattedAddress: result.formatted_address,
locality: region.locality,
region: region.region,
country: region.country,
types: result.types,
labels: [clean(result.formatted_address)].filter(Boolean),
streetLabels: street ? [street] : [],
};
}
/**
* Nominatim JSONv2 transport for explicitly configured endpoints. Callers own
* scheduling and request headers; this does not select a public instance.
*/
export function createNominatimClient({
searchEndpoint,
reverseEndpoint,
fetchImpl = (...args) => fetch(...args),
} = {}) {
for (const endpoint of [searchEndpoint, reverseEndpoint]) {
if (endpoint == null) continue;
if (typeof endpoint !== 'string' || !endpoint || /[?#]/.test(endpoint))
throw new TypeError('A query-free Nominatim endpoint is required');
const url = new URL(endpoint, 'http://localhost');
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
!(
(endpoint.startsWith('/') && !endpoint.startsWith('//')) ||
/^https?:\/\//.test(endpoint)
)
)
throw new TypeError('Invalid Nominatim endpoint');
}
async function request(endpoint, params, signal) {
signal?.throwIfAborted();
const response = await fetchImpl(
`${endpoint}?${new URLSearchParams({
format: 'jsonv2',
addressdetails: '1',
...params,
})}`,
{ signal, redirect: 'error' },
);
signal?.throwIfAborted();
if (!response.ok) {
await response.body?.cancel?.().catch(() => {});
throw new Error('Geocoding service unavailable');
}
return readResponseJsonCapped(response, 512 * 1024, signal);
}
return {
...(searchEndpoint
? {
async search(query, { bias, signal } = {}) {
const viewbox = nominatimViewboxFromBounds(bias);
const rows = await request(
searchEndpoint,
{
q: query,
limit: '1',
...(viewbox ? { viewbox, bounded: '0' } : {}),
},
signal,
);
if (!Array.isArray(rows))
throw new Error('Invalid geocoding response');
if (rows.length && !normalizeNominatimResult(rows[0]))
throw new Error('Invalid geocoding result');
return rows;
},
}
: {}),
...(reverseEndpoint
? {
async reverse(latitude, longitude, { signal } = {}) {
if (!validCoordinate([longitude, latitude])) return null;
const row = await request(
reverseEndpoint,
{ lat: latitude, lon: longitude },
signal,
);
// A successful, explicit no-result response is distinct from malformed data.
if (row?.error === 'Unable to geocode') return null;
if (!normalizeNominatimReverse(row))
throw new Error('Invalid reverse geocoding result');
return row;
},
}
: {}),
};
}