deckhearth/lib/pricing/nominatim.js

70 lines
1.9 KiB
JavaScript
Raw Normal View History

const DEFAULT_NOMINATIM_BASE = 'https://nominatim.openstreetmap.org';
const MIN_REQUEST_INTERVAL_MS = 1000;
const ALLOWED_HOSTS = new Set(['nominatim.openstreetmap.org']);
let lastRequestAt = 0;
function assertAllowedHost(url) {
const host = new URL(url).hostname.toLowerCase();
if (!ALLOWED_HOSTS.has(host)) {
throw new Error(`[nominatim] Host not allowlisted: ${host}`);
}
}
function getUserAgent() {
return process.env.NOMINATIM_USER_AGENT ?? 'DeckHearth/1.0';
}
async function throttle() {
const now = Date.now();
const elapsed = now - lastRequestAt;
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
await new Promise((resolve) => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed));
}
lastRequestAt = Date.now();
}
/**
* @param {string} zip Five-digit US zip code.
* @param {{ fetchImpl?: typeof fetch }} [options]
* @returns {Promise<{ lat: number, lng: number, displayName: string } | null>}
*/
export async function geocodeUsZip(zip, { fetchImpl = fetch } = {}) {
const base = process.env.NOMINATIM_BASE_URL ?? DEFAULT_NOMINATIM_BASE;
const url = new URL('/search', base);
url.searchParams.set('postalcode', zip);
url.searchParams.set('country', 'US');
url.searchParams.set('format', 'json');
url.searchParams.set('limit', '1');
assertAllowedHost(url.toString());
await throttle();
const response = await fetchImpl(url.toString(), {
headers: { 'User-Agent': getUserAgent() },
});
if (!response.ok) {
throw new Error(`[nominatim] HTTP ${response.status}`);
}
const results = await response.json();
if (!Array.isArray(results) || results.length === 0) {
return null;
}
const hit = results[0];
const lat = Number(hit.lat);
const lng = Number(hit.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return null;
}
return {
lat,
lng,
displayName: hit.display_name ?? `${zip}, US`,
};
}