refactor: compose configurable geocoding and route services

This commit is contained in:
Sameh Khamis committed 2026-09-13 21:23:13 -07:00
1 parent 3b91feffdc
commit 8b36c28b06
25 files changed
+764 -211

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
import { createGeospatialServices } from './geospatial.js';
import { createHttpGeospatialProvider } from './http.js';
import { createPlaceSearch } from './placeSearch.js';
import { createGoogleGeocoder } from './google.js';
import { createPhotonGeocoder } from '../keylessGeocoder.js';
/** Google first when configured, then keyless Photon; transport stays local to setup. */
export function createDefaultPlaceSearch({
resolveApiKey,
fetchImpl = (...args) => fetch(...args),
signal,
endpoints = {},
providers = {},
} = {}) {
const forward = createPlaceSearch({
signal,
providers: providers.geocode || [
createGoogleGeocoder({
request(query, { bias, signal }) {
const key = resolveApiKey?.();
if (!key) return null;
const url = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
);
url.searchParams.set('address', query);
url.searchParams.set('key', key);
if (bias) url.searchParams.set('bounds', bias);
return fetchImpl(url.toString(), { signal });
},
}),
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
],
});
return {
...forward,
...createGeospatialServices({
signal,
providers: {
...createHttpGeospatialProvider({
fetchImpl,
resolveApiKey,
endpoints,
}),
...providers,
},
}),
};
}
// Compatibility for direct module callers. Application composition supplies its own instance.
export const defaultGeospatial = createDefaultPlaceSearch({
resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__,
});
+88
View File
@@ -0,0 +1,88 @@
/** WGS84 coordinate pair in longitude/latitude order. */
export function validCoordinate(pair) {
return (
Array.isArray(pair) &&
pair.length >= 2 &&
Number.isFinite(pair[0]) &&
Math.abs(pair[0]) <= 180 &&
Number.isFinite(pair[1]) &&
Math.abs(pair[1]) <= 90
);
}
/** Compose independent geospatial operations; unsupported operations stay explicit. */
export function createGeospatialServices({
providers = {},
signal: lifetime,
} = {}) {
const capabilities = Object.freeze(
Object.fromEntries(
['reverseGeocode', 'textSearch', 'nearby', 'route'].map((name) => [
name,
typeof providers[name] === 'function',
]),
),
);
async function invoke(name, args, options = {}) {
const signal = AbortSignal.any(
[
lifetime,
options.signal,
AbortSignal.timeout(name === 'route' ? 13_000 : 5_000),
].filter(Boolean),
);
signal.throwIfAborted();
if (!capabilities[name]) return null;
const result = await providers[name](...args, { ...options, signal });
signal.throwIfAborted();
return result;
}
return {
capabilities,
signal: lifetime,
attribution: Object.freeze({ ...providers.attribution }),
reverseGeocode(latitude, longitude, options) {
if (!validCoordinate([longitude, latitude])) return Promise.resolve(null);
return invoke('reverseGeocode', [latitude, longitude], options);
},
async textSearch(query, point, options) {
if (
!String(query || '').trim() ||
!validCoordinate([point?.longitude, point?.latitude])
)
return [];
return (
(await invoke('textSearch', [String(query).trim(), point], options)) ||
[]
);
},
async nearby(point, options) {
if (!validCoordinate([point?.longitude, point?.latitude])) return [];
return (await invoke('nearby', [point], options)) || [];
},
async route(coordinates, profile = 'foot', options) {
if (
!Array.isArray(coordinates) ||
coordinates.length < 2 ||
coordinates.length > 12 ||
!coordinates.every(validCoordinate)
)
return null;
const profiles = providers.routeProfiles || ['foot', 'car', 'bike'];
if (!profiles.includes(profile)) return null;
const result = await invoke('route', [coordinates, profile], options);
if (
!result ||
!Array.isArray(result.geometry) ||
result.geometry.length < 2 ||
!result.geometry.every(validCoordinate) ||
!Number.isFinite(result.distanceM) ||
result.distanceM < 0 ||
!Number.isFinite(result.durationS) ||
result.durationS < 0
)
return null;
return { ...result, ok: true, profile };
},
};
}
+239
View File
@@ -0,0 +1,239 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGeospatialServices,
createHttpGeospatialProvider,
createDefaultPlaceSearch,
} from './index.js';
import { installRouteMiddleware } from '../../server/providers/places/routes.js';
import { createRegionalPlaceProvider } from '../../server/providers/regional/place.js';
const point = { latitude: 30, longitude: -97, radiusM: 250 };
const coordinates = [
[-97, 30],
[-97.001, 30.001],
];
const route = { geometry: coordinates, distanceM: 120, durationS: 90 };
const json = (data) =>
new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
test('each operation can use an independent provider without coupling forward search', async () => {
const calls = [];
const service = createDefaultPlaceSearch({
providers: {
geocode: [
{
geocode: async () => ({
place: { lat: 30, lng: -97 },
answered: true,
}),
},
],
reverseGeocode: async (lat, lon) => {
calls.push([lat, lon]);
return { locality: 'Town' };
},
textSearch: async (q) => [{ name: q, ...point }],
nearby: async (p) => [p],
route: async () => route,
routeProfiles: ['foot'],
},
});
assert.equal((await service.geocode('Town')).place.lat, 30);
assert.equal((await service.reverseGeocode(30, -97)).locality, 'Town');
assert.equal((await service.textSearch('Library', point))[0].name, 'Library');
assert.deepEqual(await service.nearby(point), [point]);
assert.deepEqual(await service.route(coordinates), {
...route,
ok: true,
profile: 'foot',
});
assert.equal(await service.route(coordinates, 'car'), null);
assert.deepEqual(calls, [[30, -97]]);
});
test('unsupported operations and invalid coordinates never initiate a request', async () => {
let calls = 0;
const service = createGeospatialServices({
providers: {
route: () => {
calls++;
},
},
});
assert.equal(service.capabilities.reverseGeocode, false);
assert.equal(await service.reverseGeocode(30, -97), null);
assert.equal(
await service.route([
[999, 30],
[-97, 30],
]),
null,
);
assert.equal(await service.route(coordinates, 'spaceship'), null);
assert.equal(calls, 0);
const invalid = createGeospatialServices({
providers: { route: async () => ({ ...route, durationS: NaN }) },
});
assert.equal(await invalid.route(coordinates), null);
});
test('application or caller cancellation rejects late provider and body results', async () => {
for (const lifetimeAbort of [true, false]) {
const lifetime = new AbortController();
const caller = new AbortController();
const service = createGeospatialServices({
signal: lifetime.signal,
providers: createHttpGeospatialProvider({
endpoints: { route: '/custom/route' },
fetchImpl: async (_url, { signal }) => ({
ok: true,
async json() {
(lifetimeAbort ? lifetime : caller).abort();
assert.equal(signal.aborted, true);
return { ...route, ok: true };
},
}),
}),
});
await assert.rejects(
service.route(coordinates, 'foot', { signal: caller.signal }),
{ name: 'AbortError' },
);
}
});
test('compatible HTTP endpoints retain units and never place a server credential in browser requests', async () => {
const urls = [];
const service = createGeospatialServices({
providers: createHttpGeospatialProvider({
endpoints: {
reverse: '/geo/reverse',
textSearch: '/geo/search',
nearby: '/geo/nearby',
route: '/geo/route',
},
fetchImpl: async (url) => {
urls.push(new URL(url, 'https://example.test'));
if (url.startsWith('/geo/reverse'))
return json({
status: 'OK',
results: [{ formatted_address: 'Town', address_components: [] }],
});
if (url.startsWith('/geo/route')) return json({ ok: true, ...route });
return json({ places: [{ name: 'Library', ...point }] });
},
}),
});
assert.equal(
(await service.reverseGeocode(30, -97)).formattedAddress,
'Town',
);
await service.textSearch('Library', point);
await service.nearby(point);
assert.equal((await service.route(coordinates)).distanceM, 120);
assert.deepEqual(
urls.map((url) => url.pathname),
['/geo/reverse', '/geo/search', '/geo/nearby', '/geo/route'],
);
assert.ok(urls.every((url) => !url.searchParams.has('key')));
});
test('HTTP refusal cancels its response and does not retry another destination', async () => {
let cancelled = 0,
calls = 0;
const provider = createHttpGeospatialProvider({
fetchImpl: async () => {
calls++;
return {
ok: false,
body: {
async cancel() {
cancelled++;
},
},
};
},
});
await assert.rejects(provider.nearby(point), /unavailable/);
assert.equal(calls, 1);
assert.equal(cancelled, 1);
});
test('Photon configuration changes its endpoint without changing its normalized result', async () => {
const service = createDefaultPlaceSearch({
endpoints: { photon: 'https://search.example/api/' },
fetchImpl: async (url) => {
assert.equal(new URL(url).origin, 'https://search.example');
return json({
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [-97, 30] },
properties: { name: 'Town' },
},
],
});
},
});
assert.equal((await service.geocode('Town')).place.lat, 30);
});
test('routing middleware uses configured OSRM servers while retaining request bounds and profile', async () => {
let handler,
calls = 0;
installRouteMiddleware(
{
use(_path, fn) {
handler = fn;
},
},
{
endpoints: { foot: 'https://routes.example/walking' },
fetchImpl: async (url, options) => {
calls++;
assert.match(
url,
/^https:\/\/routes\.example\/walking\/route\/v1\/foot\//,
);
assert.equal(options.redirect, 'error');
return json({
code: 'Ok',
routes: [{ geometry: { coordinates }, distance: 120, duration: 90 }],
});
},
},
);
const request = async (query) => {
let payload;
await handler(
{ url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } },
{
writeHead() {},
end(body) {
payload = JSON.parse(body);
},
},
);
return payload;
};
assert.equal(
(await request('profile=foot&coords=-97,30;-97.001,30.001')).distanceM,
120,
);
assert.equal((await request('profile=foot&coords=-97,30;97,-30')).ok, false);
assert.equal(calls, 1);
});
test('regional reverse geocoding uses the configured Nominatim endpoint and projection', async () => {
const lookup = createRegionalPlaceProvider({
endpoint: 'https://places.example/reverse',
requestJson: async (url) => {
assert.equal(new URL(url).origin, 'https://places.example');
return { address: { city: 'Town', country: 'Country' } };
},
});
assert.equal((await lookup(point)).locality, 'Town');
});
+132
View File
@@ -0,0 +1,132 @@
import { validCoordinate } from './geospatial.js';
function labels(values) {
return [
...new Set(
values
.map((value) =>
String(value || '')
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120),
)
.filter(Boolean),
),
];
}
/** Normalize reverse-geocoder labels without exposing provider response objects. */
export function normalizeGoogleReverse(data) {
if (data?.status !== 'OK' || !data.results?.length) return null;
const result = data.results[0];
const relevant = data.results.slice(0, 12);
const components = Array.isArray(result.address_components)
? result.address_components
: [];
const component = (type) =>
components.find((item) => item.types?.includes(type))?.long_name || null;
return {
formattedAddress: result.formatted_address || null,
locality:
component('locality') ||
component('postal_town') ||
component('administrative_area_level_2'),
region: component('administrative_area_level_1'),
country: component('country'),
types: result.types || [],
labels: labels(relevant.map((item) => item.formatted_address)).slice(0, 12),
streetLabels: labels(
relevant.flatMap((item) =>
(item.address_components || [])
.filter((entry) => entry.types?.includes('route'))
.map((entry) => entry.long_name),
),
).slice(0, 12),
};
}
/** Existing JSON protocols with application-selected endpoints and transport. */
export function createHttpGeospatialProvider({
fetchImpl = (...args) => fetch(...args),
resolveApiKey,
endpoints = {},
} = {}) {
const urls = {
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
textSearch: '/api/google/text-search',
nearby: '/api/google/nearby-places',
route: '/api/route',
...endpoints,
};
async function json(endpoint, params, { signal } = {}) {
if (!endpoint) return null;
const separator = endpoint.includes('?') ? '&' : '?';
const response = await fetchImpl(
`${endpoint}${separator}${new URLSearchParams(params)}`,
{ signal },
);
signal?.throwIfAborted();
if (!response.ok) {
await response.body?.cancel?.().catch(() => {});
throw new Error('Geospatial service unavailable');
}
const data = await response.json();
signal?.throwIfAborted();
return data;
}
const places = (data) =>
(Array.isArray(data?.places) ? data.places : [])
.filter((place) => validCoordinate([place.longitude, place.latitude]))
.slice(0, 20);
return {
attribution: {
reverseGeocode: 'Google',
textSearch: 'Google',
nearby: 'Google',
route: 'OpenStreetMap / OSRM',
},
routeProfiles: ['foot', 'car', 'bike'],
async reverseGeocode(latitude, longitude, options) {
const key = resolveApiKey?.();
if (resolveApiKey && !key) return null;
return normalizeGoogleReverse(
await json(
urls.reverse,
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
options,
),
);
},
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
return places(
await json(
urls.textSearch,
{ q: query, lat: latitude, lon: longitude, radiusM },
options,
),
);
},
async nearby({ latitude, longitude, radiusM = 250 }, options) {
return places(
await json(
urls.nearby,
{ lat: latitude, lon: longitude, radiusM },
options,
),
);
},
async route(coordinates, profile, options) {
const coords = coordinates
.map((pair) =>
pair
.slice(0, 2)
.map((n) => n.toFixed(6))
.join(','),
)
.join(';');
const data = await json(urls.route, { profile, coords }, options);
return data?.ok ? data : null;
},
};
}
+6
View File
@@ -1,3 +1,9 @@
export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js';
export { createGoogleGeocoder, normalizeGooglePlace } from './google.js';
export { createGeospatialServices, validCoordinate } from './geospatial.js';
export {
createHttpGeospatialProvider,
normalizeGoogleReverse,
} from './http.js';
export { createPhotonGeocoder } from '../keylessGeocoder.js';
export { createDefaultPlaceSearch } from './defaults.js';