style: format extracted place and route modules

This commit is contained in:
Sameh Khamis committed 2026-09-11 19:57:22 -07:00
1 parent 2793d7a8df
commit 7e6d89d8f5
9 files changed
+385 -142

No files matched your search

+9 -1
View File
@@ -44,5 +44,13 @@
"src/tooling/liveProviders.test.mjs", "src/tooling/liveProviders.test.mjs",
"src/data/adsbLolFallback.js", "src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.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"
] ]
+16 -48
View File
@@ -10,47 +10,27 @@
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js" "src/data/localGeojsonLod.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"application": { "application": {
"exports": [ "exports": ["./application"],
"./application" "modules": ["src/app/application.js"],
],
"modules": [
"src/app/application.js"
],
"external": [] "external": []
}, },
"viewer": { "viewer": {
"exports": [ "exports": ["./application/viewer"],
"./application/viewer" "modules": ["src/app/viewer.js"],
], "external": ["cesium"]
"modules": [
"src/app/viewer.js"
],
"external": [
"cesium"
]
}, },
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./build/vite"],
"./build/vite" "modules": ["build/vite.js"],
], "external": ["vite-plugin-cesium"]
"modules": [
"build/vite.js"
],
"external": [
"vite-plugin-cesium"
]
}, },
"live-providers": { "live-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/live"],
"./server/providers/live"
],
"modules": [ "modules": [
"server/providers/live.js", "server/providers/live.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -65,24 +45,16 @@
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js" "src/data/aisWatchdog.js"
], ],
"external": [ "external": ["ws"]
"ws"
]
}, },
"adsb-lol-source": { "adsb-lol-source": {
"exports": [ "exports": ["./sources/adsb-lol"],
"./sources/adsb-lol" "modules": ["src/data/adsbLolFallback.js"],
],
"modules": [
"src/data/adsbLolFallback.js"
],
"external": [] "external": []
}, },
"place-providers": { "place-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/places"],
"./server/providers/places"
],
"modules": [ "modules": [
"server/providers/places.js", "server/providers/places.js",
"server/providers/places/google.js", "server/providers/places/google.js",
@@ -97,12 +69,8 @@
"external": [] "external": []
}, },
"place-payloads": { "place-payloads": {
"exports": [ "exports": ["./sources/places"],
"./sources/places" "modules": ["src/data/placeProviderPayloads.js"],
],
"modules": [
"src/data/placeProviderPayloads.js"
],
"external": [] "external": []
} }
} }
+4 -3
View File
@@ -8,10 +8,11 @@
* @returns {number} Distance in kilometers. * @returns {number} Distance in kilometers.
*/ */
export function haversineKm(lat1, lon1, lat2, lon2) { 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 dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1); const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 const a =
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; 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)); return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
+9 -2
View File
@@ -13,7 +13,10 @@ export function makeRateLimiter({ windowMs, max, globalMax }) {
globalTimes = globalTimes.filter((t) => now - t < windowMs); globalTimes = globalTimes.filter((t) => now - t < windowMs);
if (globalMax && globalTimes.length >= globalMax) return false; // global backstop if (globalMax && globalTimes.length >= globalMax) return false; // global backstop
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs); 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); recent.push(now);
hits.set(key, recent); hits.set(key, recent);
globalTimes.push(now); globalTimes.push(now);
@@ -46,7 +49,11 @@ export function makeRateLimiter({ windowMs, max, globalMax }) {
export function makeOptInRateLimiter(envValue) { export function makeOptInRateLimiter(envValue) {
const max = Number(envValue); const max = Number(envValue);
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited 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,
});
} }
/** /**
+9 -2
View File
@@ -1,6 +1,13 @@
/** Place-search and route middleware for Node development servers. */ /** Place-search and route middleware for Node development servers. */
export { googlePlacesContextProxy } from './places/google.js'; 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 { 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'; export { haversineKm } from './common/geo.js';
+73 -21
View File
@@ -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 { 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. // Construct lazily after the standalone environment has loaded.
// undefined = not built yet; null = unlimited; fn = active limiter // undefined = not built yet; null = unlimited; fn = active limiter
@@ -8,12 +14,17 @@ let _googleRateLimiter;
/** Google cost endpoint (nearby-places). Null = unlimited (default). */ /** Google cost endpoint (nearby-places). Null = unlimited (default). */
function googleRateLimiter() { 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; return _googleRateLimiter;
} }
/** Nearby place labels and view-biased text search, with request-time key resolution. */ /** 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) { function install(middlewares) {
middlewares.use('/api/google/nearby-places', async (req, res) => { middlewares.use('/api/google/nearby-places', async (req, res) => {
if (req.method !== 'GET') { if (req.method !== 'GET') {
@@ -51,16 +62,26 @@ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey }
const requestUrl = new URL(req.url || '', 'http://localhost'); const requestUrl = new URL(req.url || '', 'http://localhost');
const latitude = Number(requestUrl.searchParams.get('lat')); const latitude = Number(requestUrl.searchParams.get('lat'));
const longitude = Number(requestUrl.searchParams.get('lon')); 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)) { if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
res.statusCode = 400; res.statusCode = 400;
res.setHeader('Content-Type', 'application/json'); 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; return;
} }
try { try {
const response = await fetch('https://places.googleapis.com/v1/places:searchNearby', { const response = await fetch(
'https://places.googleapis.com/v1/places:searchNearby',
{
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -86,21 +107,31 @@ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey }
}, },
}, },
}), }),
}); },
);
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
const places = projectNearbyPlaces(data, latitude, longitude); const places = projectNearbyPlaces(data, latitude, longitude);
res.statusCode = response.ok ? 200 : response.status; res.statusCode = response.ok ? 200 : response.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'private, max-age=300'); res.setHeader('Cache-Control', 'private, max-age=300');
res.end(JSON.stringify({ res.end(
JSON.stringify({
places, places,
error: response.ok ? null : data.error?.message || 'Google Places request failed', error: response.ok
})); ? null
: data.error?.message || 'Google Places request failed',
}),
);
} catch (error) { } catch (error) {
res.statusCode = 502; res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); 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,16 +176,27 @@ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey }
const textQuery = String(requestUrl.searchParams.get('q') || '').trim(); const textQuery = String(requestUrl.searchParams.get('q') || '').trim();
const latitude = Number(requestUrl.searchParams.get('lat')); const latitude = Number(requestUrl.searchParams.get('lat'));
const longitude = Number(requestUrl.searchParams.get('lon')); const longitude = Number(requestUrl.searchParams.get('lon'));
const radiusM = Math.max(50, Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000)); const radiusM = Math.max(
if (!textQuery || !Number.isFinite(latitude) || !Number.isFinite(longitude)) { 50,
Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000),
);
if (
!textQuery ||
!Number.isFinite(latitude) ||
!Number.isFinite(longitude)
) {
res.statusCode = 400; res.statusCode = 400;
res.setHeader('Content-Type', 'application/json'); 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; return;
} }
try { try {
const response = await fetch('https://places.googleapis.com/v1/places:searchText', { const response = await fetch(
'https://places.googleapis.com/v1/places:searchText',
{
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -179,21 +221,31 @@ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey }
}, },
maxResultCount: 5, maxResultCount: 5,
}), }),
}); },
);
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
const places = projectTextSearchPlaces(data, latitude, longitude); const places = projectTextSearchPlaces(data, latitude, longitude);
res.statusCode = response.ok ? 200 : response.status; res.statusCode = response.ok ? 200 : response.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'private, max-age=300'); res.setHeader('Cache-Control', 'private, max-age=300');
res.end(JSON.stringify({ res.end(
JSON.stringify({
places, places,
error: response.ok ? null : data.error?.message || 'Google Places request failed', error: response.ok
})); ? null
: data.error?.message || 'Google Places request failed',
}),
);
} catch (error) { } catch (error) {
res.statusCode = 502; res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); 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: [],
}),
);
} }
}); });
} }
+39 -10
View File
@@ -1,7 +1,10 @@
import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { haversineKm } from '../common/geo.js'; import { haversineKm } from '../common/geo.js';
import { readResponseTextCapped } from '../common/http.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 }. */ /** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */
const ROUTE_CACHE_MS = 600000; const ROUTE_CACHE_MS = 600000;
@@ -16,7 +19,11 @@ const ROUTE_MAX_LEG_KM = 600;
const ROUTE_MAX_TOTAL_KM = 2500; 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) { export function installRouteMiddleware(middlewares) {
// Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike). // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike).
@@ -28,7 +35,10 @@ export function installRouteMiddleware(middlewares) {
}; };
try { try {
if (!_routeRateLimiter(clientKey(req))) { if (!_routeRateLimiter(clientKey(req))) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' }); res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '5',
});
res.end(JSON.stringify({ ok: false, error: 'rate limited' })); res.end(JSON.stringify({ ok: false, error: 'rate limited' }));
return; return;
} }
@@ -37,8 +47,12 @@ export function installRouteMiddleware(middlewares) {
const profile = normalizeRouteProfile(raw); const profile = normalizeRouteProfile(raw);
if (!profile) return fail('invalid profile'); if (!profile) return fail('invalid profile');
const osrmProfile = profile === 'car' ? 'driving' : profile; const osrmProfile = profile === 'car' ? 'driving' : profile;
const pairs = (url.searchParams.get('coords') || '').split(';').map((s) => s.trim()).filter(Boolean); const pairs = (url.searchParams.get('coords') || '')
if (pairs.length < 2 || pairs.length > 12) return fail('need 2-12 coordinates'); .split(';')
.map((s) => s.trim())
.filter(Boolean);
if (pairs.length < 2 || pairs.length > 12)
return fail('need 2-12 coordinates');
const clean = []; const clean = [];
const pts = []; const pts = [];
for (const pr of pairs) { for (const pr of pairs) {
@@ -46,7 +60,12 @@ export function installRouteMiddleware(middlewares) {
if (parts.length !== 2) return fail('invalid coordinate'); if (parts.length !== 2) return fail('invalid coordinate');
const lon = Number(parts[0]); const lon = Number(parts[0]);
const lat = Number(parts[1]); const lat = Number(parts[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { if (
!Number.isFinite(lon) ||
!Number.isFinite(lat) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
return fail('invalid coordinate'); return fail('invalid coordinate');
} }
clean.push(`${lon},${lat}`); clean.push(`${lon},${lat}`);
@@ -58,7 +77,12 @@ export function installRouteMiddleware(middlewares) {
let totalKm = 0; let totalKm = 0;
for (let i = 1; i < pts.length; i += 1) { for (let i = 1; i < pts.length; i += 1) {
// pts are [lon, lat]; existing haversineKm takes (lat1, lon1, lat2, lon2). // 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]); 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'); if (legKm > ROUTE_MAX_LEG_KM) return fail('route leg too long');
totalKm += legKm; totalKm += legKm;
} }
@@ -84,16 +108,21 @@ export function installRouteMiddleware(middlewares) {
if (!upstreamRes.ok) return fail('no route found'); if (!upstreamRes.ok) return fail('no route found');
const ctype = upstreamRes.headers.get('content-type') || ''; const ctype = upstreamRes.headers.get('content-type') || '';
if (!ctype.includes('json')) return fail('no route found'); if (!ctype.includes('json')) return fail('no route found');
const text = await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES); const text = await readResponseTextCapped(
upstreamRes,
ROUTE_MAX_RESPONSE_BYTES,
);
osrm = JSON.parse(text); osrm = JSON.parse(text);
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
const route = osrm?.routes?.[0]; const route = osrm?.routes?.[0];
if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) return fail('no route found'); if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length)
return fail('no route found');
const payload = projectRouteResult(route, profile); const payload = projectRouteResult(route, profile);
_routeCache.set(cacheKey, { payload, cachedAt: now }); _routeCache.set(cacheKey, { payload, cachedAt: now });
if (_routeCache.size > 200) _routeCache.delete(_routeCache.keys().next().value); if (_routeCache.size > 200)
_routeCache.delete(_routeCache.keys().next().value);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload)); res.end(JSON.stringify(payload));
} catch (e) { } catch (e) {
+61 -26
View File
@@ -9,30 +9,40 @@ function placeContextPriority(types) {
} }
function approximateDistanceM(latA, lonA, latB, lonB) { 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 latitudeScale = 111320;
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
return Math.round(Math.hypot( return Math.round(
(latB - latA) * latitudeScale, Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
(lonB - lonA) * longitudeScale );
));
} }
export function projectNearbyPlaces(data, latitude, longitude) { export function projectNearbyPlaces(data, latitude, longitude) {
const seenPlaces = new Set(); const seenPlaces = new Set();
const places = Array.isArray(data.places) ? data.places const places = Array.isArray(data.places)
? data.places
.map((place) => { .map((place) => {
const placeLatitude = place.location?.latitude ?? null; const placeLatitude = place.location?.latitude ?? null;
const placeLongitude = place.location?.longitude ?? null; const placeLongitude = place.location?.longitude ?? null;
const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; const types = Array.isArray(place.types)
? place.types.slice(0, 8)
: [];
return { return {
id: place.id || null, id: place.id || null,
name: place.displayName?.text || null, name: place.displayName?.text || null,
address: place.shortFormattedAddress || place.formattedAddress || null, address:
place.shortFormattedAddress || place.formattedAddress || null,
latitude: placeLatitude, latitude: placeLatitude,
longitude: placeLongitude, longitude: placeLongitude,
distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), distanceM: approximateDistanceM(
primaryType: place.primaryTypeDisplayName?.text || place.primaryType || null, latitude,
longitude,
placeLatitude,
placeLongitude,
),
primaryType:
place.primaryTypeDisplayName?.text || place.primaryType || null,
types, types,
contextPriority: placeContextPriority(types), contextPriority: placeContextPriority(types),
}; };
@@ -43,49 +53,74 @@ export function projectNearbyPlaces(data, latitude, longitude) {
seenPlaces.add(key); seenPlaces.add(key);
return true; return true;
}) })
.sort((a, b) => b.contextPriority - a.contextPriority || a.distanceM - b.distanceM) .sort(
(a, b) =>
b.contextPriority - a.contextPriority || a.distanceM - b.distanceM,
)
.map(({ contextPriority, ...place }) => place) .map(({ contextPriority, ...place }) => place)
.slice(0, 20) : []; .slice(0, 20)
: [];
return places; return places;
} }
export function projectTextSearchPlaces(data, latitude, longitude) { export function projectTextSearchPlaces(data, latitude, longitude) {
const places = Array.isArray(data.places) ? data.places const places = Array.isArray(data.places)
? data.places
.map((place) => { .map((place) => {
const placeLatitude = place.location?.latitude ?? null; const placeLatitude = place.location?.latitude ?? null;
const placeLongitude = place.location?.longitude ?? null; const placeLongitude = place.location?.longitude ?? null;
const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; const types = Array.isArray(place.types)
? place.types.slice(0, 8)
: [];
// Places returns a lat/lng bounding box (low/high corners) framing the // 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 // place — no polygon, but enough to SIZE a fallback grounds disc to the
// real feature instead of a blind constant. Normalize to plain numbers. // real feature instead of a blind constant. Normalize to plain numbers.
const vp = place.viewport; const vp = place.viewport;
const viewport = ( const viewport =
Number.isFinite(vp?.low?.latitude) && Number.isFinite(vp?.low?.longitude) Number.isFinite(vp?.low?.latitude) &&
&& Number.isFinite(vp?.high?.latitude) && Number.isFinite(vp?.high?.longitude) Number.isFinite(vp?.low?.longitude) &&
) ? { Number.isFinite(vp?.high?.latitude) &&
low: { latitude: vp.low.latitude, longitude: vp.low.longitude }, Number.isFinite(vp?.high?.longitude)
high: { latitude: vp.high.latitude, longitude: vp.high.longitude }, ? {
} : null; low: {
latitude: vp.low.latitude,
longitude: vp.low.longitude,
},
high: {
latitude: vp.high.latitude,
longitude: vp.high.longitude,
},
}
: null;
return { return {
id: place.id || null, id: place.id || null,
name: place.displayName?.text || null, name: place.displayName?.text || null,
address: place.formattedAddress || null, address: place.formattedAddress || null,
latitude: placeLatitude, latitude: placeLatitude,
longitude: placeLongitude, longitude: placeLongitude,
distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), distanceM: approximateDistanceM(
latitude,
longitude,
placeLatitude,
placeLongitude,
),
primaryType: place.primaryType || null, primaryType: place.primaryType || null,
types, types,
viewport, viewport,
}; };
}) })
.filter((place) => place.name) : []; .filter((place) => place.name)
: [];
return places; return places;
} }
export function normalizeRouteProfile(raw) { export function normalizeRouteProfile(raw) {
return (raw === 'car' || raw === 'driving') ? 'car' return raw === 'car' || raw === 'driving'
: (raw === 'bike' || raw === 'cycling' || raw === 'bicycle') ? 'bike' ? 'car'
: (raw === 'foot' || raw === 'walking' || raw === 'walk') ? 'foot' : raw === 'bike' || raw === 'cycling' || raw === 'bicycle'
? 'bike'
: raw === 'foot' || raw === 'walking' || raw === 'walk'
? 'foot'
: null; : null;
} }
+165 -29
View File
@@ -1,32 +1,97 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { googlePlacesContextProxy, installRouteMiddleware } from '../../server/providers/places.js'; import {
import { projectNearbyPlaces, projectTextSearchPlaces } from '../../src/data/placeProviderPayloads.js'; googlePlacesContextProxy,
installRouteMiddleware,
} from '../../server/providers/places.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
} from '../../src/data/placeProviderPayloads.js';
function install(register) { function install(register) {
const routes = new Map(); 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') => { 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); } }; const res = {
await routes.get(route)({ url, method, socket: { remoteAddress: peer } }, 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; return res;
}; };
} }
test('nearby labels rank landmarks, deduplicate names/addresses and bound the projection', () => { 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 place = (name, types, longitude = 0) => ({
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); 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.length, 20);
assert.equal(result[0].name, 'Monument'); assert.equal(result[0].name, 'Monument');
assert.equal(result[0].distanceM, 11132); assert.equal(result[0].distanceM, 11132);
assert.equal(result.filter(p => p.name.toLowerCase() === 'monument').length, 1); assert.equal(
assert.ok(result.every(p => !('contextPriority' in p))); result.filter((p) => p.name.toLowerCase() === 'monument').length,
1,
);
assert.ok(result.every((p) => !('contextPriority' in p)));
assert.deepEqual(projectNearbyPlaces({}, 0, 0), []); assert.deepEqual(projectNearbyPlaces({}, 0, 0), []);
}); });
test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => { 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 viewport = {
const result = projectTextSearchPlaces({ places: [{ displayName: { text: 'Museum' }, viewport, types: Array(12).fill('museum') }, { displayName: { text: 'Park' }, viewport: { low: viewport.low } }] }, 0, 0); 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.deepEqual(result[0].viewport, viewport);
assert.equal(result[0].distanceM, Number.MAX_SAFE_INTEGER); assert.equal(result[0].distanceM, Number.MAX_SAFE_INTEGER);
assert.equal(result[0].latitude, null); 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]) { 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 = ''; let key = '';
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 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 = []; const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => { t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, options }); calls.push({ url, options });
assert.equal(options.headers['X-Goog-Api-Key'], key); 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 nearby = '/api/google/nearby-places';
const search = '/api/google/text-search'; 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); assert.equal(calls.length, 0);
key = 'fixture-server-key'; key = 'fixture-server-key';
const result = await request(nearby, '?lat=30&lon=-97&radiusM=9000'); const result = await request(nearby, '?lat=30&lon=-97&radiusM=9000');
assert.equal(result.statusCode, 200); assert.equal(result.statusCode, 200);
assert.equal(result.body.places[0].name, 'Museum'); 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'); assert.equal(result.headers['cache-control'], 'private, max-age=300');
key = 'rotated-fixture-key'; key = 'rotated-fixture-key';
assert.equal((await request(search, '?q=museum&lat=30&lon=-97&radiusM=1')).statusCode, 200); assert.equal(
assert.equal(JSON.parse(calls[1].options.body).locationBias.circle.radius, 50); (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(search, '?lat=30&lon=-97')).statusCode, 400);
assert.equal((await request(nearby, '?lat=bad&lon=-97')).statusCode, 400); assert.equal((await request(nearby, '?lat=bad&lon=-97')).statusCode, 400);
assert.equal((await request(nearby, '', 'POST')).statusCode, 405); assert.equal((await request(nearby, '', 'POST')).statusCode, 405);
t.mock.method(globalThis, 'fetch', async () => Response.json({ error: { message: 'Denied' } }, { status: 403 })); t.mock.method(globalThis, 'fetch', async () =>
assert.deepEqual((await request(search, '?q=museum&lat=30&lon=-97')).body, { places: [], error: 'Denied' }); 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); const request = install(installRouteMiddleware);
let calls = 0; let calls = 0;
let now = Date.now(); let now = Date.now();
t.mock.method(Date, 'now', () => now); t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', async url => { t.mock.method(globalThis, 'fetch', async (url) => {
calls++; calls++;
assert.match(url, /routed-car\/route\/v1\/driving\//); 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 route = '/api/route';
const query = '?profile=driving&coords=-97,30;-97.01,30.01'; const query = '?profile=driving&coords=-97,30;-97.01,30.01';
const result = await request(route, query); 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(result.body, {
assert.deepEqual((await request(route, query.replace('driving', 'car'))).body, 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); 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); assert.equal(calls, 1);
now += 600001; now += 600001;
t.mock.method(globalThis, 'fetch', async () => new Response('offline', { status: 503 })); t.mock.method(
assert.deepEqual((await request(route, query)).body, { ok: false, error: 'no route found' }); globalThis,
t.mock.method(globalThis, 'fetch', async () => new Response('x', { headers: { 'content-type': 'text/html' } })); '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'); assert.equal((await request(route, query)).body.error, 'no route found');
}); });