diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c22e24133..4e45d2aab 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -44,5 +44,13 @@ "src/tooling/liveProviders.test.mjs", "src/data/adsbLolFallback.js", "src/data/aisStreamAdapter.js", - "src/data/aisWatchdog.js" + "src/data/aisWatchdog.js", + "server/providers/common/geo.js", + "server/providers/common/rate-limit.js", + "server/providers/places.js", + "server/providers/places/google-key.js", + "server/providers/places/google.js", + "server/providers/places/routes.js", + "src/data/placeProviderPayloads.js", + "src/tooling/placeProviders.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7177bfd0c..fefec7ba5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,24 +45,16 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -97,12 +69,8 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] } } diff --git a/server/providers/common/geo.js b/server/providers/common/geo.js index 1adf697e1..1b7a9d85b 100644 --- a/server/providers/common/geo.js +++ b/server/providers/common/geo.js @@ -8,10 +8,11 @@ * @returns {number} Distance in kilometers. */ export function haversineKm(lat1, lon1, lat2, lon2) { - const toRad = (value) => value * Math.PI / 180; + const toRad = (value) => (value * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat / 2) ** 2 - + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } diff --git a/server/providers/common/rate-limit.js b/server/providers/common/rate-limit.js index 85103f04c..3d7c0b9f8 100644 --- a/server/providers/common/rate-limit.js +++ b/server/providers/common/rate-limit.js @@ -13,7 +13,10 @@ export function makeRateLimiter({ windowMs, max, globalMax }) { globalTimes = globalTimes.filter((t) => now - t < windowMs); if (globalMax && globalTimes.length >= globalMax) return false; // global backstop const recent = (hits.get(key) || []).filter((t) => now - t < windowMs); - if (recent.length >= max) { hits.set(key, recent); return false; } + if (recent.length >= max) { + hits.set(key, recent); + return false; + } recent.push(now); hits.set(key, recent); globalTimes.push(now); @@ -46,7 +49,11 @@ export function makeRateLimiter({ windowMs, max, globalMax }) { export function makeOptInRateLimiter(envValue) { const max = Number(envValue); if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited - return makeRateLimiter({ windowMs: 60_000, max: Math.floor(max), globalMax: Math.floor(max) * 20 }); + return makeRateLimiter({ + windowMs: 60_000, + max: Math.floor(max), + globalMax: Math.floor(max) * 20, + }); } /** diff --git a/server/providers/places.js b/server/providers/places.js index 2220dfb26..648be2aa8 100644 --- a/server/providers/places.js +++ b/server/providers/places.js @@ -1,6 +1,13 @@ /** Place-search and route middleware for Node development servers. */ export { googlePlacesContextProxy } from './places/google.js'; -export { googleServerApiKey, keylessGooglePlacesResponse } from './places/google-key.js'; +export { + googleServerApiKey, + keylessGooglePlacesResponse, +} from './places/google-key.js'; export { installRouteMiddleware } from './places/routes.js'; -export { makeRateLimiter, makeOptInRateLimiter, clientKey } from './common/rate-limit.js'; +export { + makeRateLimiter, + makeOptInRateLimiter, + clientKey, +} from './common/rate-limit.js'; export { haversineKm } from './common/geo.js'; diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 62ced55e7..2fb4aea9c 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -1,6 +1,12 @@ -import { googleServerApiKey, keylessGooglePlacesResponse } from './google-key.js'; +import { + googleServerApiKey, + keylessGooglePlacesResponse, +} from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; -import { projectNearbyPlaces, projectTextSearchPlaces } from '../../../src/data/placeProviderPayloads.js'; +import { + projectNearbyPlaces, + projectTextSearchPlaces, +} from '../../../src/data/placeProviderPayloads.js'; // Construct lazily after the standalone environment has loaded. // undefined = not built yet; null = unlimited; fn = active limiter @@ -8,12 +14,17 @@ let _googleRateLimiter; /** Google cost endpoint (nearby-places). Null = unlimited (default). */ function googleRateLimiter() { - if (_googleRateLimiter === undefined) _googleRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_GOOGLE_PER_MIN); + if (_googleRateLimiter === undefined) + _googleRateLimiter = makeOptInRateLimiter( + process.env.GEV_RATELIMIT_GOOGLE_PER_MIN, + ); return _googleRateLimiter; } /** Nearby place labels and view-biased text search, with request-time key resolution. */ -export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey } = {}) { +export function googlePlacesContextProxy({ + resolveApiKey = googleServerApiKey, +} = {}) { function install(middlewares) { middlewares.use('/api/google/nearby-places', async (req, res) => { if (req.method !== 'GET') { @@ -51,56 +62,76 @@ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey } const requestUrl = new URL(req.url || '', 'http://localhost'); const latitude = Number(requestUrl.searchParams.get('lat')); const longitude = Number(requestUrl.searchParams.get('lon')); - const radiusM = Math.max(25, Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250)); + const radiusM = Math.max( + 25, + Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250), + ); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'Valid lat and lon are required', places: [] })); + res.end( + JSON.stringify({ + error: 'Valid lat and lon are required', + places: [], + }), + ); return; } try { - const response = await fetch('https://places.googleapis.com/v1/places:searchNearby', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': [ - 'places.id', - 'places.displayName', - 'places.formattedAddress', - 'places.shortFormattedAddress', - 'places.location', - 'places.primaryType', - 'places.primaryTypeDisplayName', - 'places.types', - ].join(','), - }, - body: JSON.stringify({ - maxResultCount: 20, - rankPreference: 'DISTANCE', - locationRestriction: { - circle: { - center: { latitude, longitude }, - radius: radiusM, - }, + const response = await fetch( + 'https://places.googleapis.com/v1/places:searchNearby', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.shortFormattedAddress', + 'places.location', + 'places.primaryType', + 'places.primaryTypeDisplayName', + 'places.types', + ].join(','), }, - }), - }); + body: JSON.stringify({ + maxResultCount: 20, + rankPreference: 'DISTANCE', + locationRestriction: { + circle: { + center: { latitude, longitude }, + radius: radiusM, + }, + }, + }), + }, + ); const data = await response.json().catch(() => ({})); const places = projectNearbyPlaces(data, latitude, longitude); res.statusCode = response.ok ? 200 : response.status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'private, max-age=300'); - res.end(JSON.stringify({ - places, - error: response.ok ? null : data.error?.message || 'Google Places request failed', - })); + res.end( + JSON.stringify({ + places, + error: response.ok + ? null + : data.error?.message || 'Google Places request failed', + }), + ); } catch (error) { res.statusCode = 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify({ error: error?.message || 'Google Places request failed', places: [] })); + res.end( + JSON.stringify({ + error: error?.message || 'Google Places request failed', + places: [], + }), + ); } }); @@ -145,55 +176,76 @@ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey } const textQuery = String(requestUrl.searchParams.get('q') || '').trim(); const latitude = Number(requestUrl.searchParams.get('lat')); const longitude = Number(requestUrl.searchParams.get('lon')); - const radiusM = Math.max(50, Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000)); - if (!textQuery || !Number.isFinite(latitude) || !Number.isFinite(longitude)) { + const radiusM = Math.max( + 50, + Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000), + ); + if ( + !textQuery || + !Number.isFinite(latitude) || + !Number.isFinite(longitude) + ) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'q, lat and lon are required', places: [] })); + res.end( + JSON.stringify({ error: 'q, lat and lon are required', places: [] }), + ); return; } try { - const response = await fetch('https://places.googleapis.com/v1/places:searchText', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': [ - 'places.id', - 'places.displayName', - 'places.formattedAddress', - 'places.location', - 'places.viewport', - 'places.primaryType', - 'places.types', - ].join(','), - }, - body: JSON.stringify({ - textQuery, - locationBias: { - circle: { - center: { latitude, longitude }, - radius: radiusM, - }, + const response = await fetch( + 'https://places.googleapis.com/v1/places:searchText', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.location', + 'places.viewport', + 'places.primaryType', + 'places.types', + ].join(','), }, - maxResultCount: 5, - }), - }); + body: JSON.stringify({ + textQuery, + locationBias: { + circle: { + center: { latitude, longitude }, + radius: radiusM, + }, + }, + maxResultCount: 5, + }), + }, + ); const data = await response.json().catch(() => ({})); const places = projectTextSearchPlaces(data, latitude, longitude); res.statusCode = response.ok ? 200 : response.status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'private, max-age=300'); - res.end(JSON.stringify({ - places, - error: response.ok ? null : data.error?.message || 'Google Places request failed', - })); + res.end( + JSON.stringify({ + places, + error: response.ok + ? null + : data.error?.message || 'Google Places request failed', + }), + ); } catch (error) { res.statusCode = 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify({ error: error?.message || 'Google Places request failed', places: [] })); + res.end( + JSON.stringify({ + error: error?.message || 'Google Places request failed', + places: [], + }), + ); } }); } diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 953e9c9a0..04d20a74d 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -1,7 +1,10 @@ import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; import { haversineKm } from '../common/geo.js'; import { readResponseTextCapped } from '../common/http.js'; -import { normalizeRouteProfile, projectRouteResult } from '../../../src/data/placeProviderPayloads.js'; +import { + normalizeRouteProfile, + projectRouteResult, +} from '../../../src/data/placeProviderPayloads.js'; /** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */ const ROUTE_CACHE_MS = 600000; @@ -16,89 +19,115 @@ const ROUTE_MAX_LEG_KM = 600; const ROUTE_MAX_TOTAL_KM = 2500; -const _routeRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 200 }); +const _routeRateLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 60, + globalMax: 200, +}); export function installRouteMiddleware(middlewares) { - // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike). - // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...] - middlewares.use('/api/route', async (req, res) => { - const fail = (msg) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: false, error: msg })); - }; - try { - if (!_routeRateLimiter(clientKey(req))) { - res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' }); - res.end(JSON.stringify({ ok: false, error: 'rate limited' })); - return; - } - const url = new URL(req.url, 'http://localhost'); - const raw = (url.searchParams.get('profile') || 'foot').toLowerCase(); - const profile = normalizeRouteProfile(raw); - if (!profile) return fail('invalid profile'); - const osrmProfile = profile === 'car' ? 'driving' : profile; - const pairs = (url.searchParams.get('coords') || '').split(';').map((s) => s.trim()).filter(Boolean); - if (pairs.length < 2 || pairs.length > 12) return fail('need 2-12 coordinates'); - const clean = []; - const pts = []; - for (const pr of pairs) { - const parts = pr.split(','); - if (parts.length !== 2) return fail('invalid coordinate'); - const lon = Number(parts[0]); - const lat = Number(parts[1]); - if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { - return fail('invalid coordinate'); - } - clean.push(`${lon},${lat}`); - pts.push([lon, lat]); - } - // Reject obviously-abusive spans — a real walking/driving route is local, - // so a cross-continent request is either a bug or an attempt to drive - // heavy upstream OSRM work. - let totalKm = 0; - for (let i = 1; i < pts.length; i += 1) { - // pts are [lon, lat]; existing haversineKm takes (lat1, lon1, lat2, lon2). - const legKm = haversineKm(pts[i - 1][1], pts[i - 1][0], pts[i][1], pts[i][0]); - if (legKm > ROUTE_MAX_LEG_KM) return fail('route leg too long'); - totalKm += legKm; - } - if (totalKm > ROUTE_MAX_TOTAL_KM) return fail('route too long'); - const coords = clean.join(';'); - const cacheKey = `${profile}|${coords}`; - const now = Date.now(); - const cached = _routeCache.get(cacheKey); - if (cached && now - cached.cachedAt <= ROUTE_CACHE_MS) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(cached.payload)); - return; - } - const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 12000); - let osrm; - try { - const upstreamRes = await fetch(upstream, { - signal: controller.signal, - headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, - }); - if (!upstreamRes.ok) return fail('no route found'); - const ctype = upstreamRes.headers.get('content-type') || ''; - if (!ctype.includes('json')) return fail('no route found'); - const text = await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES); - osrm = JSON.parse(text); - } finally { - clearTimeout(timer); - } - const route = osrm?.routes?.[0]; - if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) return fail('no route found'); - const payload = projectRouteResult(route, profile); - _routeCache.set(cacheKey, { payload, cachedAt: now }); - if (_routeCache.size > 200) _routeCache.delete(_routeCache.keys().next().value); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); - } catch (e) { - console.error('[Route Proxy]', e?.message || e); - fail('route proxy error'); + // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike). + // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...] + middlewares.use('/api/route', async (req, res) => { + const fail = (msg) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: msg })); + }; + try { + if (!_routeRateLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '5', + }); + res.end(JSON.stringify({ ok: false, error: 'rate limited' })); + return; + } + const url = new URL(req.url, 'http://localhost'); + const raw = (url.searchParams.get('profile') || 'foot').toLowerCase(); + const profile = normalizeRouteProfile(raw); + if (!profile) return fail('invalid profile'); + const osrmProfile = profile === 'car' ? 'driving' : profile; + const pairs = (url.searchParams.get('coords') || '') + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + if (pairs.length < 2 || pairs.length > 12) + return fail('need 2-12 coordinates'); + const clean = []; + const pts = []; + for (const pr of pairs) { + const parts = pr.split(','); + if (parts.length !== 2) return fail('invalid coordinate'); + const lon = Number(parts[0]); + const lat = Number(parts[1]); + if ( + !Number.isFinite(lon) || + !Number.isFinite(lat) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) { + return fail('invalid coordinate'); } - }); + clean.push(`${lon},${lat}`); + pts.push([lon, lat]); + } + // Reject obviously-abusive spans — a real walking/driving route is local, + // so a cross-continent request is either a bug or an attempt to drive + // heavy upstream OSRM work. + let totalKm = 0; + for (let i = 1; i < pts.length; i += 1) { + // pts are [lon, lat]; existing haversineKm takes (lat1, lon1, lat2, lon2). + const legKm = haversineKm( + pts[i - 1][1], + pts[i - 1][0], + pts[i][1], + pts[i][0], + ); + if (legKm > ROUTE_MAX_LEG_KM) return fail('route leg too long'); + totalKm += legKm; + } + if (totalKm > ROUTE_MAX_TOTAL_KM) return fail('route too long'); + const coords = clean.join(';'); + const cacheKey = `${profile}|${coords}`; + const now = Date.now(); + const cached = _routeCache.get(cacheKey); + if (cached && now - cached.cachedAt <= ROUTE_CACHE_MS) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(cached.payload)); + return; + } + const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + let osrm; + try { + const upstreamRes = await fetch(upstream, { + signal: controller.signal, + headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, + }); + if (!upstreamRes.ok) return fail('no route found'); + const ctype = upstreamRes.headers.get('content-type') || ''; + if (!ctype.includes('json')) return fail('no route found'); + const text = await readResponseTextCapped( + upstreamRes, + ROUTE_MAX_RESPONSE_BYTES, + ); + osrm = JSON.parse(text); + } finally { + clearTimeout(timer); + } + const route = osrm?.routes?.[0]; + if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) + return fail('no route found'); + const payload = projectRouteResult(route, profile); + _routeCache.set(cacheKey, { payload, cachedAt: now }); + if (_routeCache.size > 200) + _routeCache.delete(_routeCache.keys().next().value); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + } catch (e) { + console.error('[Route Proxy]', e?.message || e); + fail('route proxy error'); + } + }); } diff --git a/src/data/placeProviderPayloads.js b/src/data/placeProviderPayloads.js index 03d9d1923..37fd99ef7 100644 --- a/src/data/placeProviderPayloads.js +++ b/src/data/placeProviderPayloads.js @@ -9,92 +9,127 @@ function placeContextPriority(types) { } function approximateDistanceM(latA, lonA, latB, lonB) { - if (![latA, lonA, latB, lonB].every(Number.isFinite)) return Number.MAX_SAFE_INTEGER; + if (![latA, lonA, latB, lonB].every(Number.isFinite)) + return Number.MAX_SAFE_INTEGER; const latitudeScale = 111320; const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); - return Math.round(Math.hypot( - (latB - latA) * latitudeScale, - (lonB - lonA) * longitudeScale - )); + return Math.round( + Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale), + ); } export function projectNearbyPlaces(data, latitude, longitude) { const seenPlaces = new Set(); - const places = Array.isArray(data.places) ? data.places - .map((place) => { - const placeLatitude = place.location?.latitude ?? null; - const placeLongitude = place.location?.longitude ?? null; - const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; - return { - id: place.id || null, - name: place.displayName?.text || null, - address: place.shortFormattedAddress || place.formattedAddress || null, - latitude: placeLatitude, - longitude: placeLongitude, - distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), - primaryType: place.primaryTypeDisplayName?.text || place.primaryType || null, - types, - contextPriority: placeContextPriority(types), - }; - }) - .filter((place) => { - const key = `${place.name}:${place.address || ''}`.toLowerCase(); - if (!place.name || seenPlaces.has(key)) return false; - seenPlaces.add(key); - return true; - }) - .sort((a, b) => b.contextPriority - a.contextPriority || a.distanceM - b.distanceM) - .map(({ contextPriority, ...place }) => place) - .slice(0, 20) : []; + const places = Array.isArray(data.places) + ? data.places + .map((place) => { + const placeLatitude = place.location?.latitude ?? null; + const placeLongitude = place.location?.longitude ?? null; + const types = Array.isArray(place.types) + ? place.types.slice(0, 8) + : []; + return { + id: place.id || null, + name: place.displayName?.text || null, + address: + place.shortFormattedAddress || place.formattedAddress || null, + latitude: placeLatitude, + longitude: placeLongitude, + distanceM: approximateDistanceM( + latitude, + longitude, + placeLatitude, + placeLongitude, + ), + primaryType: + place.primaryTypeDisplayName?.text || place.primaryType || null, + types, + contextPriority: placeContextPriority(types), + }; + }) + .filter((place) => { + const key = `${place.name}:${place.address || ''}`.toLowerCase(); + if (!place.name || seenPlaces.has(key)) return false; + seenPlaces.add(key); + return true; + }) + .sort( + (a, b) => + b.contextPriority - a.contextPriority || a.distanceM - b.distanceM, + ) + .map(({ contextPriority, ...place }) => place) + .slice(0, 20) + : []; return places; } export function projectTextSearchPlaces(data, latitude, longitude) { - const places = Array.isArray(data.places) ? data.places - .map((place) => { - const placeLatitude = place.location?.latitude ?? null; - const placeLongitude = place.location?.longitude ?? null; - const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; - // Places returns a lat/lng bounding box (low/high corners) framing the - // place — no polygon, but enough to SIZE a fallback grounds disc to the - // real feature instead of a blind constant. Normalize to plain numbers. - const vp = place.viewport; - const viewport = ( - Number.isFinite(vp?.low?.latitude) && Number.isFinite(vp?.low?.longitude) - && Number.isFinite(vp?.high?.latitude) && Number.isFinite(vp?.high?.longitude) - ) ? { - low: { latitude: vp.low.latitude, longitude: vp.low.longitude }, - high: { latitude: vp.high.latitude, longitude: vp.high.longitude }, - } : null; - return { - id: place.id || null, - name: place.displayName?.text || null, - address: place.formattedAddress || null, - latitude: placeLatitude, - longitude: placeLongitude, - distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), - primaryType: place.primaryType || null, - types, - viewport, - }; - }) - .filter((place) => place.name) : []; + const places = Array.isArray(data.places) + ? data.places + .map((place) => { + const placeLatitude = place.location?.latitude ?? null; + const placeLongitude = place.location?.longitude ?? null; + const types = Array.isArray(place.types) + ? place.types.slice(0, 8) + : []; + // Places returns a lat/lng bounding box (low/high corners) framing the + // place — no polygon, but enough to SIZE a fallback grounds disc to the + // real feature instead of a blind constant. Normalize to plain numbers. + const vp = place.viewport; + const viewport = + Number.isFinite(vp?.low?.latitude) && + Number.isFinite(vp?.low?.longitude) && + Number.isFinite(vp?.high?.latitude) && + Number.isFinite(vp?.high?.longitude) + ? { + low: { + latitude: vp.low.latitude, + longitude: vp.low.longitude, + }, + high: { + latitude: vp.high.latitude, + longitude: vp.high.longitude, + }, + } + : null; + return { + id: place.id || null, + name: place.displayName?.text || null, + address: place.formattedAddress || null, + latitude: placeLatitude, + longitude: placeLongitude, + distanceM: approximateDistanceM( + latitude, + longitude, + placeLatitude, + placeLongitude, + ), + primaryType: place.primaryType || null, + types, + viewport, + }; + }) + .filter((place) => place.name) + : []; return places; } export function normalizeRouteProfile(raw) { - return (raw === 'car' || raw === 'driving') ? 'car' - : (raw === 'bike' || raw === 'cycling' || raw === 'bicycle') ? 'bike' - : (raw === 'foot' || raw === 'walking' || raw === 'walk') ? 'foot' - : null; + return raw === 'car' || raw === 'driving' + ? 'car' + : raw === 'bike' || raw === 'cycling' || raw === 'bicycle' + ? 'bike' + : raw === 'foot' || raw === 'walking' || raw === 'walk' + ? 'foot' + : null; } export function projectRouteResult(route, profile) { - return { - ok: true, - profile, - distanceM: Math.round(route.distance), - durationS: Math.round(route.duration), - geometry: route.geometry.coordinates, - }; + return { + ok: true, + profile, + distanceM: Math.round(route.distance), + durationS: Math.round(route.duration), + geometry: route.geometry.coordinates, + }; } diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index a0384d2b0..1580c0fd1 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -1,32 +1,97 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { googlePlacesContextProxy, installRouteMiddleware } from '../../server/providers/places.js'; -import { projectNearbyPlaces, projectTextSearchPlaces } from '../../src/data/placeProviderPayloads.js'; +import { + googlePlacesContextProxy, + installRouteMiddleware, +} from '../../server/providers/places.js'; +import { + projectNearbyPlaces, + projectTextSearchPlaces, +} from '../../src/data/placeProviderPayloads.js'; function install(register) { const routes = new Map(); - register({ use(route, handler) { routes.set(route, handler); } }); + register({ + use(route, handler) { + routes.set(route, handler); + }, + }); return async (route, url, method = 'GET', peer = 'fixture') => { - const res = { statusCode: 200, headers: {}, setHeader(name, value) { this.headers[name.toLowerCase()] = value; }, writeHead(status, headers) { this.statusCode = status; for (const [key, value] of Object.entries(headers)) this.setHeader(key, value); }, end(body) { this.body = JSON.parse(body); } }; - await routes.get(route)({ url, method, socket: { remoteAddress: peer } }, res); + const res = { + statusCode: 200, + headers: {}, + setHeader(name, value) { + this.headers[name.toLowerCase()] = value; + }, + writeHead(status, headers) { + this.statusCode = status; + for (const [key, value] of Object.entries(headers)) + this.setHeader(key, value); + }, + end(body) { + this.body = JSON.parse(body); + }, + }; + await routes.get(route)( + { url, method, socket: { remoteAddress: peer } }, + res, + ); return res; }; } test('nearby labels rank landmarks, deduplicate names/addresses and bound the projection', () => { - const place = (name, types, longitude = 0) => ({ displayName: { text: name }, types, location: { latitude: 0, longitude }, formattedAddress: 'Street' }); - const result = projectNearbyPlaces({ places: [place('Bathroom', ['public_bathroom']), place('Monument', ['monument'], 0.1), place('MONUMENT', ['monument']), ...Array.from({ length: 25 }, (_, i) => place(`POI ${i}`, ['point_of_interest'], i / 100)), { id: 'unnamed' }] }, 0, 0); + const place = (name, types, longitude = 0) => ({ + displayName: { text: name }, + types, + location: { latitude: 0, longitude }, + formattedAddress: 'Street', + }); + const result = projectNearbyPlaces( + { + places: [ + place('Bathroom', ['public_bathroom']), + place('Monument', ['monument'], 0.1), + place('MONUMENT', ['monument']), + ...Array.from({ length: 25 }, (_, i) => + place(`POI ${i}`, ['point_of_interest'], i / 100), + ), + { id: 'unnamed' }, + ], + }, + 0, + 0, + ); assert.equal(result.length, 20); assert.equal(result[0].name, 'Monument'); assert.equal(result[0].distanceM, 11132); - assert.equal(result.filter(p => p.name.toLowerCase() === 'monument').length, 1); - assert.ok(result.every(p => !('contextPriority' in p))); + assert.equal( + result.filter((p) => p.name.toLowerCase() === 'monument').length, + 1, + ); + assert.ok(result.every((p) => !('contextPriority' in p))); assert.deepEqual(projectNearbyPlaces({}, 0, 0), []); }); test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => { - const viewport = { low: { latitude: 1, longitude: 2 }, high: { latitude: 3, longitude: 4 } }; - const result = projectTextSearchPlaces({ places: [{ displayName: { text: 'Museum' }, viewport, types: Array(12).fill('museum') }, { displayName: { text: 'Park' }, viewport: { low: viewport.low } }] }, 0, 0); + const viewport = { + low: { latitude: 1, longitude: 2 }, + high: { latitude: 3, longitude: 4 }, + }; + const result = projectTextSearchPlaces( + { + places: [ + { + displayName: { text: 'Museum' }, + viewport, + types: Array(12).fill('museum'), + }, + { displayName: { text: 'Park' }, viewport: { low: viewport.low } }, + ], + }, + 0, + 0, + ); assert.deepEqual(result[0].viewport, viewport); assert.equal(result[0].distanceM, Number.MAX_SAFE_INTEGER); assert.equal(result[0].latitude, null); @@ -35,58 +100,129 @@ test('text search preserves bounds, rejects malformed bounds and tolerates absen }); for (const preview of [false, true]) { - test(`Google middleware resolves credentials per request and preserves search responses (${preview ? 'preview' : 'dev'})`, async t => { + test(`Google middleware resolves credentials per request and preserves search responses (${preview ? 'preview' : 'dev'})`, async (t) => { let key = ''; const plugin = googlePlacesContextProxy({ resolveApiKey: () => key }); - const request = install(middlewares => plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares })); + const request = install((middlewares) => + plugin[preview ? 'configurePreviewServer' : 'configureServer']({ + middlewares, + }), + ); const calls = []; t.mock.method(globalThis, 'fetch', async (url, options) => { calls.push({ url, options }); assert.equal(options.headers['X-Goog-Api-Key'], key); - return Response.json({ places: [{ displayName: { text: 'Museum' }, location: { latitude: 30, longitude: -97 } }] }); + return Response.json({ + places: [ + { + displayName: { text: 'Museum' }, + location: { latitude: 30, longitude: -97 }, + }, + ], + }); }); const nearby = '/api/google/nearby-places'; const search = '/api/google/text-search'; - assert.equal((await request(nearby, '?lat=30&lon=-97')).body.configured, false); + assert.equal( + (await request(nearby, '?lat=30&lon=-97')).body.configured, + false, + ); assert.equal(calls.length, 0); key = 'fixture-server-key'; const result = await request(nearby, '?lat=30&lon=-97&radiusM=9000'); assert.equal(result.statusCode, 200); assert.equal(result.body.places[0].name, 'Museum'); - assert.equal(JSON.parse(calls[0].options.body).locationRestriction.circle.radius, 5000); + assert.equal( + JSON.parse(calls[0].options.body).locationRestriction.circle.radius, + 5000, + ); assert.equal(result.headers['cache-control'], 'private, max-age=300'); key = 'rotated-fixture-key'; - assert.equal((await request(search, '?q=museum&lat=30&lon=-97&radiusM=1')).statusCode, 200); - assert.equal(JSON.parse(calls[1].options.body).locationBias.circle.radius, 50); + assert.equal( + (await request(search, '?q=museum&lat=30&lon=-97&radiusM=1')).statusCode, + 200, + ); + assert.equal( + JSON.parse(calls[1].options.body).locationBias.circle.radius, + 50, + ); assert.equal((await request(search, '?lat=30&lon=-97')).statusCode, 400); assert.equal((await request(nearby, '?lat=bad&lon=-97')).statusCode, 400); assert.equal((await request(nearby, '', 'POST')).statusCode, 405); - t.mock.method(globalThis, 'fetch', async () => Response.json({ error: { message: 'Denied' } }, { status: 403 })); - assert.deepEqual((await request(search, '?q=museum&lat=30&lon=-97')).body, { places: [], error: 'Denied' }); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ error: { message: 'Denied' } }, { status: 403 }), + ); + assert.deepEqual((await request(search, '?q=museum&lat=30&lon=-97')).body, { + places: [], + error: 'Denied', + }); }); } -test('OSRM routing preserves aliases, cache, span guards and upstream failure behavior', async t => { +test('OSRM routing preserves aliases, cache, span guards and upstream failure behavior', async (t) => { const request = install(installRouteMiddleware); let calls = 0; let now = Date.now(); t.mock.method(Date, 'now', () => now); - t.mock.method(globalThis, 'fetch', async url => { + t.mock.method(globalThis, 'fetch', async (url) => { calls++; assert.match(url, /routed-car\/route\/v1\/driving\//); - return Response.json({ code: 'Ok', routes: [{ distance: 123.6, duration: 80.2, geometry: { coordinates: [[-97, 30], [-97.01, 30.01]] } }] }); + return Response.json({ + code: 'Ok', + routes: [ + { + distance: 123.6, + duration: 80.2, + geometry: { + coordinates: [ + [-97, 30], + [-97.01, 30.01], + ], + }, + }, + ], + }); }); const route = '/api/route'; const query = '?profile=driving&coords=-97,30;-97.01,30.01'; const result = await request(route, query); - assert.deepEqual(result.body, { ok: true, profile: 'car', distanceM: 124, durationS: 80, geometry: [[-97, 30], [-97.01, 30.01]] }); - assert.deepEqual((await request(route, query.replace('driving', 'car'))).body, result.body); + assert.deepEqual(result.body, { + ok: true, + profile: 'car', + distanceM: 124, + durationS: 80, + geometry: [ + [-97, 30], + [-97.01, 30.01], + ], + }); + assert.deepEqual( + (await request(route, query.replace('driving', 'car'))).body, + result.body, + ); assert.equal(calls, 1); - for (const [url, error] of [['?profile=plane&coords=0,0;1,1', 'invalid profile'], ['?coords=0,91;1,1', 'invalid coordinate'], ['?coords=0,0;100,0', 'route leg too long'], ['?coords=0,0', 'need 2-12 coordinates']]) assert.equal((await request(route, url)).body.error, error); + for (const [url, error] of [ + ['?profile=plane&coords=0,0;1,1', 'invalid profile'], + ['?coords=0,91;1,1', 'invalid coordinate'], + ['?coords=0,0;100,0', 'route leg too long'], + ['?coords=0,0', 'need 2-12 coordinates'], + ]) + assert.equal((await request(route, url)).body.error, error); assert.equal(calls, 1); now += 600001; - t.mock.method(globalThis, 'fetch', async () => new Response('offline', { status: 503 })); - assert.deepEqual((await request(route, query)).body, { ok: false, error: 'no route found' }); - t.mock.method(globalThis, 'fetch', async () => new Response('x', { headers: { 'content-type': 'text/html' } })); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('offline', { status: 503 }), + ); + assert.deepEqual((await request(route, query)).body, { + ok: false, + error: 'no route found', + }); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('x', { headers: { 'content-type': 'text/html' } }), + ); assert.equal((await request(route, query)).body.error, 'no route found'); });