diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 7bb89d80a..41b674996 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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 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/overpass` exports the quoted-string/comment lexer. The lexer alone is not a query validator: spatial bounds, timeouts and other policy remain in the diff --git a/package.json b/package.json index 3171375d4..6f8a5b411 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,7 @@ "./layers/submarine-cables/source": "./src/layers/submarineCables/bundledSource.js", "./sources/http-body": "./src/sources/httpBody.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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4018a9498..30b035a0e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -674,5 +674,6 @@ "src/search/nominatim.js", "src/search/nominatim.test.mjs", "src/sources/httpBody.js", - "src/sources/overpass.js" + "src/sources/overpass.js", + "src/sources/nominatim.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cc5561a89..cdd9fe6bb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -298,7 +298,8 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/sources/nominatim.js" ], "external": [] }, @@ -1636,7 +1637,8 @@ "src/sources/reference.js", "src/search/nominatim.js", "src/nominatimGeocode.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/sources/nominatim.js" ], "external": [ "@mapbox/vector-tile", @@ -1992,7 +1994,8 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/sources/nominatim.js" ], "external": [ "cesium", @@ -2040,7 +2043,8 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/sources/nominatim.js" ], "external": [ "cesium", @@ -2287,7 +2291,8 @@ "src/search/google.js", "src/search/geospatial.js", "src/data/regionalModel.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/sources/nominatim.js" ], "external": [] }, @@ -2300,5 +2305,16 @@ "exports": ["./sources/overpass"], "modules": ["src/sources/overpass.js"], "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": [] } } diff --git a/src/search/nominatim.js b/src/search/nominatim.js index 029d68224..927c2e1d1 100644 --- a/src/search/nominatim.js +++ b/src/search/nominatim.js @@ -1,124 +1,14 @@ import { - nominatimToGeocodeResult, - nominatimViewboxFromBounds, -} from '../nominatimGeocode.js'; + createNominatimClient, + normalizeNominatimResult, + normalizeNominatimReverse, +} from '../sources/nominatim.js'; import { normalizeGooglePlace } from './google.js'; -import { validCoordinate } from './geospatial.js'; -import { normalizeRegionalPlace } from '../data/regionalModel.js'; -import { readResponseJsonCapped } from '../sources/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; - }, - } - : {}), - }; -} +export { + createNominatimClient, + normalizeNominatimResult, + normalizeNominatimReverse, +}; /** Forward and reverse operations only; feature queries and routing are separate. */ export function createNominatimProvider(options = {}) { diff --git a/src/sources/nominatim.js b/src/sources/nominatim.js new file mode 100644 index 000000000..c2f9382c8 --- /dev/null +++ b/src/sources/nominatim.js @@ -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; + }, + } + : {}), + }; +}