feat(search): compose Google and Photon through an explicit provider service

This commit is contained in:
Sameh Khamis committed 2026-09-12 15:38:43 -07:00
commit 57d7f9fc23
30 files changed
+1621 -151

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
/** Normalize a Google forward-geocode result without leaking its address shape. */
export function normalizeGooglePlace(result) {
const lat = result?.geometry?.location?.lat;
const lng = result?.geometry?.location?.lng;
if (
!Number.isFinite(lat) ||
!Number.isFinite(lng) ||
Math.abs(lat) > 90 ||
Math.abs(lng) > 180
)
return null;
const types = Array.isArray(result.types) ? result.types : [];
const resultTypes = new Set(types.map((type) => String(type).toLowerCase()));
const components = Array.isArray(result.address_components)
? result.address_components
: [];
// Address-only landmark results must retain the requested landmark identity.
const canonical = components.find(
(component) =>
Array.isArray(component.types) &&
component.types.some(
(type) =>
type !== 'political' && resultTypes.has(String(type).toLowerCase()),
),
);
return {
lat,
lng,
name: canonical?.long_name || '',
label: result.formatted_address || '',
types,
viewport: result.geometry.bounds || result.geometry.viewport || null,
};
}
/** Construct Google geocoding with caller-owned transport and key selection. */
export function createGoogleGeocoder({ request }) {
return {
async geocode(query, { bias = null, signal } = {}) {
signal?.throwIfAborted();
try {
const response = await request(query, { bias, signal });
signal?.throwIfAborted();
// An unconfigured provider did not contribute a negative verdict.
if (!response) return { place: null, answered: true };
if (response.ok === false) return { place: null, answered: false };
const data = await response.json();
signal?.throwIfAborted();
if (
data?.status === 'ZERO_RESULTS' &&
Array.isArray(data.results) &&
data.results.length === 0
)
return { place: null, answered: true };
const place =
data?.status === 'OK'
? normalizeGooglePlace(data.results?.[0])
: null;
return { place, answered: Boolean(place) };
} catch {
signal?.throwIfAborted();
return { place: null, answered: false };
}
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js';
export { createGoogleGeocoder, normalizeGooglePlace } from './google.js';
export { createPhotonGeocoder } from '../keylessGeocoder.js';
+65
View File
@@ -0,0 +1,65 @@
/** A missing geocoder returns an unavailable outcome without making requests. */
export const unavailablePlaceSearch = Object.freeze({
async geocode(_query, { signal } = {}) {
signal?.throwIfAborted();
return { place: null, answered: false };
},
});
/**
* Compose ordered geocoders into one bounded service. Each returns
* { place: { lat, lng, name, label, types, viewport } | null, answered: boolean }.
* Only definitive results are cached. Caller cancellation stops the whole chain.
*/
export function createPlaceSearch({
providers,
signal: lifetime,
timeoutMs = 12_000,
now = Date.now,
}) {
const cache = new Map();
return {
async geocode(query, { bias = null, signal } = {}) {
const combined = AbortSignal.any(
[lifetime, signal, AbortSignal.timeout(timeoutMs)].filter(Boolean),
);
combined.throwIfAborted();
const text = String(query ?? '').trim();
if (!text) return { place: null, answered: true };
const key = `${bias || ''}\n${text.toLowerCase()}`;
const cached = cache.get(key);
if (cached && cached.expires > now()) return cached.result;
cache.delete(key);
let result = { place: null, answered: providers.length > 0 };
try {
for (const [index, provider] of providers.entries()) {
combined.throwIfAborted();
const next = await provider.geocode(text, { bias, signal: combined });
combined.throwIfAborted();
result = {
place: next.place,
answered: Boolean(next.place) || (result.answered && next.answered),
};
if (result.place) {
result.fallbackUsed = index > 0;
break;
}
}
} catch (error) {
lifetime?.throwIfAborted();
signal?.throwIfAborted();
if (!combined.aborted) throw error;
return { place: null, answered: false };
}
combined.throwIfAborted();
if (result.answered) {
cache.set(key, {
result,
expires: now() + (result.place ? 300_000 : 30_000),
});
while (cache.size > 64) cache.delete(cache.keys().next().value);
}
return result;
},
};
}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPlaceSearch, createGoogleGeocoder, createPhotonGeocoder } from './index.js';
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
import { photonExtentToBounds } from '../keylessGeocoder.js';
const feature = { geometry: { type: 'Point', coordinates: [105.85, 21.03] }, properties: { name: 'Hà Nội', type: 'city' } };
const hit = () => Response.json({ features: [feature] });
test('malformed successful Photon responses remain retryable', async () => {
for (const malformed of [{ error: 'temporary failure' }, { features: {} }, { features: [{ geometry: { coordinates: [null, 1] } }] }]) {
let calls = 0;
const provider = createPhotonGeocoder({ fetchImpl: async () => ++calls === 1 ? Response.json(malformed) : hit() });
assert.deepEqual(await provider.geocode('Hanoi'), { place: null, answered: false });
assert.equal((await provider.geocode('Hanoi')).place.name, 'Hà Nội');
assert.equal(calls, 2);
}
});
test('standalone geocoding falls back after Google connection, JSON and refusal failures', async () => {
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
const urls = [];
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
} });
const result = await service.geocode('Hanoi');
assert.equal(result.place.name, 'Hà Nội');
assert.equal(result.fallbackUsed, true);
assert.equal(urls.length, 2);
}
});
test('a keyless service does not issue any Google request', async () => {
const service = createStandalonePlaceSearch({ fetchImpl: async (url) => {
assert.equal(new URL(url).hostname, 'photon.komoot.io'); return hit();
} });
assert.equal((await service.geocode('Hanoi')).place.lat, 21.03);
});
test('cancellation before lookup makes no request', async () => {
const controller = new AbortController(); controller.abort();
let calls = 0;
const provider = createPhotonGeocoder({ fetchImpl: async () => { calls++; return hit(); } });
await assert.rejects(provider.geocode('Hanoi', { signal: controller.signal }), { name: 'AbortError' });
assert.equal(calls, 0);
});
test('cancellation stops Photon retries and does not cache a late response', async () => {
const controller = new AbortController();
let calls = 0;
const provider = createPhotonGeocoder({ fetchImpl: async () => {
calls++; if (calls === 1) controller.abort(); return hit();
} });
await assert.rejects(provider.geocode('Hanoi', { bias: '1,1|2,2', signal: controller.signal }), { name: 'AbortError' });
assert.equal(calls, 1);
assert.equal((await provider.geocode('Hanoi')).place.name, 'Hà Nội');
assert.equal(calls, 2);
});
test('a Google request cancelled by its caller never starts the fallback', async () => {
const controller = new AbortController(); let fallbackCalls = 0;
const service = createPlaceSearch({ providers: [
createGoogleGeocoder({ request: async () => { controller.abort(); return Response.json({ status: 'ZERO_RESULTS', results: [] }); } }),
{ geocode: async () => { fallbackCalls++; return { place: null, answered: true }; } },
] });
await assert.rejects(service.geocode('missing', { signal: controller.signal }), { name: 'AbortError' });
assert.equal(fallbackCalls, 0);
});
test('service lifetime cancellation also rejects cache hits', async () => {
const controller = new AbortController();
const service = createPlaceSearch({ signal: controller.signal, providers: [createPhotonGeocoder({ fetchImpl: async () => hit() })] });
assert.ok((await service.geocode('Hanoi')).place);
controller.abort();
await assert.rejects(service.geocode('Hanoi'), { name: 'AbortError' });
});
test('one cancelled caller cannot cancel another caller of the same service', async () => {
const controller = new AbortController();
const service = createPlaceSearch({ providers: [{ async geocode(_query, { signal }) {
await new Promise((resolve) => setImmediate(resolve)); signal.throwIfAborted();
return { place: { lat: 1, lng: 2 }, answered: true };
} }] });
const first = service.geocode('same', { signal: controller.signal });
const second = service.geocode('same');
controller.abort();
await assert.rejects(first, { name: 'AbortError' });
assert.equal((await second).place.lat, 1);
});
test('definitive misses expire while outage outcomes are never cached', async () => {
let now = 0, calls = 0, answered = true;
const service = createPlaceSearch({ now: () => now, providers: [{ async geocode() { calls++; return { place: null, answered }; } }] });
await service.geocode('miss'); await service.geocode('miss'); assert.equal(calls, 1);
now = 30_001; answered = false;
await service.geocode('miss'); await service.geocode('miss'); assert.equal(calls, 3);
});
test('wrapped and nonnumeric Photon bounds are omitted', () => {
assert.equal(photonExtentToBounds([170, 10, -170, -10]), null);
assert.equal(photonExtentToBounds([null, 10, 20, -10]), null);
});