Merge upstream/main into fix/taiwan-highway-cctv-fallback

This commit is contained in:
Said Turksever committed 2026-11-07 17:46:53 +00:00
commit cc3d390911
474 files changed
+68164 -2182

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
import { normalizeAcartiaSightings } from '../../src/layers/wildlifeSightings/records.js';
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import { providerUserAgent } from './common/userAgent.js';
const API_URL = 'https://acartia.io/api/v1/sightings/current';
// All views share the provider's seven-day snapshot.
const TTL_MS = 600_000;
const STALE_MAX_MS = 24 * 60 * 60_000;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const TIMEOUT_MS = 15_000;
const COOLDOWN_MS = 60_000;
const COOLDOWN_MIN_MS = 5000;
const COOLDOWN_MAX_MS = 60 * 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
/** Bound upstream Retry-After seconds or dates to five seconds–one hour. */
export function acartiaRetryCooldownMs(raw, nowMs) {
const value = typeof raw === 'string' ? raw.trim() : '';
if (value) {
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
const deadline =
!Number.isFinite(seconds) && /[a-z]/i.test(value)
? Date.parse(value)
: NaN;
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
if (Number.isFinite(delay) && delay >= 0)
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, delay));
}
return COOLDOWN_MS;
}
/** Salish Sea coverage. Precise coordinates and comments never enter cache. */
export function acartiaProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cache = new Map(),
warn = (...args) => console.warn(...args),
} = {}) {
const inFlight = new Map();
const allow = makeRateLimiter({
windowMs: 60_000,
max: 60,
globalMax: 600,
now,
});
let cooldownUntil = 0;
let cooldownStatus = 429;
let warnedClamp = false;
const retrySeconds = () =>
Math.max(1, Math.ceil((cooldownUntil - now()) / 1000));
const unavailable = (status = 502) =>
Object.assign(new Error('acartia_unavailable'), {
status,
retryAfterS: retrySeconds(),
});
function coolDown(status, delay) {
const until = now() + delay;
if (until > cooldownUntil) {
cooldownUntil = until;
cooldownStatus = status;
} else if (status === 429 && now() < cooldownUntil) cooldownStatus = 429;
}
async function load() {
if (now() < cooldownUntil) throw unavailable(cooldownStatus);
const signal = AbortSignal.timeout(TIMEOUT_MS);
try {
const response = await fetchImpl(API_URL, {
signal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': providerUserAgent('wildlife-sightings'),
},
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if (response.status === 429) {
const delay = acartiaRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
if (delay === COOLDOWN_MAX_MS && !warnedClamp) {
warnedClamp = true;
warn('[acartia-proxy] upstream cooldown capped at one hour');
}
coolDown(429, delay);
throw unavailable(429);
}
throw unavailable();
}
// This endpoint documents a JSON array, not a results/data envelope.
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
signal,
);
const rows = normalizeAcartiaSightings(payload, now());
// Only a non-array is malformed. A quiet feed, or one whose rows all fall
// outside coverage or the species checks, is a valid empty snapshot.
if (!rows) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows };
} catch (error) {
if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS);
warn('[acartia-proxy] upstream refresh failed');
throw unavailable(error.status === 429 ? 429 : 502);
}
}
function age(entry) {
if (!entry) return Infinity;
const elapsed = now() - entry.savedAt;
return elapsed < 0 ? Infinity : elapsed;
}
async function acquire() {
const previous = cache.get('sightings');
if (age(previous) < TTL_MS) return { value: previous.value, stale: false };
if (age(previous) > STALE_MAX_MS) cache.delete('sightings');
try {
const { promise } = coalesceProxyRequest(
inFlight,
'sightings',
async () => {
const value = await load();
cache.set('sightings', { value, savedAt: now() });
return value;
},
);
return { value: await promise, stale: false };
} catch (error) {
if (age(previous) <= STALE_MAX_MS)
return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, { stale = false, retryAfterS } = {}) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(retryAfterS ? { 'Retry-After': String(retryAfterS) } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const pathname = (req.url || '/').split('?')[0];
if (pathname !== '/sightings') return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req)))
return json(429, { error: 'rate_limited' }, { retryAfterS: 60 });
try {
const { value, stale } = await acquire();
return json(200, stale ? { ...value, stale: true } : value, { stale });
} catch (error) {
return json(
error.status === 429 ? 429 : 502,
{ error: 'acartia_unavailable' },
{ retryAfterS: error.retryAfterS },
);
}
}
return {
name: 'acartia',
configureServer({ middlewares }) {
middlewares.use('/api/acartia', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/acartia', handler);
},
};
}
+419
View File
@@ -0,0 +1,419 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { acartiaProxy, acartiaRetryCooldownMs } from './acartia.js';
const NOW = Date.parse('2026-11-04T18:05:00Z');
const sighting = (id = 'synthetic-1') => ({
entry_id: id,
latitude: 48.12345,
longitude: -123.12345,
created: '2026-11-04 17:00:00',
type: 'Orca',
no_sighted: '5',
photo_url: 'https://provider.example/photos/synthetic.jpg',
data_source_name: 'Synthetic Whale Network',
data_source_entity: 'Community reports',
trusted: true,
data_source_comments: '[email protected] +1-555-000-0000',
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = acartiaProxy({ now: () => NOW, warn() {}, ...options });
assert.equal(plugin.name, 'acartia');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(mount, callback) {
assert.equal(mount, '/api/acartia');
handler = callback;
},
},
});
return async (
url = '/sightings',
{ method = 'GET', peer = 'local', headers = {} } = {},
) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(key, value) {
this.headers[key] = value;
},
end(value) {
this.status ??= this.statusCode;
this.body = JSON.parse(value);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
test('cache stores only supported marine mammals inside the coverage box', async () => {
const cache = new Map();
const request = install({
cache,
fetchImpl: async () =>
Response.json([
sighting('orca'),
{ ...sighting('seal'), type: 'Harbor seal' },
{ ...sighting('unknown'), type: 'Unknown' },
{ ...sighting('fish'), type: 'Whale shark' },
{ ...sighting('outside'), latitude: 37.8 },
{ ...sighting('rounded-edge'), latitude: 45.49999 },
]),
});
const result = await request();
assert.equal(result.status, 200);
for (const rows of [result.body.rows, cache.get('sightings').value.rows]) {
assert.deepEqual(
rows.map((row) => [row.stableId, row.cetacean]),
[
['acartia:orca', true],
['acartia:seal', false],
],
);
}
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: documented array is normalized and coarsened before caching`, async () => {
const cache = new Map();
let calls = 0;
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls += 1;
assert.equal(url, 'https://acartia.io/api/v1/sightings/current');
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.headers.Accept, 'application/json');
assert.equal(
options.headers['User-Agent'],
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
);
return Response.json([sighting()]);
},
},
hook,
);
assert.equal(calls, 0);
assert.equal(cache.size, 0);
const result = await request(
'/sightings?url=https://attacker.example&latitude=1',
);
assert.equal(result.status, 200);
assert.equal(result.headers['Cache-Control'], 'no-store');
assert.equal(result.body.fetchedAt, NOW);
assert.equal(result.body.rows[0].stableId, 'acartia:synthetic-1');
assert.equal(result.body.rows[0].lat, 48.12);
assert.equal(result.body.rows[0].lon, -123.12);
assert.equal(result.body.rows[0].provider.name, 'Synthetic Whale Network');
const retained = cache.get('sightings');
assert.equal(retained.value.rows[0].lat, 48.12);
assert.equal(retained.value.rows[0].lon, -123.12);
for (const value of [result.body, [...cache.values()]])
assert.doesNotMatch(
JSON.stringify(value),
/private-observer|555|data_source_comments|48\.12345|123\.12345/,
);
assert.equal(calls, 1);
});
}
test('gate, method and fixed route run before cache or upstream', async () => {
const cache = new Map();
let calls = 0;
const request = install({
cache,
fetchImpl: async () => {
calls += 1;
return Response.json([sighting()]);
},
});
for (let i = 0; i < 65; i += 1) {
assert.equal(
(
await request('/sightings', {
headers: {
host: 'localhost:5173',
origin: 'https://foreign.example',
'sec-fetch-site': 'cross-site',
},
})
).status,
403,
);
}
const method = await request('/sightings', { method: 'POST' });
assert.equal(method.status, 405);
assert.equal(method.headers.Allow, 'GET');
for (const route of ['/', '/unknown', '/sightings/extra']) {
const result = await request(route);
assert.equal(result.status, 404);
assert.equal(result.body.error, 'unknown_route');
}
assert.equal(calls, 0);
assert.equal(cache.size, 0);
const sameOrigin = await request('/sightings', {
headers: {
host: 'gev.example',
origin: 'https://gev.example',
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
});
assert.equal(sameOrigin.status, 200);
});
test('request 61 is locally limited even with a fresh cache', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
return Response.json([sighting()]);
},
});
for (let i = 0; i < 60; i += 1) assert.equal((await request()).status, 200);
const limited = await request();
assert.equal(limited.status, 429);
assert.equal(limited.body.error, 'rate_limited');
assert.equal(limited.headers['Retry-After'], '60');
assert.equal(calls, 1);
});
test('one global snapshot single-flights and stays fresh for ten minutes', async () => {
let at = NOW;
let calls = 0;
let release;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
if (calls === 1)
await new Promise((resolve) => {
release = resolve;
});
return Response.json([sighting()]);
},
});
const first = request();
const concurrent = request();
await new Promise(setImmediate);
assert.equal(calls, 1);
release();
assert.deepEqual((await first).body, (await concurrent).body);
at += 599_999;
await request();
assert.equal(calls, 1);
at += 1;
await request();
assert.equal(calls, 2);
});
test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => {
for (const quiet of [
[],
[null],
[{ ...sighting(), data_source_name: '' }],
[{ ...sighting(), latitude: 37.8 }],
[{ ...sighting(), type: 'Unknown' }],
]) {
let at = NOW;
let calls = 0;
let empty = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(empty ? quiet : [sighting()]);
},
});
assert.equal((await request()).body.rows.length, 1);
empty = true;
at += 600_001;
const result = await request();
assert.equal(result.status, 200);
assert.deepEqual(result.body, { fetchedAt: at, rows: [] });
assert.equal(result.headers['X-Data-Stale'], undefined);
assert.equal(result.headers['Retry-After'], undefined);
assert.deepEqual(cache.get('sightings').value.rows, []);
// The quiet feed keeps the normal ten-minute cadence, not a failure retry.
at += 599_999;
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
at += 1;
await request();
assert.equal(calls, 3);
const cold = install({ fetchImpl: async () => Response.json(quiet) });
const coldResult = await cold();
assert.equal(coldResult.status, 200);
assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] });
}
});
test('an invented wrapped snapshot never replaces the cache', async () => {
const bad = { results: [sighting()] };
let at = NOW;
let fail = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => Response.json(fail ? bad : [sighting()]),
});
const fresh = await request();
const retained = cache.get('sightings');
fail = true;
at += 600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
assert.equal(cache.get('sightings'), retained);
const coldCache = new Map();
const cold = install({
cache: coldCache,
fetchImpl: async () => Response.json(bad),
});
const result = await cold();
assert.equal(result.status, 502);
assert.equal(result.body.error, 'acartia_unavailable');
assert.equal(result.headers['Retry-After'], '15');
assert.equal(coldCache.size, 0);
});
test('failure serves stale for at most 24 hours and never reports a cold success', async () => {
let at = NOW;
let fail = false;
const request = install({
now: () => at,
fetchImpl: async () => {
if (fail) throw new Error('private upstream detail');
return Response.json([sighting()]);
},
});
const first = await request();
fail = true;
at += 23 * 60 * 60_000 + 59 * 60_000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
at += 2 * 60_000;
const expired = await request();
assert.equal(expired.status, 502);
assert.deepEqual(expired.body, { error: 'acartia_unavailable' });
});
test('upstream 429 honors Retry-After, caps it, and spends no requests while cooling down', async () => {
assert.equal(acartiaRetryCooldownMs('2', NOW), 5000);
assert.equal(acartiaRetryCooldownMs('999999', NOW), 3_600_000);
assert.equal(
acartiaRetryCooldownMs(new Date(NOW + 120_000).toUTCString(), NOW),
120_000,
);
assert.equal(acartiaRetryCooldownMs(null, NOW), 60_000);
let at = NOW;
let calls = 0;
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: async () => {
calls += 1;
return new Response('limit', {
status: 429,
headers: { 'Retry-After': '999999' },
});
},
});
const first = await request();
assert.equal(first.status, 429);
assert.equal(first.body.error, 'acartia_unavailable');
assert.equal(first.headers['Retry-After'], '3600');
at += 5000;
const cooling = await request();
assert.equal(cooling.headers['Retry-After'], '3595');
assert.equal(calls, 1);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
1,
);
at += 3_600_000;
await request();
assert.equal(calls, 2);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
1,
);
});
test('upstream 429 also serves a prior snapshot stale', async () => {
let at = NOW;
let fail = false;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return fail
? new Response('limit', { status: 429 })
: Response.json([sighting()]);
},
});
const first = await request();
fail = true;
at += 600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
at += 1000;
assert.equal((await request()).body.stale, true);
assert.equal(calls, 2);
});
test('bad JSON, 5xx and capped bodies start a 15 second failure cooldown', async () => {
for (const bad of [
() => new Response('{malformed'),
() => new Response('private upstream message', { status: 503 }),
() =>
new Response('{}', {
headers: { 'Content-Length': String(2 * 1024 * 1024 + 1) },
}),
]) {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return calls === 1 ? bad() : Response.json([sighting()]);
},
});
const first = await request();
assert.equal(first.status, 502);
assert.deepEqual(first.body, { error: 'acartia_unavailable' });
assert.equal(first.headers['Retry-After'], '15');
at += 14_999;
assert.equal((await request()).headers['Retry-After'], '1');
assert.equal(calls, 1);
at += 1;
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
}
});
+12
View File
@@ -27,6 +27,7 @@ import {
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadCataloniaSourcesFromOpenData,
loadIcelandSourcesFromOpenData,
loadHkSourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadKingCountySourcesFromOpenData,
@@ -35,6 +36,7 @@ import {
loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
loadFloridaSourcesFromOpenData,
loadHoustonSourcesFromOpenData,
loadA22SourcesFromOpenDataHub,
} from './sources.js';
@@ -121,6 +123,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
load: loadCataloniaSourcesFromOpenData,
},
{
name: 'iceland',
enabled: () => envEnabled('CCTV_ICELAND_ENABLED'),
load: loadIcelandSourcesFromOpenData,
},
{
name: 'hk',
enabled: () => envEnabled('CCTV_HK_ENABLED'),
@@ -178,6 +185,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources,
},
{
name: 'houston',
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
load: loadHoustonSourcesFromOpenData,
},
{
name: 'quebec',
enabled: () => envEnabled('CCTV_QUEBEC_ENABLED'),
+62
View File
@@ -347,6 +347,37 @@ export const CATALONIA_ANCHORS = [
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
];
/**
* Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras:
* one keyless JSON list, one row per camera view (about 500 views at roughly
* 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each
* rewritten by the publisher every few minutes. Camera images are published
* under CC BY 4.0 (IRCA terms of use, "Open data" section).
*/
export const ICELAND_CAMERAS_URL =
'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1';
/** The only directory Iceland camera frames may come from. */
export const ICELAND_IMAGE_ORIGIN =
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */
export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
/** Ground-elevation prior in metres: the feed carries none, and the country
* runs from sea level to highland passes. The client's ground snap corrects
* this on 3D-tile stacks. */
export const ICELAND_GROUND_ELEVATION_M = 60;
/** The feed lists about 500 views; the default keeps half so the shared
* catalog cap still leaves room for denser regions. */
export const DEFAULT_ICELAND_MAX_SOURCES = 250;
/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps
* every region's nearest cameras instead of only the capital's. */
export const ICELAND_ANCHORS = [
{ lat: 64.1466, lon: -21.9426 }, // Reykjavík
{ lat: 65.6885, lon: -18.1262 }, // Akureyri
{ lat: 65.2639, lon: -14.3948 }, // Egilsstaðir
{ lat: 66.0749, lon: -23.1351 }, // Ísafjörður
{ lat: 63.9331, lon: -20.9971 }, // Selfoss
];
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
export const DEFAULT_HK_CAMERAS_URL =
@@ -481,6 +512,37 @@ export const NYC_ANCHORS = [
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
];
/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000
* cameras with a numeric id). Frames are stills synthesized from that id on
* the city's snapshot host — never taken from the payload. The catalog is
* served as `application/javascript`; asking for `application/json` gets HTTP
* 406, so the loader accepts any media type. */
export const HOUSTON_CAMERAS_URL =
'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js';
/** The only origin Houston camera frames may come from. The catalog's `url`
* is a `showWin(...)` viewer helper and is ignored; the registered path is
* always this origin plus `/snapshots/cctv/<id>.jpg`. */
export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/';
export const HOUSTON_IMAGE_URL = (id) =>
`${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`;
/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a
* different (non-snapshot) camera class in the same list. */
export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/;
export const DEFAULT_HOUSTON_MAX_SOURCES = 250;
export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Ground-elevation prior in metres. The TranStar payload carries none, and
* Houston sits a few metres above sea level. */
export const HOUSTON_GROUND_ELEVATION_M = 15;
/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */
export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 };
export const HOUSTON_ANCHORS = [
{ lat: 29.7604, lon: -95.3698 }, // Downtown
{ lat: 29.739, lon: -95.461 }, // Uptown / Galleria
{ lat: 29.71, lon: -95.398 }, // Texas Medical Center
{ lat: 29.9902, lon: -95.3368 }, // IAH
{ lat: 29.3013, lon: -94.7977 }, // Galveston
];
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
* own camera list with coordinates, one request for the whole motorway
+2
View File
@@ -18,6 +18,7 @@ import {
OTTAWA_IMAGE_ORIGIN,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
ICELAND_IMAGE_ORIGIN,
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
TFL_IMAGE_ORIGIN,
WARENDORF_IMAGE_ORIGINS,
@@ -528,6 +529,7 @@ for (const prefix of [
TFL_IMAGE_ORIGIN,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
ICELAND_IMAGE_ORIGIN,
...WARENDORF_IMAGE_ORIGINS,
NSW_IMAGE_ORIGIN,
OTTAWA_IMAGE_ORIGIN,
+23
View File
@@ -410,6 +410,18 @@ export function isLikelyHongKongCoordinate(lat, lon) {
);
}
/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther
* TranStar partners (Dallas, Columbus) are out of this city pack. */
export function isLikelyHoustonCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 29.2 &&
lat <= 30.35 &&
lon >= -96.05 &&
lon <= -94.7
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
@@ -448,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
);
}
/** Iceland bounding box (mainland and nearby islands, with a margin). */
export function isLikelyIcelandCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 63.0 &&
lat <= 66.7 &&
lon >= -25.0 &&
lon <= -13.0
);
}
/**
* Decode numeric HTML character references, decimal ("&#237;") and hex
* ("&#xED;") alike, the entity form the Catalonia feed uses for accented
+342
View File
@@ -127,6 +127,19 @@ import {
A22_MAX_CATALOG_BYTES,
A22_GROUND_ELEVATION_BY_KM,
A22_DEFAULT_GROUND_ELEVATION_M,
HOUSTON_CAMERAS_URL,
HOUSTON_IMAGE_URL,
HOUSTON_CAMERA_ID_RE,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_MAX_CATALOG_BYTES,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_ANCHORS,
ICELAND_CAMERAS_URL,
ICELAND_IMAGE_ORIGIN,
ICELAND_MAX_CATALOG_BYTES,
ICELAND_GROUND_ELEVATION_M,
DEFAULT_ICELAND_MAX_SOURCES,
ICELAND_ANCHORS,
} from './constants.js';
import {
toFiniteNumber,
@@ -145,8 +158,10 @@ import {
isLikelyCalgaryCoordinate,
isLikelyFloridaCoordinate,
isLikelyCataloniaCoordinate,
isLikelyIcelandCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyHoustonCoordinate,
isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
@@ -2021,6 +2036,174 @@ export async function loadCataloniaSourcesFromOpenData() {
}
}
/**
* Compass headings for Icelandic direction words, as they appear after "til"
* (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen",
* as in "séð austur").
*/
const ICELAND_DIRECTION_HEADINGS = {
norður: 0,
norðaustur: 45,
austur: 90,
suðaustur: 135,
suður: 180,
suðvestur: 225,
vestur: 270,
norðvestur: 315,
};
const ICELAND_DIRECTION_WORD =
'(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?';
/** A facing, plus a second one joined by "og" ("til austurs og vesturs"). */
const ICELAND_DIRECTION_RE = new RegExp(
`(?<![\\p{L}])(?:til|í|séð)\\s+${ICELAND_DIRECTION_WORD}` +
`(?:\\s+og\\s+(?:til\\s+|í\\s+)?${ICELAND_DIRECTION_WORD})?(?![\\p{L}])`,
'gu',
);
/**
* The compass heading an Icelandic view description states, or NaN. Only an
* explicit "til <átt>", "í <átt>" or "séð <átt>" counts: a bare place name or
* a road direction ("Miklabraut austur") is not a facing. A description naming
* two different directions ("til norðurs og til suðurs", "til austurs og
* vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the
* road) states none.
*
* @param {string} text - The feed's `Skyring` field.
* @returns {number} Heading in degrees [0..360), or NaN.
*/
export function icelandHeadingFromDescription(text) {
const headings = new Set();
for (const match of String(text || '')
.toLowerCase()
.matchAll(ICELAND_DIRECTION_RE)) {
for (const word of [match[1], match[2]]) {
if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]);
}
}
return headings.size === 1 ? headings.values().next().value : NaN;
}
/**
* One Vegagerðin camera row -> one catalog source, or null. A row is one camera
* view; a station (`Maelist_nr`) carries several. The id comes from the frame
* file name, which is unique per view in the live feed.
*
* @param {object} row - Element of the `vefmyndavelar2014_1` array.
* @returns {?object}
*/
export function icelandCameraToSource(row) {
const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN);
if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null;
// Numbers only: Number(null) and Number('') are 0.
const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN;
const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN;
if (!isLikelyIcelandCoordinate(lat, lon)) return null;
const stem = new URL(url).pathname
.slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length)
.replace(/\.jpe?g$/i, '')
.toLowerCase();
if (!/^[a-z0-9_-]+$/.test(stem)) return null;
const cameraId = `is-vegagerdin-${stem}`;
const description = String(row.Skyring || '')
.replace(/\s+/g, ' ')
// Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá
// Árnessýslu" while missing "séð séð".
.replace(/(?<!\p{L})(\p{L}+) \1(?!\p{L})/giu, '$1')
.trim();
const station = String(row.Myndavel || '').trim();
const heading = icelandHeadingFromDescription(description);
const hasHeading = Number.isFinite(heading);
return {
id: cameraId,
name: description || station || `Vegagerðin ${row.Maelist_nr ?? stem}`,
city: 'Iceland',
cityId: 'iceland',
provider: 'Vegagerðin (IRCA)',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
groundElevationM: ICELAND_GROUND_ELEVATION_M,
feedType: 'image',
url,
snapshotUrl: url,
sourceKind: 'iceland-vegagerdin',
license:
'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0',
code: cameraDisplayCode(station.toUpperCase() || description),
};
}
/**
* Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list.
* Frames are stills on the IRCA host and the loader registers only links
* inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides
* ids, coordinates, labels and which frame path is proxied, so it is fetched
* over https, redirects are refused and the body read is capped at
* ICELAND_MAX_CATALOG_BYTES.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadIcelandSourcesFromOpenData() {
try {
const resp = await fetch(ICELAND_CAMERAS_URL, {
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
},
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
// A response this loader will not read still owns its transport until the
// body is released, so every rejection path cancels before returning.
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Iceland catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Iceland camera download failed:', resp.status);
return discard();
}
const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES);
const rows = Array.isArray(body) ? body : [];
const cameras = rows.map(icelandCameraToSource).filter(Boolean);
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_ICELAND_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS);
console.log(
`[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Iceland camera download error:',
error?.message || error,
);
return [];
}
}
/**
* Read one XML child tag's text from an `<image>` block.
*
@@ -3771,3 +3954,162 @@ export async function loadA22SourcesFromOpenDataHub() {
return [];
}
}
/**
* Label for one Houston TranStar camera: the interchange an operator
* recognises ("10 EAST @ SAN JACINTO").
*
* @param {object} record - Raw TranStar camera row.
* @param {string} cameraId - Derived stable id.
* @returns {string}
*/
export function houstonCameraName(record, cameraId) {
const name = String(record?.location ?? '').trim();
if (name) return name;
const short = String(cameraId || '').replace(/^houston-/, '');
return short ? `Houston Camera ${short}` : 'Houston Camera';
}
/**
* TranStar `dir` (the camera's side of the freeway) -> the travel direction
* of the nearest lanes, as TranStar's map script `translateCameraDirection`
* reads it. Only these four values are mapped; anything else is unknown.
*/
const HOUSTON_TRAVEL_HEADING_BY_SIDE = Object.freeze({
south: 90,
north: 270,
west: 180,
east: 0,
});
/**
* One Houston TranStar camera row -> one catalog source, or null.
*
* Frame URLs are synthesized from a validated numeric id on the pinned
* snapshot origin. The payload's `url` is a `showWin(...)` viewer helper
* and is ignored so a catalog edit cannot steer the frame proxy off-host.
* TranStar's `dir` is the side of the freeway the camera sits on, not the
* way it faces: TranStar's own map labels it "Traffic closest to the camera
* is traveling …" (South→Eastbound, North→Westbound, West→Southbound,
* East→Northbound). That travel direction is only a low-confidence prior
* for the view, so the heading stays estimated with the low-confidence
* pose; `None`, blank or unknown values fall back to the shared id-hash
* heading.
*
* @param {object} record - Raw TranStar camera row.
* @returns {?object}
*/
export function houstonCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
const lat = toFiniteNumber(record.lat);
const lon = toFiniteNumber(record.lng);
if (!isLikelyHoustonCoordinate(lat, lon)) return null;
const rawId = String(record.id ?? '').trim();
if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null;
const cameraId = `houston-${rawId}`;
const imageUrl = HOUSTON_IMAGE_URL(rawId);
const name = houstonCameraName(record, cameraId);
const travelHeading =
HOUSTON_TRAVEL_HEADING_BY_SIDE[
String(record.dir ?? '')
.trim()
.toLowerCase()
];
return {
id: cameraId,
name,
city: 'Houston',
cityId: 'houston',
provider: 'Houston TranStar',
lat,
lon,
headingDeg: Number.isFinite(travelHeading)
? travelHeading
: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: HOUSTON_GROUND_ELEVATION_M,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'houston-transtar',
license:
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js`
* catalog. Frames are stills on `www.houstontranstar.org`, registered from
* a validated numeric id rather than from the payload URL. The catalog host
* answers `Accept: application/json` with HTTP 406, so this request accepts
* any media type.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadHoustonSourcesFromOpenData() {
try {
const endpoint =
process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: '*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Houston catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Houston camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(
resp,
HOUSTON_MAX_CATALOG_BYTES,
);
const rows = Array.isArray(payload?.cameras) ? payload.cameras : [];
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = houstonCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
: DEFAULT_HOUSTON_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS);
console.log(
`[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Houston camera download error:',
error?.message || error,
);
return [];
}
}
+137
View File
@@ -0,0 +1,137 @@
import path from 'node:path';
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
/**
* A bounded two-level cache for JSON answers: memory (entry count) and disk
* (entry count, total bytes, age). Every memory insertion goes through one
* function, so a disk hit is bounded like a fresh answer; disk writes are
* followed by a prune that removes expired files first, then the oldest,
* until both quotas hold.
*
* @param {object} options
* @param {string|null} options.dir Disk directory; null keeps memory only.
* @param {number} options.ttlMs Age past which an entry is not served.
* @param {number} [options.maxMemoryEntries]
* @param {number} [options.maxDiskEntries]
* @param {number} [options.maxDiskBytes]
* @param {() => number} [options.now]
*/
export function createBoundedCache({
dir,
ttlMs,
maxMemoryEntries = 60,
maxDiskEntries = 400,
maxDiskBytes = 256 * 1024 * 1024,
now = () => Date.now(),
}) {
const memory = new Map();
let pruning = null;
let prunePending = false;
const fileFor = (key) =>
path.join(dir, `${createHash('sha1').update(key).digest('hex')}.json`);
function remember(key, entry) {
memory.delete(key);
memory.set(key, entry);
while (memory.size > maxMemoryEntries)
memory.delete(memory.keys().next().value);
}
async function prune() {
if (!dir) return;
let names;
try {
names = (await fsp.readdir(dir)).filter((name) => name.endsWith('.json'));
} catch {
return;
}
const files = [];
for (const name of names) {
const file = path.join(dir, name);
try {
const stat = await fsp.stat(file);
files.push({ file, size: stat.size, mtime: stat.mtimeMs });
} catch {
/* removed meanwhile */
}
}
const cutoff = now() - ttlMs;
const keep = [];
for (const entry of files) {
if (entry.mtime < cutoff) await fsp.rm(entry.file, { force: true });
else keep.push(entry);
}
keep.sort((a, b) => a.mtime - b.mtime);
let bytes = keep.reduce((sum, entry) => sum + entry.size, 0);
while (
keep.length &&
(keep.length > maxDiskEntries || bytes > maxDiskBytes)
) {
const oldest = keep.shift();
bytes -= oldest.size;
await fsp.rm(oldest.file, { force: true });
}
}
/** Run one prune at a time; a request during a prune schedules one more. */
function schedulePrune() {
if (pruning) {
prunePending = true;
return pruning;
}
pruning = prune()
.catch(() => {})
.finally(() => {
pruning = null;
if (prunePending) {
prunePending = false;
schedulePrune();
}
});
return pruning;
}
return {
/** The cached value, or undefined. A disk hit is re-inserted into memory. */
async get(key) {
const held = memory.get(key);
if (held && now() - held.cachedAt <= ttlMs) return held.value;
if (held) memory.delete(key);
if (!dir) return undefined;
try {
const entry = JSON.parse(await fsp.readFile(fileFor(key), 'utf8'));
if (!Number.isFinite(entry?.cachedAt) || now() - entry.cachedAt > ttlMs)
return undefined;
remember(key, entry);
return entry.value;
} catch {
return undefined;
}
},
/** Store a value in memory and, when a directory is set, on disk. */
async set(key, value) {
const entry = { value, cachedAt: now() };
remember(key, entry);
if (!dir) return;
try {
await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(fileFor(key), JSON.stringify(entry));
} catch {
return;
}
// In the background: an answer never waits on a 400-file directory scan.
// `prune()` returns the pending pass for callers that need it done.
schedulePrune();
},
/** Run a prune and wait until no pass is running or queued. */
async prune() {
schedulePrune();
while (pruning) await pruning;
},
get memorySize() {
return memory.size;
},
};
}
+221
View File
@@ -0,0 +1,221 @@
import { readRequestBody } from './request.js';
import {
clientKey,
makeCostRateLimiter,
resolvePerMinuteCap,
} from './rate-limit.js';
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
import { isGeminiRequestOriginAllowed } from '../gemini/request.js';
import { parseUsage } from './textTools.js';
const MAX_BYTES = 512 * 1024;
const FAILURE = 'Could not connect. Try again.';
// A rejected key is not retried upstream for this long, unless the key changes.
const REJECTED_KEY_COOLDOWN_MS = 60_000;
// What an upstream failure says about the key or the account. Gemini answers
// an invalid key with 400 and an ErrorInfo reason, not 401; OpenAI answers an
// account without credit with 429 insufficient_quota, which waiting won't fix.
// A 403 is the key's project, region or permissions, so it cools down too but
// does not blame the key. Only small error bodies are read.
async function upstreamRefusal(response, signal) {
const { status } = response;
if (status === 401) return 'API_KEY_REJECTED';
if (status === 403) return 'ACCESS_DENIED';
if (![400, 429].includes(status)) return null;
let error;
try {
error = (await readResponseJsonCapped(response, 16 * 1024, signal))?.error;
} catch {
return null;
}
if (
status === 400 &&
Array.isArray(error?.details) &&
error.details.some((detail) => detail?.reason === 'API_KEY_INVALID')
)
return 'API_KEY_REJECTED';
if (status === 429 && error?.code === 'insufficient_quota')
return 'QUOTA_EXCEEDED';
return null;
}
// Pass through only a delay in whole seconds, capped at an hour.
function upstreamRetryAfter(response) {
const value = String(response.headers?.get?.('retry-after') ?? '').trim();
if (!/^\d{1,6}$/.test(value)) return null;
return String(Math.min(Math.max(Number(value), 1), 3600));
}
export function createTextChatHandler({
provider,
prepare,
parse,
fetchImpl = (...args) => fetch(...args),
resolveApiKey,
resolveHost = () => process.env.HOST,
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
resolveRateLimit = () =>
process.env[`GEV_RATELIMIT_${provider.toUpperCase()}_CHAT_PER_MIN`],
// An unset chat throttle follows the provider's own cost throttle.
resolveProviderRateLimit = () => undefined,
providerPerMin = 30,
// Returns { error, code } when the route must refuse before any key is used.
refuse = () => null,
now = Date.now,
timeoutMs = 60000,
} = {}) {
let limiter;
let rejected = null;
return async (req, res) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('X-Content-Type-Options', 'nosniff');
const reply = (status, value, code) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.statusCode = status;
if (status >= 400) res.setHeader('Connection', 'close');
res.end(
JSON.stringify(
typeof value === 'string'
? { error: value, ...(code ? { code } : {}) }
: value,
),
);
};
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return reply(405, 'Method not allowed');
}
const lifetime = new AbortController();
const disconnect = () => {
if (!res.writableEnded) lifetime.abort();
};
req.once('aborted', disconnect);
res.once('close', disconnect);
const timer = setTimeout(() => lifetime.abort(), timeoutMs);
timer.unref?.();
let response;
let usage = null;
try {
if (
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
)
return reply(403, FAILURE);
const refusal = refuse();
if (refusal) return reply(503, refusal.error, refusal.code);
const apiKey = String(resolveApiKey() || '').trim();
if (!apiKey)
return reply(
503,
'Add an API key in Provider Settings',
'API_KEY_MISSING',
);
if (rejected && (rejected.key !== apiKey || now() >= rejected.until))
rejected = null;
if (rejected) {
res.setHeader(
'Retry-After',
String(Math.max(1, Math.ceil((rejected.until - now()) / 1000))),
);
return reply(502, FAILURE, rejected.code);
}
if (limiter === undefined)
limiter = makeCostRateLimiter(
resolveRateLimit(),
resolvePerMinuteCap(resolveProviderRateLimit(), providerPerMin),
);
if (limiter && !limiter(clientKey(req))) {
res.setHeader('Retry-After', '60');
return reply(429, FAILURE, 'RATE_LIMITED');
}
if (
!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')
)
return reply(415, FAILURE);
if (Number(req.headers['content-length']) > MAX_BYTES)
return reply(413, FAILURE);
const raw = await readRequestBody(req, MAX_BYTES, lifetime.signal);
let input;
try {
input = JSON.parse(raw);
} catch {
return reply(400, FAILURE);
}
const prepared = prepare(input);
if (!prepared) return reply(400, FAILURE);
if (prepared.errorStatus) return reply(prepared.errorStatus, FAILURE);
const { endpoint, body, headers } = prepared;
lifetime.signal.throwIfAborted();
const acquisition = Promise.resolve(
fetchImpl(endpoint, {
method: 'POST',
redirect: 'error',
signal: lifetime.signal,
headers: { 'Content-Type': 'application/json', ...headers(apiKey) },
body: JSON.stringify(body),
}),
);
void acquisition.then(
(late) => {
response = late;
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
},
() => {},
);
response = await waitForSignal(acquisition, lifetime.signal);
lifetime.signal.throwIfAborted();
if (response.redirected || (response.url && response.url !== endpoint))
throw new Error('Unexpected destination');
if (!response.ok) {
const refusal = await upstreamRefusal(response, lifetime.signal);
if (refusal) {
void response.body?.cancel().catch(() => {});
rejected = {
key: apiKey,
code: refusal,
until: now() + REJECTED_KEY_COOLDOWN_MS,
};
return reply(502, FAILURE, refusal);
}
void response.body?.cancel().catch(() => {});
if (![429, 503].includes(response.status)) return reply(502, FAILURE);
const retryAfter = upstreamRetryAfter(response);
if (retryAfter) res.setHeader('Retry-After', retryAfter);
return reply(
response.status,
FAILURE,
response.status === 429 ? 'RATE_LIMITED' : undefined,
);
}
const data = await readResponseJsonCapped(
response,
MAX_BYTES,
lifetime.signal,
);
lifetime.signal.throwIfAborted();
usage = parseUsage(provider, data);
const result = parse(data);
if (!result || JSON.stringify(result).includes(apiKey))
return reply(502, { error: FAILURE, usage });
reply(200, { ...result, usage });
} catch (error) {
reply(
error?.code === 'BODY_TOO_LARGE'
? 413
: lifetime.signal.aborted
? 504
: 502,
{ error: FAILURE, usage },
);
} finally {
if (lifetime.signal.aborted)
void response?.body?.cancel().catch(() => {});
clearTimeout(timer);
req.off('aborted', disconnect);
res.off('close', disconnect);
}
};
}
@@ -0,0 +1,32 @@
import { realtimeDirectives } from '../openai/instructions.js';
// Voice wording in the shared directives and its written form. A test checks
// each phrase still occurs in the directives, so rewording one there fails
// rather than leaking spoken wording into chat.
export const TEXT_WORDING = Object.freeze([
['without speaking first', 'without writing a preamble'],
['before speaking', 'before confirming'],
['may first say ONE sentence', 'may first write ONE sentence'],
["speak the result's say line", "write the result's say line"],
['on the voice card: speak a caveat', 'in the tool result: include a caveat'],
['as you talk', 'as you write'],
['you can read aloud', 'you can report'],
]);
// Shared-manual adaptation follows Darren Dignam's GEV COMMAND approach (#976).
// Keep our Radio handoff and annotation outcome policies from the shared manual.
// `options.overpass` matches the tools offered, as for voice.
export function textInstructions(annotationGuidance, options) {
return realtimeDirectives(annotationGuidance, options)
.map((line, index) => {
if (index === 0)
return "You are GEV Chat, a concise text assistant for a Cesium geospatial app called God's Eye View.";
if (index === 1)
return 'Have a natural written conversation. This is text-only chat; no microphone or generated audio is used.';
return TEXT_WORDING.reduce(
(text, [spoken, written]) => text.replace(spoken, written),
line,
);
})
.join('\n');
}
+54
View File
@@ -0,0 +1,54 @@
import { validateValue } from '../../../src/tools/schema.js';
export function invalidCalls(calls, tools) {
const schemas = new Map(tools.map((tool) => [tool.name, tool.parameters]));
return calls.flatMap((call, index) => {
const schema = schemas.get(call.name);
const errors = !schema
? ['Unknown tool']
: call.parseError
? ['Arguments must be valid JSON']
: validateValue(schema, call.args === undefined ? {} : call.args);
return errors.length
? [{ index, error: errors.slice(0, 4).join('; ').slice(0, 1000) }]
: [];
});
}
// Missing usage stays unknown, never an invented zero-cost request.
export function parseUsage(provider, data) {
const raw = provider === 'gemini' ? data?.usageMetadata : data?.usage;
const number = (value) =>
Number.isSafeInteger(value) && value >= 0 ? value : null;
const inputTokens = number(
provider === 'gemini' ? raw?.promptTokenCount : raw?.prompt_tokens,
);
const candidateTokens = number(
provider === 'gemini' ? raw?.candidatesTokenCount : raw?.completion_tokens,
);
const totalTokens = number(
provider === 'gemini' ? raw?.totalTokenCount : raw?.total_tokens,
);
if (inputTokens === null || candidateTokens === null || totalTokens === null)
return null;
const reasoningTokens =
number(
provider === 'gemini'
? raw?.thoughtsTokenCount
: raw?.completion_tokens_details?.reasoning_tokens,
) ?? 0;
const outputTokens =
candidateTokens + (provider === 'gemini' ? reasoningTokens : 0);
return {
inputTokens,
outputTokens,
totalTokens,
reasoningTokens,
cachedInputTokens:
number(
provider === 'gemini'
? raw?.cachedContentTokenCount
: raw?.prompt_tokens_details?.cached_tokens,
) ?? 0,
};
}
+4
View File
@@ -0,0 +1,4 @@
/** Identify each provider while keeping the project contact URL consistent. */
export function providerUserAgent(name) {
return `gods-eye-view-${name}-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)`;
}
+157
View File
@@ -0,0 +1,157 @@
import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js';
import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and
// R (restricted) only — the small, always-in-force slice of the national
// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a
// payload small enough to refresh (~330 KB for the current P+R set).
const API_URL =
'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' +
'Special_Use_Airspace/FeatureServer/0/query?' +
new URLSearchParams({
where: "TYPE_CODE IN ('P','R')",
outFields: [
'NAME',
'TYPE_CODE',
'UPPER_VAL',
'UPPER_UOM',
'UPPER_CODE',
'LOWER_VAL',
'LOWER_UOM',
'LOWER_CODE',
'TIMESOFUSE',
'CONT_AGENT',
'CITY',
'STATE',
'OBJECTID',
].join(','),
maxAllowableOffset: '0.001',
outSR: '4326',
f: 'geojson',
}).toString();
const MAX_PAGES = 5;
const MIB = 1024 * 1024;
const SNAPSHOT_TTL_MS = 3_600_000;
/** Longest a failed refresh keeps serving the last snapshot, marked stale. */
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true ||
payload?.properties?.exceededTransferLimit === true;
/** Fixed-origin, bounded FAA SUA routes for dev and preview. */
export function droneAirspaceProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
const cache = new Map();
const inFlight = new Map();
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
async function upstream(url, cap, timeout) {
const signal = AbortSignal.timeout(timeout);
const response = await fetchImpl(url, {
signal,
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('upstream_unavailable');
}
return readResponseJsonCapped(response, cap, signal);
}
// A snapshot is accepted only once the service says it is complete. A page
// limit reached while still flagged, a flagged page that adds nothing, or
// no valid zone at all is a failed refresh, so the last good snapshot stays.
async function fetchZones() {
const features = [];
let complete = false;
for (let page = 0; page < MAX_PAGES; page++) {
const url =
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
const payload = await upstream(url, 8 * MIB, 30_000);
if (!Array.isArray(payload?.features))
throw new Error('invalid_snapshot');
features.push(...payload.features);
if (!exceededTransferLimit(payload)) {
complete = true;
break;
}
if (!payload.features.length) break;
}
if (!complete) throw new Error('incomplete_snapshot');
const rows = normalizeDroneAirspaceSnapshot({ features });
if (!rows?.length) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows };
}
async function acquire(key, ttl, load) {
const previous = cache.get(key);
if (previous && now() - previous.savedAt < ttl)
return { value: previous.value, stale: false };
if (!inFlight.has(key) && inFlight.size >= 256)
throw Object.assign(new Error('busy'), { status: 429 });
try {
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
const value = await load();
cache.set(key, { value, savedAt: now() });
return value;
});
return { value: await promise, stale: false };
} catch (error) {
// A failed refresh serves the last snapshot, marked stale, for a day.
if (previous && now() - previous.savedAt <= STALE_MAX_MS)
return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, stale = false) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(status === 429 ? { 'Retry-After': '60' } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
// Keyless and needed to render the layer: refuse cross-site browsers
// before they spend a client's quota or start a refresh, but keep the
// documented reverse-proxy deployment working.
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
if (path !== '/' && path !== '')
return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
try {
const { value, stale } = await acquire(
'zones',
SNAPSHOT_TTL_MS,
fetchZones,
);
json(200, stale ? { ...value, stale: true } : value, stale);
} catch (error) {
json(error.status === 429 ? 429 : 502, {
error: 'drone_airspace_unavailable',
});
}
}
return {
name: 'drone-airspace',
configureServer({ middlewares }) {
middlewares.use('/api/drone-airspace', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/drone-airspace', handler);
},
};
}
+5
View File
@@ -1,8 +1,13 @@
import { createGeminiChatHandler } from './gemini/chat.js';
import { createGeminiTokenHandler } from './gemini/realtime.js';
/** Mint constrained Live API credentials; the permanent key stays on Node. */
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
function install(middlewares) {
middlewares.use(
'/api/gemini/chat',
createGeminiChatHandler({ ...realtime, annotationGuidance }),
);
middlewares.use(
'/api/gemini/token',
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
+188
View File
@@ -0,0 +1,188 @@
import { createTextChatHandler } from '../common/textChat.js';
import { invalidCalls } from '../common/textTools.js';
import { textInstructions } from '../common/textInstructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
import { normalizeGeminiModel } from './config.js';
// The Gemini token route's throttle default (GEV_RATELIMIT_GEMINI_PER_MIN).
export const GEMINI_DEFAULT_PER_MIN = 30;
const object = (value) =>
value && typeof value === 'object' && !Array.isArray(value);
const keys = (value, allowed) =>
Object.keys(value).every((key) => allowed.includes(key));
// Only text and function exchanges may cross this route, never media or config.
export function validChatContents(contents, names) {
return (
Array.isArray(contents) &&
contents.length > 0 &&
contents.length <= 96 &&
contents.every(
(content) =>
object(content) &&
keys(content, ['role', 'parts']) &&
['user', 'model'].includes(content.role) &&
Array.isArray(content.parts) &&
content.parts.length > 0 &&
content.parts.length <= 32 &&
content.parts.every((part) => {
if (
!object(part) ||
!keys(part, [
'text',
'functionCall',
'functionResponse',
'thoughtSignature',
])
)
return false;
if (
part.thoughtSignature !== undefined &&
(typeof part.thoughtSignature !== 'string' ||
part.thoughtSignature.length > 32000 ||
content.role !== 'model')
)
return false;
if (Object.hasOwn(part, 'text'))
return (
keys(part, ['text', 'thoughtSignature']) &&
typeof part.text === 'string'
);
const call = part.functionCall ?? part.functionResponse;
if (
!object(call) ||
typeof call.name !== 'string' ||
call.name.length > 128 ||
(names && !names.has(call.name)) ||
(call.id !== undefined &&
(typeof call.id !== 'string' || call.id.length > 1024))
)
return false;
if (part.functionCall)
return (
content.role === 'model' &&
keys(part, ['functionCall', 'thoughtSignature']) &&
keys(call, ['name', 'args', 'id']) &&
(call.args === null || object(call.args ?? {}))
);
return (
content.role === 'user' &&
keys(part, ['functionResponse']) &&
keys(call, ['name', 'response', 'id']) &&
object(call.response)
);
}),
)
);
}
export function createGeminiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
// osm_query is offered, and the prompt names it, only with Overpass, as
// for voice.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
resolveChatModel = () =>
process.env.GEMINI_CHAT_MODEL || 'gemini-3.5-flash-lite',
resolveApiKey = () => process.env.GEMINI_API_KEY,
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN,
...options
} = {}) {
return createTextChatHandler({
...options,
provider: 'gemini',
resolveApiKey,
resolveProviderRateLimit,
providerPerMin: GEMINI_DEFAULT_PER_MIN,
prepare(input) {
const model = normalizeGeminiModel(resolveChatModel());
if (!model || /live|audio|tts/i.test(model)) return { errorStatus: 503 };
if (
!object(input) ||
!keys(input, ['contents']) ||
!validChatContents(input.contents, null)
)
return null;
if (!pairedContents(input.contents)) return null;
const overpass = overpassConfigured();
return {
endpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
headers: (key) => ({ 'x-goog-api-key': key }),
body: {
contents: input.contents,
systemInstruction: {
parts: [
{ text: textInstructions(annotationGuidance, { overpass }) },
],
},
tools: [
{
functionDeclarations: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
name,
description,
parametersJsonSchema: parameters,
}),
),
},
],
generationConfig: {
responseModalities: ['TEXT'],
maxOutputTokens: 4096,
thinkingConfig: { thinkingLevel: 'LOW' },
},
},
};
},
parse(data) {
const candidate = data?.candidates?.[0];
const content = candidate?.content;
if (
!content ||
!validChatContents([content], null) ||
content.role !== 'model'
)
return null;
const calls = content.parts
.filter((part) => part.functionCall)
.map((part) => part.functionCall);
return {
content,
finishReason: candidate.finishReason,
invalidCalls: invalidCalls(
calls,
offeredTools(tools, { overpass: overpassConfigured() }),
),
};
},
});
}
// Never forward an orphan result or an unfinished batch in browser history.
function pairedContents(contents) {
let pending = [];
for (const content of contents) {
const calls = content.parts
.filter((part) => part.functionCall)
.map((part) => part.functionCall);
const results = content.parts
.filter((part) => part.functionResponse)
.map((part) => part.functionResponse);
if (pending.length) {
if (
calls.length ||
results.length !== pending.length ||
results.some(
(result, i) =>
result.name !== pending[i].name || result.id !== pending[i].id,
)
)
return false;
pending = [];
} else if (results.length) return false;
if (calls.length) pending = calls;
}
return !pending.length;
}
+8 -4
View File
@@ -1,5 +1,5 @@
import { realtimeInstructions } from '../openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../openai/tools.js';
import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js';
export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live';
export const GEMINI_TOKEN_ENDPOINT =
@@ -14,22 +14,26 @@ export function normalizeGeminiModel(value) {
return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null;
}
/** REST BidiGenerateContentSetup fields, excluding its model resource name. */
/**
* REST BidiGenerateContentSetup fields, excluding its model resource name.
* `overpass` says an operator configured Overpass, which `osm_query` needs.
*/
export function createGeminiLiveConfig(
annotationGuidance,
inputMode = 'open-mic',
tools = GEV_REALTIME_TOOLS,
{ overpass = false } = {},
) {
if (!['open-mic', 'push-to-talk'].includes(inputMode))
throw new Error('Invalid Gemini input mode');
const config = {
generationConfig: { responseModalities: ['AUDIO'] },
systemInstruction: {
parts: [{ text: realtimeInstructions(annotationGuidance) }],
parts: [{ text: realtimeInstructions(annotationGuidance, { overpass }) }],
},
tools: [
{
functionDeclarations: tools.map(
functionDeclarations: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
name,
description,
+4
View File
@@ -12,6 +12,7 @@ import {
isGeminiRequestOriginAllowed,
readGeminiTokenRequest,
} from './request.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
@@ -19,6 +20,8 @@ const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
export function createGeminiTokenHandler({
annotationGuidance,
tools,
// osm_query is offered only when an operator configured Overpass.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
fetchImpl = (...args) => fetch(...args),
resolveApiKey = () => process.env.GEMINI_API_KEY,
resolveModel = () =>
@@ -104,6 +107,7 @@ export function createGeminiTokenHandler({
annotationGuidance,
inputMode,
tools,
{ overpass: overpassConfigured() },
);
const issuedAt = now();
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
+370
View File
@@ -0,0 +1,370 @@
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { normalizeInatObservations } from '../../../src/layers/wildlifeSightings/records.js';
import { readResponseJsonCapped } from '../common/http.js';
import { clampInt } from '../common/query.js';
import { providerUserAgent } from '../common/userAgent.js';
import { projectInatGrid, validInatGridTile } from './grid.js';
import { createInatQueue } from './queue.js';
const DAILY_BUDGET = 8000;
const DAY_MS = 24 * 60 * 60_000;
// One page is capped independently of upstream Content-Length.
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const TIMEOUT_MS = 20_000;
const API_URL = 'https://api.inaturalist.org/v1';
// Request only the fields used by the public observation projection.
const OBSERVATIONS_URL = 'https://api.inaturalist.org/v2/observations';
const OBSERVATION_FIELDS =
'(id:!t,uri:!t,location:!t,geojson:all,geoprivacy:!t,taxon_geoprivacy:!t,' +
'obscured:!t,quality_grade:!t,observed_on:!t,' +
'time_observed_at:!t,faves_count:!t,' +
'num_identification_agreements:!t,' +
'photos:(url:!t,attribution:!t,license_code:!t),' +
'taxon:(name:!t,preferred_common_name:!t,iconic_taxon_name:!t,' +
'threatened:!t))';
const COOLDOWN_MS = 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
/** Seconds and HTTP dates are accepted; a missing value earns one minute. */
export function inatRetryCooldownMs(raw, nowMs) {
const value = typeof raw === 'string' ? raw.trim() : '';
if (value) {
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
const deadline =
!Number.isFinite(seconds) && /[a-z]/i.test(value)
? Date.parse(value)
: NaN;
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
if (Number.isFinite(delay) && delay >= 0) return delay;
}
return COOLDOWN_MS;
}
function upstreamError(code, status, retryAfterS, upstreamStatus) {
return Object.assign(new Error(code), {
code,
status,
retryAfterS,
upstreamStatus,
});
}
function observationFilters(params) {
const query = new URLSearchParams({
verifiable: 'true',
captive: 'false',
// d1 is an observation DATE, unlike creation-time created_d1.
d1: params.observedD1,
});
if (params.taxa !== 'whales') query.set('photos', 'true');
if (params.quality === 'research') query.set('quality_grade', 'research');
if (params.taxa === 'whales') query.set('taxon_id', '152871');
else if (['Aves', 'Mammalia', 'Insecta', 'Plantae'].includes(params.taxa))
query.set('iconic_taxa', params.taxa);
return query;
}
/**
* Shared iNaturalist governor. Only request counters are persisted; observation
* payloads are projected immediately and are never written to disk.
*/
export function createInatClient({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cacheDir = path.join(process.cwd(), '.gev-cache', 'inaturalist'),
warn = (...args) => console.warn(...args),
queueOptions,
} = {}) {
const enqueue = createInatQueue(queueOptions);
let budget = null;
let budgetLoaded = false;
let budgetTail = Promise.resolve();
let cooldownUntil = 0;
// An unavailable UTFGrid tile must never place observation queries into a
// 502 cooldown. Only an upstream 429 is a provider-wide restriction.
const failures = new Map();
function dailyLimit() {
return clampInt(
process.env.INATURALIST_DAILY_BUDGET,
1,
Number.MAX_SAFE_INTEGER,
DAILY_BUDGET,
);
}
function day() {
return new Date(now()).toISOString().slice(0, 10);
}
function currentBudget() {
const date = day();
if (budget?.date !== date) budget = { date, count: 0 };
return budget;
}
async function readBudgetOnce() {
if (budgetLoaded) return;
budgetLoaded = true;
if (cacheDir === null) return;
try {
const value = JSON.parse(
await fs.readFile(path.join(cacheDir, 'budget.json'), 'utf8'),
);
if (
typeof value?.date === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(value.date) &&
Number.isSafeInteger(value.count) &&
value.count >= 0
)
budget = { date: value.date, count: value.count };
} catch (error) {
if (error.code !== 'ENOENT')
warn('[inaturalist-proxy] budget read failed');
}
}
async function persistBudget(value) {
if (cacheDir === null) return;
const destination = path.join(cacheDir, 'budget.json');
const temporary = `${destination}.${randomUUID()}.tmp`;
try {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(temporary, JSON.stringify(value), 'utf8');
await fs.rename(temporary, destination);
} catch {
await fs.rm(temporary, { force: true }).catch(() => {});
warn('[inaturalist-proxy] budget write failed');
}
}
function retrySeconds(until = cooldownUntil) {
return Math.max(0, Math.ceil((until - now()) / 1000));
}
function checkCooldown(resource) {
if (now() < cooldownUntil)
throw upstreamError('inaturalist_rate_limited', 429, retrySeconds(), 429);
const failure = failures.get(resource);
if (failure && now() < failure.until)
throw upstreamError(
'inaturalist_unavailable',
502,
retrySeconds(failure.until),
failure.status,
);
failures.delete(resource);
}
// Reserve and atomically persist one attempt before issuing it. A recovered
// tail prevents one disk/reservation failure from poisoning future calls.
function reserveAttempt(signal, resource) {
const result = budgetTail.then(async () => {
signal?.throwIfAborted();
checkCooldown(resource);
await readBudgetOnce();
signal?.throwIfAborted();
checkCooldown(resource);
const value = currentBudget();
if (value.count >= dailyLimit()) {
const retry = Math.max(
1,
Math.ceil((Date.parse(`${day()}T00:00:00Z`) + DAY_MS - now()) / 1000),
);
throw upstreamError('inaturalist_budget_exhausted', 503, retry);
}
value.count += 1;
await persistBudget({ ...value });
});
budgetTail = result.catch(() => {});
return result;
}
async function requestProjected(url, project, resource, { signal } = {}) {
signal?.throwIfAborted();
checkCooldown(resource);
return enqueue(() => dispatch(url, project, resource, signal), {
signal,
priority: resource === 'observations' ? 0 : 1,
});
}
async function dispatch(url, project, resource, signal) {
signal?.throwIfAborted();
await reserveAttempt(signal, resource);
signal?.throwIfAborted();
checkCooldown(resource);
const timeout = AbortSignal.timeout(TIMEOUT_MS);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
let upstreamStatus;
let retryDelay = FAILURE_COOLDOWN_MS;
try {
const response = await fetchImpl(url, {
signal: requestSignal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': providerUserAgent('wildlife-sightings'),
},
});
upstreamStatus = response.status;
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if (response.status === 429) {
const delay = inatRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
cooldownUntil = Math.max(cooldownUntil, now() + delay);
throw upstreamError(
'inaturalist_rate_limited',
429,
retrySeconds(),
429,
);
}
if (response.headers.has('retry-after'))
retryDelay = inatRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
throw upstreamError('inaturalist_unavailable', 502);
}
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
requestSignal,
);
return project(payload);
} catch (error) {
// Log only safe diagnostic categories, never a URL, body, status text or
// exception message (each can contain request/private information).
const reason = signal?.aborted
? 'abort'
: timeout.aborted || error.name === 'TimeoutError'
? 'timeout'
: upstreamStatus && upstreamStatus >= 400
? 'http'
: error.code === 'RESPONSE_TOO_LARGE'
? 'response-too-large'
: upstreamStatus
? 'invalid-payload'
: 'network';
warn(
`[inaturalist-proxy] upstream refresh failed resource=${resource} status=${upstreamStatus ?? 'none'} reason=${reason}`,
);
if (signal?.aborted) throw error;
// Network, abort, schema and size failures are not upstream cooldown
// signals. A later healthy request may recover immediately.
if (upstreamStatus >= 500)
failures.set(resource, {
until: now() + retryDelay,
status: upstreamStatus,
});
const failure = failures.get(resource);
throw upstreamError(
error.status === 429
? 'inaturalist_rate_limited'
: 'inaturalist_unavailable',
error.status === 429 ? 429 : 502,
error.status === 429
? retrySeconds()
: failure
? retrySeconds(failure.until)
: undefined,
upstreamStatus === 429 || upstreamStatus >= 500
? upstreamStatus
: undefined,
);
}
}
/** Request one bounded observation sample and project its public fields. */
async function getObservations(params, options = {}) {
const query = observationFilters(params);
// View samples stay stable in the route cache until their TTL expires.
query.set('order_by', params.scope === 'view' ? 'random' : 'observed_on');
if (params.scope !== 'view') query.set('order', 'desc');
query.set('per_page', '200');
query.set('fields', OBSERVATION_FIELDS);
if (params.scope === 'view') {
query.set('swlat', String(params.box.south));
query.set('swlng', String(params.box.west));
query.set('nelat', String(params.box.north));
query.set('nelng', String(params.box.east));
}
return requestProjected(
`${OBSERVATIONS_URL}?${query}`,
(payload) => {
if (
!Array.isArray(payload?.results) ||
!Number.isSafeInteger(payload.total_results) ||
payload.total_results < 0
)
throw upstreamError('inaturalist_unavailable', 502);
const rows = normalizeInatObservations(payload.results, {
nowMs: now(),
windowMs: (params.taxa === 'whales' ? 7 : 2) * DAY_MS,
});
// Empty views need total_results: 0. A wholly invalid page must not
// replace good data. Valid rows excluded by the finer rolling-time
// cutoff are a healthy empty result of the upstream DATE over-fetch.
if (
!rows ||
(!rows.length &&
(payload.results.length || payload.total_results) &&
!normalizeInatObservations(payload.results)?.length)
)
throw upstreamError('inaturalist_unavailable', 502);
return {
rows,
totalResults: payload.total_results,
...(payload.total_results > payload.results.length
? { partial: true, rawRowCount: payload.total_results }
: {}),
};
},
'observations',
options,
);
}
/** Decode one validated density tile without observation identifiers. */
async function getGrid(params, options = {}) {
if (!validInatGridTile(params)) throw new TypeError('Invalid grid tile');
const query = observationFilters(params);
return requestProjected(
`${API_URL}/grid/${params.z}/${params.x}/${params.y}.grid.json?${query}`,
(payload) => projectInatGrid(payload, params),
'grid',
options,
);
}
return {
getObservations,
getGrid,
/** Current provider-wide or resource-specific retry information. */
cooldownState(resource) {
if (now() < cooldownUntil)
return {
upstreamStatus: 429,
retryAfterS: retrySeconds(),
};
const failure = failures.get(resource);
if (failure && now() < failure.until)
return {
upstreamStatus: failure.status,
retryAfterS: retrySeconds(failure.until),
};
return undefined;
},
/** The current UTC day's reserved upstream attempts and configured limit. */
budgetState() {
const value = currentBudget();
return { ...value, budget: dailyLimit() };
},
};
}
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatClient } from './client.js';
import { syntheticGrid } from './grid.fixture.mjs';
const NOW = Date.parse('2026-11-04T18:05:00Z');
const observation = (id, time) => ({
id,
geojson: { coordinates: [-122.3, 47.6] },
quality_grade: 'research',
time_observed_at: time,
observed_on: time.slice(0, 10),
// Upload age does not determine whether an observation is recent.
created_at: '2026-11-04T18:00:00Z',
taxon: { name: 'Synthetic taxon', iconic_taxon_name: 'Aves' },
});
function clientFor(results) {
return createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn() {},
fetchImpl: async () =>
Response.json({ results, total_results: results.length }),
});
}
test('iNaturalist drops ancient and boundary-expired observations uploaded today', async () => {
const client = clientFor([
observation(1, '2010-11-04T18:00:00Z'),
observation(2, '2026-11-02T18:04:59Z'),
observation(3, '2026-11-02T18:05:00Z'),
]);
const result = await client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
});
assert.deepEqual(
result.rows.map((row) => row.stableId),
['inat:3'],
);
});
test('a valid date-filtered page can be empty after the exact recency cutoff', async () => {
const client = clientFor([observation(1, '2026-11-02T06:00:00Z')]);
const result = await client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
});
assert.deepEqual(result.rows, []);
assert.equal(result.totalResults, 1);
});
const params = {
scope: 'view',
box: { south: 47, west: -123, north: 48, east: -122 },
taxa: 'all',
observedD1: '2026-11-02',
};
test('observations and tiles share pacing, and observation priority wins a cold burst', async () => {
let at = 0;
const calls = [];
const client = createInatClient({
cacheDir: null,
now: () => NOW + at,
queueOptions: {
now: () => at,
wait: async (ms) => {
at += ms;
},
},
fetchImpl: async (url) => {
const isGrid = new URL(url).pathname.includes('/grid/');
calls.push([isGrid ? 'grid' : 'observations', at]);
return Response.json(
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
);
},
});
const tiles = Array.from({ length: 3 }, (_, x) =>
client.getGrid({ ...params, z: 2, x, y: 1 }),
);
await Promise.all([...tiles, client.getObservations(params)]);
assert.deepEqual(calls, [
['observations', 0],
['grid', 0],
['grid', 0],
['grid', 0],
]);
assert.equal(client.budgetState().count, 4);
});
test('upstream status and safe failure reasons are logged without upstream messages or URLs', async () => {
for (const [response, expected] of [
[
() =>
new Response('request-secret', {
status: 503,
statusText: 'request-secret',
}),
/status=503 reason=http/,
],
[
() => Promise.reject(new DOMException('request-secret', 'TimeoutError')),
/status=none reason=timeout/,
],
[
() => Response.json({ unexpected: 'request-secret' }),
/status=200 reason=invalid-payload/,
],
]) {
const warnings = [];
const client = createInatClient({
cacheDir: null,
now: () => NOW,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: response,
});
await assert.rejects(client.getObservations(params), { status: 502 });
assert.equal(warnings.length, 1);
assert.match(warnings[0], expected);
assert.doesNotMatch(warnings[0], /request-secret|https?:|swlat|d1=/);
}
});
test('a dispatched abort is diagnosed, spends only its attempt, and leaves the next request healthy', async () => {
const warnings = [];
const controller = new AbortController();
let started;
const didStart = new Promise((resolve) => {
started = resolve;
});
let calls = 0;
const client = createInatClient({
cacheDir: null,
now: () => NOW,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: async (url, { signal }) => {
calls += 1;
if (calls > 1) return Response.json({ results: [], total_results: 0 });
started();
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = client.getObservations(params, { signal: controller.signal });
const failed = assert.rejects(pending, { name: 'AbortError' });
await didStart;
controller.abort();
await failed;
assert.match(warnings[0], /resource=observations status=none reason=abort/);
await client.getObservations(params);
assert.equal(calls, 2);
});
@@ -0,0 +1,51 @@
/** Build a synthetic UTFGrid with logical cell counts and repeated hit pixels. */
export function syntheticGrid({
z = 2,
x = 1,
y = 1,
cells = [
{ row: 1, column: 51, count: 6, iconic: 3 },
{ row: 3, column: 49, count: 12, iconic: 47170 },
],
} = {}) {
const pixels = Array.from({ length: 64 }, () => Array(64).fill(' '));
const keys = [''];
const data = {};
cells.forEach((cell, index) => {
const key = String(900_000_000 + index);
keys.push(key);
const value = index + 1;
let code = value + 32;
if (code >= 34) code += 1;
if (code >= 92) code += 1;
const character = String.fromCharCode(code);
for (const [row, column] of [
[cell.row, cell.column],
[cell.row - 1, cell.column],
[cell.row + 1, cell.column],
[cell.row, cell.column - 1],
[cell.row, cell.column + 1],
]) {
if (row >= 0 && row < 64 && column >= 0 && column < 64)
pixels[row][column] = character;
}
data[key] = {
captive: null,
cellCount: cell.count,
geoprivacy: null,
id: Number(key),
latitude:
(Math.atan(
Math.sinh(Math.PI * (1 - 2 * ((y + cell.row / 64) / 2 ** z))),
) *
180) /
Math.PI,
longitude: ((x + cell.column / 64) / 2 ** z) * 360 - 180,
private_location: null,
quality_grade: 'needs_id',
'taxon.iconic_taxon_id': cell.iconic,
uuid: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
};
});
return { grid: pixels.map((row) => row.join('')), keys, data };
}
+127
View File
@@ -0,0 +1,127 @@
// iNaturalist's operational /v1/grid/{z}/{x}/{y}.grid.json endpoint is not
// covered by its v1 Swagger contract. Validate the entire UTFGrid shape before
// accepting any cell; callers preserve the last good sample on schema drift.
import {
wildlifeGridCellId,
WILDLIFE_GRID_MAX_ZOOM,
} from '../../../src/layers/wildlifeSightings/gridViewport.js';
const INAT_GRID_SIZE = 64;
export const INAT_GRID_DECODE_MEASURE = 'wildlife:server:tile-decode';
const grades = new Set(['research', 'needs_id', 'casual']);
const object = (value) =>
!!value && typeof value === 'object' && !Array.isArray(value);
/** Accept only integer XYZ coordinates within the supported zoom range. */
export function validInatGridTile({ z, x, y } = {}) {
return (
Number.isInteger(z) &&
z >= 0 &&
z <= WILDLIFE_GRID_MAX_ZOOM &&
Number.isInteger(x) &&
Number.isInteger(y) &&
x >= 0 &&
y >= 0 &&
x < 2 ** z &&
y < 2 ** z
);
}
/** Pure allow-list projection. A UTFGrid key is an observation identifier and
* its repeated pixels form a dilated hit area, not additional occupied cells.
* Neither those keys nor representative/private fields cross this boundary. */
export function projectInatGrid(payload, tile) {
const start = `${INAT_GRID_DECODE_MEASURE}:start`;
const end = `${INAT_GRID_DECODE_MEASURE}:end`;
performance.mark(start);
try {
return decodeInatGrid(payload, tile);
} finally {
performance.mark(end);
// Keep the latest sample inspectable without retaining every tile forever.
// PerformanceObservers still receive each individual decode measurement.
performance.clearMeasures(INAT_GRID_DECODE_MEASURE);
performance.measure(INAT_GRID_DECODE_MEASURE, start, end);
performance.clearMarks(start);
performance.clearMarks(end);
}
}
function decodeInatGrid(payload, tile) {
const invalid = () => {
throw new TypeError('Invalid iNaturalist UTFGrid');
};
if (
!validInatGridTile(tile) ||
!object(payload) ||
!Array.isArray(payload.grid) ||
payload.grid.length !== INAT_GRID_SIZE ||
!payload.grid.every(
(row) => typeof row === 'string' && row.length === INAT_GRID_SIZE,
) ||
!Array.isArray(payload.keys) ||
!payload.keys.length ||
payload.keys.length > INAT_GRID_SIZE ** 2 + 1 ||
payload.keys[0] !== '' ||
!payload.keys.every(
(key) => typeof key === 'string' && key.length <= 128,
) ||
new Set(payload.keys).size !== payload.keys.length ||
!object(payload.data)
)
invalid();
const dataKeys = Object.keys(payload.data);
if (dataKeys.length !== payload.keys.length - 1) invalid();
for (const key of payload.keys.slice(1)) {
if (!key || !Object.hasOwn(payload.data, key)) invalid();
const value = payload.data[key];
if (
!object(value) ||
!Number.isSafeInteger(value.cellCount) ||
value.cellCount <= 0 ||
!Number.isFinite(value.latitude) ||
Math.abs(value.latitude) > 90 ||
!Number.isFinite(value.longitude) ||
Math.abs(value.longitude) > 180 ||
!grades.has(value.quality_grade) ||
!(
value['taxon.iconic_taxon_id'] === null ||
(Number.isSafeInteger(value['taxon.iconic_taxon_id']) &&
value['taxon.iconic_taxon_id'] > 0)
)
)
invalid();
}
const occupied = new Set();
for (const line of payload.grid) {
for (let column = 0; column < INAT_GRID_SIZE; column += 1) {
let code = line.charCodeAt(column);
if (code < 32 || code === 34 || code === 92) invalid();
if (code >= 93) code -= 1;
if (code >= 35) code -= 1;
const index = code - 32;
if (index < 0 || index >= payload.keys.length) invalid();
if (index) occupied.add(payload.keys[index]);
}
}
if (occupied.size !== dataKeys.length) invalid();
const cells = new Map();
for (const key of occupied) {
const value = payload.data[key];
const id = wildlifeGridCellId(value.latitude, value.longitude, tile.z);
const cell = {
id,
cellCount: value.cellCount,
lat: value.latitude,
lon: value.longitude,
};
// Two different representatives claiming one logical cell are a contract
// change; summing or choosing one silently would fabricate the count.
if (cells.has(id)) invalid();
cells.set(id, cell);
}
const rows = [...cells.values()];
const totalResults = rows.reduce((total, cell) => total + cell.cellCount, 0);
if (!Number.isSafeInteger(totalResults)) invalid();
return { cells: rows, totalResults };
}
+374
View File
@@ -0,0 +1,374 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { projectInatGrid } from './grid.js';
import { syntheticGrid } from './grid.fixture.mjs';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { createInatClient } from './client.js';
const tile = { z: 2, x: 1, y: 1 };
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves&quality=research';
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
test('UTFGrid projection validates the full shape and counts each logical occupied cell once', () => {
const raw = syntheticGrid();
const projected = projectInatGrid(raw, tile);
assert.equal(raw.grid.length, 64);
assert.ok(raw.grid.every((row) => row.length === 64));
assert.equal(
raw.grid.join('').split('!').length - 1,
5,
'one key is repeated by its hit area',
);
assert.equal(projected.cells.length, 2);
assert.equal(projected.totalResults, 18);
assert.deepEqual(projected.cells[0], {
id: 'grid:2/1/1:1:51',
cellCount: 6,
lat: raw.data[raw.keys[1]].latitude,
lon: raw.data[raw.keys[1]].longitude,
});
for (const cell of projected.cells)
assert.deepEqual(Object.keys(cell).sort(), [
'cellCount',
'id',
'lat',
'lon',
]);
for (const privateField of [
'900000000',
'uuid',
'geoprivacy',
'private_location',
'captive',
'quality_grade',
])
assert.ok(!JSON.stringify(projected).includes(privateField));
});
test('legitimate blank grid and null/unknown iconic ids remain safe', () => {
assert.deepEqual(projectInatGrid(syntheticGrid({ cells: [] }), tile), {
cells: [],
totalResults: 0,
});
const raw = syntheticGrid({
cells: [{ row: 1, column: 1, count: 1, iconic: null }],
});
const projected = projectInatGrid(raw, tile);
assert.equal(projected.cells[0].cellCount, 1);
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 999999;
assert.deepEqual(projectInatGrid(raw, tile), projected);
});
test('schema drift anywhere fails closed, including unused, malformed and orphan cells', () => {
const changes = [
(raw) => {
raw.grid.pop();
},
(raw) => {
raw.grid[0] = '';
},
(raw) => {
raw.grid[0] = '~'.repeat(64);
},
(raw) => {
raw.keys[0] = 'not-empty';
},
(raw) => {
raw.keys.push(raw.keys[1]);
},
(raw) => {
delete raw.data[raw.keys[1]];
},
(raw) => {
raw.data.extra = {};
},
(raw) => {
raw.data[raw.keys[1]].cellCount = -1;
},
(raw) => {
raw.data[raw.keys[1]].latitude = '47';
},
(raw) => {
raw.data[raw.keys[1]].longitude = 181;
},
(raw) => {
raw.data[raw.keys[1]].quality_grade = 'new-grade';
},
(raw) => {
delete raw.data[raw.keys[1]]['taxon.iconic_taxon_id'];
},
(raw) => {
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 'Aves';
},
(raw) => {
raw.grid = Array(64).fill(' '.repeat(64));
},
];
for (const change of changes) {
const raw = syntheticGrid();
change(raw);
assert.throws(
() => projectInatGrid(raw, tile),
/Invalid iNaturalist UTFGrid/,
);
}
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: fixed grid URL shares filters, caps and allow-list cache`, async () => {
const calls = [];
const cache = new Map();
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.match(
options.headers['User-Agent'],
/^gods-eye-view-wildlife-sightings-proxy/,
);
return Response.json(syntheticGrid());
},
},
hook,
);
assert.equal(calls.length, 0);
const response = await request(
`${gridUrl}&url=https://attacker.test&per_page=999`,
);
assert.equal(response.status, 200);
assert.equal(response.body.cells.length, 2);
assert.equal(response.body.fetchedAt, NOW);
assert.equal(response.headers['Cache-Control'], 'no-store');
assert.equal(
calls[0].origin + calls[0].pathname,
'https://api.inaturalist.org/v1/grid/2/1/1.grid.json',
);
assert.deepEqual(Object.fromEntries(calls[0].searchParams), {
verifiable: 'true',
captive: 'false',
d1: '2026-11-02',
photos: 'true',
quality_grade: 'research',
iconic_taxa: 'Aves',
});
await request(gridUrl);
assert.equal(calls.length, 1, 'fresh stable tile/filter key is cached');
assert.ok(!JSON.stringify([...cache]).includes('private_location'));
assert.ok(!JSON.stringify([...cache]).includes('900000000'));
const whale = await request('/grid?z=2&x=1&y=1&taxa=whales&confirmed=true');
assert.equal(whale.status, 200);
assert.equal(calls[1].searchParams.get('taxon_id'), '152871');
assert.equal(calls[1].searchParams.get('d1'), '2026-10-28');
assert.equal(calls[1].searchParams.has('photos'), false);
});
}
test('grid admission rejects cross-site/method/path/tile before acquisition', async () => {
const request = install({
fetchImpl() {
assert.fail('invalid grid request acquired');
},
});
assert.equal(
(
await request(gridUrl, {
headers: {
host: 'localhost:5173',
origin: 'https://other.test',
'sec-fetch-site': 'cross-site',
},
})
).status,
403,
);
assert.equal((await request('/grid', { method: 'POST' })).status, 405);
assert.equal((await request('/grid/extra')).status, 404);
for (const query of [
'',
'z=2&x=4&y=0',
'z=-1&x=0&y=0',
'z=23&x=0&y=0',
'z=2&x=1.5&y=0',
'z=2&x=&y=0',
'z=2&x=0&y=4',
]) {
const result = await request(`/grid?${query}`);
assert.equal(result.status, 400, query);
assert.equal(result.body.error, 'invalid_tile');
}
});
test('grid refresh single-flight, stale fallback and hour-key expiry keep the original age', async () => {
let time = NOW;
let calls = 0;
let resolve;
let mode = 'pending';
const request = install({
now: () => time,
fetchImpl: async () => {
calls += 1;
if (mode === 'pending')
return new Promise((done) => {
resolve = done;
});
if (mode === 'bad') return Response.json({ grid: [] });
return Response.json(syntheticGrid());
},
});
const first = request();
const second = request();
for (let i = 0; i < 100 && !resolve; i += 1) await new Promise(setImmediate);
assert.equal(calls, 1);
resolve(Response.json(syntheticGrid()));
assert.equal((await first).status, 200);
assert.equal((await second).status, 200);
time += 900_001;
mode = 'bad';
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, NOW);
assert.equal(stale.headers['X-Data-Stale'], 'true');
time = NOW + 3_600_001;
const expired = await request();
assert.equal(expired.status, 502);
assert.equal(expired.body.error, 'inaturalist_unavailable');
});
test('grid and observations share the upstream cooldown and daily reservations', async () => {
let calls = 0;
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn() {},
fetchImpl: async () => {
calls += 1;
return new Response('', {
status: 429,
headers: { 'Retry-After': '120' },
});
},
});
await assert.rejects(
client.getGrid({ ...tile, taxa: 'all', observedD1: '2026-11-02' }),
(error) => error.status === 429 && error.retryAfterS === 120,
);
await assert.rejects(
client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
}),
(error) => error.status === 429,
);
assert.equal(calls, 1);
assert.equal(client.budgetState().count, 1);
});
test('grid body cap and upstream failures return fixed errors; only real 5xx cools down', async () => {
for (const [response, retryAfter] of [
[() => new Response('x'.repeat(8 * 1024 * 1024 + 1)), undefined],
[() => new Response('', { status: 503 }), '15'],
]) {
const request = install({ fetchImpl: async () => response() });
const result = await request();
assert.equal(result.status, 502);
assert.deepEqual(result.body, { error: 'inaturalist_unavailable' });
assert.equal(result.headers['Retry-After'], retryAfter);
}
});
test('a failed tile preserves last good cells and cannot send unrelated observations into 502', async () => {
let at = NOW;
let failGrid = false;
const calls = [];
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: async (url) => {
const isGrid = new URL(url).pathname.includes('/grid/');
calls.push(isGrid ? 'grid' : 'observations');
if (isGrid && failGrid)
return new Response('private upstream text', {
status: 503,
headers: { 'Retry-After': '45' },
});
return Response.json(
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
);
},
});
const first = await request();
at += 900_001;
failGrid = true;
const failed = await request();
assert.equal(failed.status, 200);
assert.equal(failed.headers['X-Data-Stale'], 'true');
assert.equal(failed.body.stale, true);
assert.equal(failed.body.fetchedAt, first.body.fetchedAt);
assert.deepEqual(failed.body.cells, first.body.cells);
assert.equal(
(await request('/observations?scope=global&taxa=whales')).status,
200,
);
assert.deepEqual(calls, ['grid', 'grid', 'observations']);
assert.match(warnings[0], /resource=grid status=503 reason=http/);
assert.doesNotMatch(warnings[0], /private upstream text/);
// The tile cooldown also preserves its data without another upstream try.
const cooling = await request();
assert.equal(cooling.body.stale, true);
assert.equal(cooling.headers['Retry-After'], '45');
assert.equal(cooling.headers['X-Upstream-Status'], '503');
assert.equal(calls.length, 3);
at += 45_000;
failGrid = false;
const refreshing = await request();
assert.equal(refreshing.body.stale, true);
await new Promise(setImmediate);
const recovered = await request();
assert.equal(recovered.status, 200);
assert.equal(recovered.body.stale, undefined);
assert.equal(
calls.length,
4,
'a failed queue task does not poison later tile work',
);
});
test('twelve queued tiles leave observation capacity and give the next dispatch to observations', async () => {
const calls = [];
let release;
const request = install({
fetchImpl: (url) => {
const path = new URL(url).pathname;
calls.push(path);
if (calls.length === 1)
return new Promise((resolve) => {
release = resolve;
});
return Response.json(
path.includes('/grid/')
? syntheticGrid()
: { results: [], total_results: 0 },
);
},
});
const first = request('/grid?z=4&x=0&y=1');
for (let i = 0; i < 100 && !release; i += 1) await new Promise(setImmediate);
assert.ok(release);
const tiles = Array.from({ length: 11 }, (_, i) =>
request(`/grid?z=4&x=${i + 1}&y=1`),
);
const observation = request('/observations?scope=global');
const queuedTile = request('/grid?z=4&x=12&y=1');
release(Response.json(syntheticGrid()));
const results = await Promise.all([first, observation, ...tiles, queuedTile]);
assert.ok(results.every((result) => result.status === 200));
assert.equal(calls.length, 14);
assert.equal(calls[1], '/v2/observations');
});
@@ -0,0 +1,343 @@
import { createInatClient } from './client.js';
import { coalesceProxyRequest } from '../common/http.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
import { snapWildlifeBox } from '../../../src/layers/wildlifeSightings/bounds.js';
import { validInatGridTile } from './grid.js';
// The upstream advertises five-minute freshness; global samples cost less often.
const TTL_MS = 300_000;
const GLOBAL_TTL_MS = 600_000;
const INAT_GRID_CACHE_TTL_MS = 15 * 60_000;
const STALE_MAX_MS = 3_600_000;
const INAT_CACHE_MAX = 400;
const HOUR_MS = 3_600_000;
const TAXA = Object.freeze([
'all',
'Aves',
'Mammalia',
'Insecta',
'Plantae',
'whales',
]);
function requestParams(pathname, query, nowMs) {
const grid = pathname === '/grid';
const scope = grid ? 'grid' : (query.get('scope') ?? 'view');
if (!grid && scope !== 'view' && scope !== 'global')
return { error: 'invalid_scope' };
const quality =
query.get('quality') === 'research' ||
(grid && query.get('confirmed') === 'true')
? 'research'
: 'all';
const taxa = TAXA.includes(query.get('taxa')) ? query.get('taxa') : 'all';
let box = null;
let tile;
if (grid) {
tile = Object.fromEntries(
['z', 'x', 'y'].map((name) => [
name,
requiredFiniteQueryNumber(query, name),
]),
);
if (!validInatGridTile(tile)) return { error: 'invalid_tile' };
} else if (scope === 'view') {
const south = requiredFiniteQueryNumber(query, 'south');
const west = requiredFiniteQueryNumber(query, 'west');
const north = requiredFiniteQueryNumber(query, 'north');
const east = requiredFiniteQueryNumber(query, 'east');
box = snapWildlifeBox({ south, west, north, east });
if (!box) return { error: 'invalid_bbox' };
}
const windowMs = (taxa === 'whales' ? 7 * 24 : 48) * HOUR_MS;
const windowKey = new Date(Math.floor((nowMs - windowMs) / HOUR_MS) * HOUR_MS)
.toISOString()
.replace('.000Z', 'Z');
const bounds = box ? `${box.south},${box.west},${box.north},${box.east}` : '';
const baseKey = grid
? `grid:v1:${tile.z}/${tile.x}/${tile.y}:${quality}:${taxa}`
: `v1:${scope}:${bounds}:${quality}:${taxa}`;
return {
scope,
...(grid
? { ...tile, waitForFresh: query.get('refresh') === 'wait' }
: { box }),
quality,
taxa,
observedD1: windowKey.slice(0, 10),
baseKey,
key: `${baseKey}:${windowKey}`,
};
}
/** A shared-provider route for dev and preview; acquisition starts on demand. */
export function inaturalistProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cacheDir,
cache = new Map(),
warn = (...args) => console.warn(...args),
queueOptions,
} = {}) {
const inFlight = new Map();
const consumers = new Map();
let client = null;
function age(entry) {
if (!entry) return Infinity;
const elapsed = now() - entry.savedAt;
return elapsed < 0 ? Infinity : elapsed;
}
function joinFlight(promise, group, signal) {
group.count += 1;
return new Promise((resolve, reject) => {
let released = false;
function release() {
if (released) return;
released = true;
signal?.removeEventListener('abort', abort);
group.count -= 1;
if (!group.count && !group.background && !group.settled) {
group.controller.abort();
// A fresh consumer must not join an abandoned queued generation.
if (inFlight.get(group.key) === promise) inFlight.delete(group.key);
if (consumers.get(group.key) === group) consumers.delete(group.key);
}
}
function abort() {
release();
reject(signal.reason);
}
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
promise.then(
(value) => {
release();
resolve(value);
},
(error) => {
release();
reject(error);
},
);
});
}
function refresh(params, background = false) {
// Global samples share pending refreshes across hourly window changes.
const flightKey = params.scope === 'global' ? params.baseKey : params.key;
let group = consumers.get(flightKey);
if (!group) {
group = {
key: flightKey,
controller: new AbortController(),
count: 0,
settled: false,
background,
};
consumers.set(flightKey, group);
}
group.background ||= background;
const { promise, shared } = coalesceProxyRequest(
inFlight,
flightKey,
async () => {
client ??= createInatClient({
fetchImpl,
now,
cacheDir,
warn,
queueOptions,
});
const options = { signal: group.controller.signal };
const result = await (params.scope === 'grid'
? client.getGrid(params, options)
: client.getObservations(params, options));
const value = {
fetchedAt: now(),
...(params.scope === 'grid'
? {}
: { scope: params.scope, box: params.box }),
...result,
};
cache.delete(params.key);
cache.set(params.key, {
value,
savedAt: now(),
baseKey: params.baseKey,
});
while (cache.size > INAT_CACHE_MAX)
cache.delete(cache.keys().next().value);
return value;
},
);
if (!shared) {
const settle = () => {
group.settled = true;
if (consumers.get(flightKey) === group) consumers.delete(flightKey);
};
void promise.then(settle, settle);
}
return { promise, group };
}
async function acquire(params, signal) {
signal?.throwIfAborted();
for (const [key, entry] of cache) {
if (age(entry) > STALE_MAX_MS) cache.delete(key);
}
const current = cache.get(params.key);
const ttl =
params.scope === 'grid'
? INAT_GRID_CACHE_TTL_MS
: params.scope === 'global'
? GLOBAL_TTL_MS
: TTL_MS;
if (current && age(current) < ttl) {
cache.delete(params.key);
cache.set(params.key, current);
return { value: current.value, stale: false };
}
// Window-hour rollover can reuse prior cells without claiming a new age.
let previous = current;
let previousKey = params.key;
if (!previous) {
for (const [key, entry] of cache) {
if (
entry.baseKey === params.baseKey &&
(!previous || entry.savedAt > previous.savedAt)
) {
previous = entry;
previousKey = key;
}
}
}
if (params.scope === 'global' && age(previous) < GLOBAL_TTL_MS)
return { value: previous.value, stale: false };
if (
params.scope === 'grid' &&
!params.waitForFresh &&
age(previous) <= STALE_MAX_MS
) {
cache.delete(previousKey);
cache.set(previousKey, previous);
// Retain one background consumer after this HTTP response closes. A stale
// tile is useful immediately; waiting for the token queue defeats SWR.
const cooling = client?.cooldownState('grid');
if (!cooling) {
const { promise } = refresh(params, true);
void promise.catch(() => {});
}
return {
value: previous.value,
stale: true,
...cooling,
};
}
try {
const { promise, group } = refresh(params);
return { value: await joinFlight(promise, group, signal), stale: false };
} catch (error) {
if (signal?.aborted) throw error;
if (age(previous) <= STALE_MAX_MS)
return {
value: previous.value,
stale: true,
retryAfterS: error.retryAfterS,
upstreamStatus: error.upstreamStatus,
};
throw error;
}
}
async function handler(req, res) {
const json = (
status,
value,
{ stale = false, retryAfterS, upstreamStatus } = {},
) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(Number.isFinite(retryAfterS)
? { 'Retry-After': String(retryAfterS) }
: {}),
...(upstreamStatus
? { 'X-Upstream-Status': String(upstreamStatus) }
: {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const url = String(req.url || '/');
const queryAt = url.indexOf('?');
const pathname = queryAt < 0 ? url : url.slice(0, queryAt);
if (pathname !== '/observations' && pathname !== '/grid')
return json(404, { error: 'unknown_route' });
const params = requestParams(
pathname,
new URLSearchParams(queryAt < 0 ? '' : url.slice(queryAt + 1)),
now(),
);
if (params.error) return json(400, { error: params.error });
const controller = new AbortController();
const abort = () => controller.abort();
// IncomingMessage.close also fires after a complete request body. The
// response close identifies the abandoned browser fetch, while aborted
// covers a request cut short before it reached the response stage.
req.once?.('aborted', abort);
res.once?.('close', abort);
try {
const { value, stale, retryAfterS, upstreamStatus } = await acquire(
params,
controller.signal,
);
return json(200, stale ? { ...value, stale: true } : value, {
stale,
retryAfterS,
upstreamStatus,
});
} catch (error) {
if (controller.signal.aborted) return;
const status =
error.code === 'inaturalist_budget_exhausted'
? 503
: error.status === 429
? 429
: 502;
const code =
error.code === 'inaturalist_budget_exhausted'
? error.code
: status === 429
? 'inaturalist_rate_limited'
: 'inaturalist_unavailable';
return json(
status,
{ error: code },
{
retryAfterS: error.retryAfterS,
upstreamStatus: error.upstreamStatus,
},
);
} finally {
req.removeListener?.('aborted', abort);
res.removeListener?.('close', abort);
}
}
return {
name: 'inaturalist',
configureServer({ middlewares }) {
middlewares.use('/api/inaturalist', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/inaturalist', handler);
},
};
}
@@ -0,0 +1,893 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { createInatClient, inatRetryCooldownMs } from './client.js';
const observation = (id = 1) => ({
id,
uri: `https://www.inaturalist.org/observations/${id}`,
geojson: { coordinates: [-122.351, 47.613] },
time_observed_at: '2026-11-04T17:00:00Z',
observed_on: '2026-11-04',
created_at: '2026-11-04T17:01:00Z',
quality_grade: 'research',
license_code: 'cc-by',
positional_accuracy: 12,
faves_count: 2,
num_identification_agreements: 3,
taxon: {
name: 'Calypte anna',
preferred_common_name: "Anna's hummingbird",
iconic_taxon_name: 'Aves',
},
photos: [
{
url: 'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
license_code: 'cc-by',
attribution: 'Synthetic photographer, CC BY',
},
],
user: { login: 'must-never-leave-server' },
});
const envelope = (rows = [observation()], total = rows.length) => ({
total_results: total,
page: 1,
per_page: 200,
results: rows,
});
const boxUrl = (index = 0, extra = '') =>
`/observations?south=47.51&west=${-122.49 + index * 0.2}&north=47.69&east=${-122.31 + index * 0.2}${extra}`;
function install(options = {}, hook = 'configureServer') {
return installInatProxy(
boxUrl(),
{
// Serial dispatch makes queued cancellation and coalescing observable.
queueOptions: { intervalMs: 0, windowMs: 0, maxInFlight: 1 },
...options,
},
hook,
);
}
async function until(check) {
for (let i = 0; i < 100; i += 1) {
if (check()) return;
await new Promise(setImmediate);
}
assert.fail('expected fake upstream calls were not dispatched');
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: actual v2 envelope, fixed query, projection and privacy`, async () => {
const calls = [];
const cache = new Map();
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.headers.Accept, 'application/json');
assert.equal(
options.headers['User-Agent'],
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
);
return Response.json(envelope());
},
},
hook,
);
assert.equal(calls.length, 0, 'construction and mounting do not acquire');
const result = await request(
boxUrl(0, '&url=https://attacker.example&per_page=999'),
);
assert.equal(result.status, 200);
assert.equal(result.headers['Cache-Control'], 'no-store');
assert.equal(result.body.rows[0].stableId, 'inat:1');
assert.equal(result.body.fetchedAt, NOW);
assert.equal(result.body.scope, 'view');
assert.deepEqual(result.body.box, {
south: 47.5,
west: -122.5,
north: 47.7,
east: -122.3,
});
const query = calls[0].searchParams;
assert.equal(
calls[0].origin + calls[0].pathname,
'https://api.inaturalist.org/v2/observations',
);
// Only the projected fields are requested, never user or identification data.
assert.match(query.get('fields'), /^\(id:!t,uri:!t,.*photos:\(url:!t/);
assert.doesNotMatch(query.get('fields'), /user|identifications/);
assert.equal(query.get('swlat'), '47.5');
assert.equal(query.get('swlng'), '-122.5');
assert.equal(query.get('nelat'), '47.7');
assert.equal(query.get('nelng'), '-122.3');
assert.equal(query.get('d1'), '2026-11-02');
assert.equal(query.has('created_d1'), false);
assert.equal(query.get('per_page'), '200');
assert.equal(query.get('verifiable'), 'true');
assert.equal(query.get('photos'), 'true');
assert.equal(query.get('captive'), 'false');
assert.equal(query.get('order_by'), 'random');
assert.equal(query.has('order'), false);
assert.doesNotMatch(
JSON.stringify(result.body),
/must-never-leave-server|"user"/,
);
assert.doesNotMatch(
JSON.stringify([...cache.values()]),
/must-never-leave-server|"user"/,
);
});
}
test('gate, method, route and query validation run before quota, cache or upstream', async () => {
const cache = new Map();
let calls = 0;
const request = install({
cache,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 32; i += 1) {
const response = await request('/observations?scope=global', {
headers: {
host: 'localhost:5173',
origin: 'https://foreign.example',
'sec-fetch-site': 'cross-site',
},
});
assert.equal(response.status, 403);
}
const method = await request('/observations', { method: 'POST' });
assert.equal(method.status, 405);
assert.equal(method.headers.Allow, 'GET');
for (const route of ['/', '/unknown', '/observations/extra', '//[invalid'])
assert.equal((await request(route)).body.error, 'unknown_route');
assert.equal(
(await request('/observations?scope=planet')).body.error,
'invalid_scope',
);
for (const query of [
'',
'south=NaN&west=0&north=1&east=1',
'south=&west=0&north=1&east=1',
'south=0&west=0&north=0&east=1',
'south=0&west=1&north=1&east=0',
'south=-91&west=0&north=1&east=1',
'south=0&west=-181&north=1&east=1',
'south=0&west=0&north=41&east=1',
'south=0&west=0&north=1&east=41',
]) {
const result = await request(`/observations?${query}`);
assert.equal(result.status, 400, query);
assert.equal(result.body.error, 'invalid_bbox', query);
}
assert.equal(cache.size, 0);
assert.equal(calls, 0);
const allowed = await request('/observations?scope=global', {
headers: {
host: 'gev.example',
origin: 'https://gev.example',
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
});
assert.equal(allowed.status, 200);
});
test('cached requests never exhaust a local request admission limiter', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 30; i += 1) assert.equal((await request()).status, 200);
const cached = await request();
assert.equal(cached.status, 200);
assert.equal(cached.headers['Retry-After'], undefined);
assert.equal(calls, 1);
});
test('proxy rejects oversized snapped spans before acquisition and preserves canonical client boxes', async () => {
const urls = [];
const request = install({
fetchImpl: async (url) => {
urls.push(new URL(url));
return Response.json(envelope());
},
});
for (const query of [
'south=10.2&west=0&north=50.1&east=1',
'south=0&west=10.2&north=1&east=50.1',
]) {
const result = await request(`/observations?${query}`);
assert.equal(result.status, 400);
assert.equal(result.body.error, 'invalid_bbox');
}
assert.equal(urls.length, 0);
const canonical =
'/observations?south=47.4&west=-122.3&north=47.6&east=-122.1';
const first = await request(canonical);
assert.equal(first.status, 200);
assert.deepEqual(first.body.box, {
south: 47.4,
west: -122.3,
north: 47.6,
east: -122.1,
});
assert.equal(urls[0].searchParams.get('swlat'), '47.4');
assert.equal(urls[0].searchParams.get('swlng'), '-122.3');
const second = await request(canonical);
assert.deepEqual(second.body, first.body);
assert.equal(urls.length, 1, 'canonical boxes share their cache key');
});
test('view snapping, whales, research and global queries obey the requested policy', async () => {
const urls = [];
const request = install({
fetchImpl: async (url) => {
urls.push(new URL(url));
return Response.json(envelope());
},
});
await request(
'/observations?south=40.2&west=-123.7&north=46.1&east=-121.4&quality=research&taxa=Aves',
);
assert.equal(urls[0].searchParams.get('swlat'), '40');
assert.equal(urls[0].searchParams.get('nelat'), '47');
assert.equal(urls[0].searchParams.get('per_page'), '200');
assert.equal(urls[0].searchParams.get('quality_grade'), 'research');
assert.equal(urls[0].searchParams.get('iconic_taxa'), 'Aves');
await request(boxUrl(0, '&taxa=whales'));
const whales = urls[1].searchParams;
assert.equal(whales.get('taxon_id'), '152871');
assert.equal(whales.get('d1'), '2026-10-28');
assert.equal(whales.has('created_d1'), false);
assert.equal(whales.has('photos'), false);
assert.equal(whales.has('iconic_taxa'), false);
await request('/observations?scope=global&quality=typo&taxa=typo&south=bad');
const global = urls[2].searchParams;
for (const field of [
'swlat',
'swlng',
'nelat',
'nelng',
'quality_grade',
'iconic_taxa',
])
assert.equal(global.has(field), false, field);
assert.equal(global.get('per_page'), '200');
assert.equal(global.get('d1'), '2026-11-02');
assert.equal(global.has('created_d1'), false);
assert.equal(global.get('order_by'), 'observed_on');
assert.equal(global.get('order'), 'desc');
await request('/observations?scope=global&taxa=whales');
const globalWhales = urls[3].searchParams;
assert.equal(globalWhales.get('d1'), '2026-10-28');
assert.equal(globalWhales.has('created_d1'), false);
assert.equal(globalWhales.get('order_by'), 'observed_on');
assert.equal(globalWhales.get('order'), 'desc');
});
test('fresh cache hits and global ten-minute TTL spend no upstream quota', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request();
at += 299_999;
assert.deepEqual((await request()).body, first.body);
at += 1;
await request();
assert.equal(calls, 2);
await request('/observations?scope=global');
at += 599_999;
await request('/observations?scope=global');
assert.equal(calls, 3);
at += 1;
await request('/observations?scope=global');
assert.equal(calls, 4);
});
test('the same snapped view and window hour returns one cached random sample until expiry', async () => {
let at = NOW;
let draws = 0;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async (url) => {
const query = new URL(url).searchParams;
assert.equal(query.get('order_by'), 'random');
assert.equal(query.get('per_page'), '200');
return Response.json(envelope([observation(++draws)]));
},
});
const first = await request();
for (const elapsed of [1, 1000, 60_000, 299_999]) {
at = NOW + elapsed;
const next = await request();
assert.deepEqual(next.body, first.body);
}
assert.equal(draws, 1);
assert.match(cache.keys().next().value, /2026-11-02T18:00:00Z$/);
at = NOW + 300_000;
const refresh = await request();
assert.equal(refresh.body.rows[0].stableId, 'inat:2');
assert.equal(draws, 2);
});
test('disconnected queued views are dropped while a remaining coalesced consumer keeps its request', async () => {
const calls = [];
const releases = [];
const cache = new Map();
const request = install({
cache,
fetchImpl: (url, { signal }) => {
calls.push({ url: new URL(url), signal });
return new Promise((resolve) => releases.push(resolve));
},
});
const first = request(boxUrl(0));
await until(() => releases.length === 1);
const superseded = new AbortController();
const oldView = request(boxUrl(1), { signal: superseded.signal });
const oneConsumer = new AbortController();
const one = request(boxUrl(2), { signal: oneConsumer.signal });
const other = request(boxUrl(2));
superseded.abort();
oneConsumer.abort();
await Promise.all([oldView, one]);
// Rejoining the abandoned key creates a healthy generation; the old
// coalescing promise must not later delete or cancel this replacement.
const replacement = request(boxUrl(1));
releases[0](Response.json(envelope()));
await until(() => releases.length === 2);
assert.equal(calls[1].url.searchParams.get('swlng'), '-122.1');
assert.equal(calls[1].signal.aborted, false);
releases[1](Response.json(envelope([observation(2)])));
await until(() => releases.length === 3);
assert.equal(calls[2].url.searchParams.get('swlng'), '-122.3');
releases[2](Response.json(envelope([observation(3)])));
const results = await Promise.all([first, other, replacement]);
assert.ok(results.every((result) => result.status === 200));
assert.equal(
calls.length,
3,
'the abandoned queued generation never dispatched',
);
assert.equal(cache.size, 3);
assert.equal(results[1].body.rows[0].stableId, 'inat:2');
assert.equal(results[2].body.rows[0].stableId, 'inat:3');
});
test('observation windows reject old sightings uploaded today and keep recent observations', async () => {
for (const [taxa, recentTime, expectedD1] of [
['all', '2026-11-04T17:00:00Z', '2026-11-02'],
['whales', '2026-10-30T17:00:00Z', '2026-10-28'],
]) {
let upstreamQuery;
const request = install({
fetchImpl: async (url) => {
upstreamQuery = new URL(url).searchParams;
return Response.json(
envelope([
{
...observation(80),
time_observed_at: '2010-06-01T12:00:00Z',
observed_on: '2010-06-01',
},
{
...observation(81),
time_observed_at: recentTime,
observed_on: recentTime.slice(0, 10),
},
]),
);
},
});
const result = await request(boxUrl(0, `&taxa=${taxa}`));
assert.equal(result.status, 200, taxa);
assert.equal(upstreamQuery.get('d1'), expectedD1);
assert.equal(upstreamQuery.has('created_d1'), false);
assert.equal(upstreamQuery.get('order_by'), 'random');
assert.deepEqual(
result.body.rows.map((row) => row.stableId),
['inat:81'],
);
}
});
test('same-key requests single-flight and a seventeenth distinct in-flight key queues', async () => {
const releases = [];
const request = install({
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const a = request();
const b = request();
await until(() => releases.length === 1);
releases.shift()(Response.json(envelope()));
assert.deepEqual((await a).body, (await b).body);
const pending = Array.from({ length: 16 }, (_, i) => request(boxUrl(i + 1)));
await until(() => releases.length === 1);
pending.push(request(boxUrl(18)));
for (let i = 0; i < 17; i += 1) {
await until(() => releases.length > i);
releases[i](Response.json(envelope()));
}
assert.ok((await Promise.all(pending)).every(({ status }) => status === 200));
});
test('a fresh global sample survives the hourly observation-window rollover without another fetch', async () => {
let at = Date.parse('2026-11-04T18:59:00Z');
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request('/observations?scope=global');
at += 60_000;
const nextHour = await request('/observations?scope=global');
assert.deepEqual(nextHour.body, first.body);
assert.equal(nextHour.body.stale, undefined);
assert.equal(calls, 1);
at += 9 * 60_000;
await request('/observations?scope=global');
assert.equal(calls, 2);
});
test('pending global refresh coalesces across the hourly observation-window rollover with a busy queue', async () => {
let at = Date.parse('2026-11-04T18:59:59Z');
const releases = [];
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: (url) =>
new Promise((resolve) =>
releases.push({
resolve,
url: new URL(url),
}),
),
});
const first = request(
'/observations?scope=global&quality=research&taxa=Aves',
);
const views = Array.from({ length: 15 }, (_, i) => request(boxUrl(i)));
await until(() => releases.length === 1);
assert.equal(releases[0].url.searchParams.get('d1'), '2026-11-02');
at = Date.parse('2026-11-04T19:00:01Z');
const nextHour = request(
'/observations?scope=global&quality=research&taxa=Aves',
);
await new Promise(setImmediate);
assert.equal(
releases.length,
1,
'the same global query joins the pending refresh',
);
const distinct = request(
'/observations?scope=global&quality=research&taxa=Mammalia',
);
for (let i = 0; i < 17; i += 1) {
await until(() => releases.length > i);
releases[i].resolve(Response.json(envelope()));
}
const [original, joined] = await Promise.all([first, nextHour]);
assert.equal(original.status, 200);
assert.equal((await distinct).status, 200);
assert.deepEqual(joined.body, original.body);
assert.ok((await Promise.all(views)).every(({ status }) => status === 200));
const globalEntries = [...cache.entries()].filter(
([, entry]) => entry.value.scope === 'global',
);
assert.equal(globalEntries.length, 2);
assert.match(globalEntries[0][0], /2026-11-02T18:00:00Z$/);
assert.equal(
(await request('/observations?scope=global&quality=research&taxa=Aves'))
.status,
200,
);
assert.equal(
releases.length,
17,
'the accepted global answer keeps its ten-minute freshness',
);
});
test('view refreshes retain distinct hourly keys while a previous-hour request is pending', async () => {
let at = Date.parse('2026-11-04T18:59:59Z');
const releases = [];
const request = install({
now: () => at,
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const first = request();
await until(() => releases.length === 1);
at = Date.parse('2026-11-04T19:00:01Z');
const nextHour = request();
await new Promise(setImmediate);
assert.equal(
releases.length,
1,
'the next view queues behind the current call',
);
releases[0](Response.json(envelope()));
await until(() => releases.length === 2);
releases[1](Response.json(envelope()));
assert.ok(
(await Promise.all([first, nextHour])).every(
({ status }) => status === 200,
),
);
});
test('upstream minute ceiling waits for request 61 across client addresses', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
queueOptions: {
now: () => at,
intervalMs: 0,
wait: async (ms) => {
at += ms;
},
},
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 60; i += 1)
assert.equal(
(await request(boxUrl(i), { peer: `10.0.0.${i}` })).status,
200,
);
const queued = await request(boxUrl(60), { peer: '10.0.1.1' });
assert.equal(queued.status, 200);
assert.equal(queued.headers['Retry-After'], undefined);
assert.equal(at, NOW + 60_000);
assert.equal(calls, 61);
});
test('views refresh after many prior requests without fabricating an upstream limit', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request();
at += 300_001;
for (let i = 1; i <= 60; i += 1)
await request(boxUrl(i), { peer: `10.0.0.${i}` });
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, undefined);
assert.notEqual(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(calls, 62);
});
test('bounded samples preserve partial metadata, and well-formed empty views are valid', async () => {
let at = NOW;
let fail = false;
const request = install({
now: () => at,
fetchImpl: async () =>
fail
? new Response('unavailable', { status: 503 })
: Response.json(envelope([observation()], 500)),
});
const first = await request();
assert.equal(first.body.partial, true);
assert.equal(first.body.rawRowCount, 500);
fail = true;
at += 59 * 60_000;
const stale = await request();
assert.equal(
stale.status,
200,
'hourly window changes still permit stale fallback',
);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(stale.body.partial, true);
at += 2 * 60_000;
const expired = await request();
assert.equal(expired.status, 502);
assert.equal(expired.body.error, 'inaturalist_unavailable');
const empty = install({
fetchImpl: async () => Response.json(envelope([], 0)),
});
const result = await empty();
assert.equal(result.status, 200);
assert.deepEqual(result.body.rows, []);
});
test('bad envelopes, JSON and capped bodies fail closed without inventing a cooldown', async () => {
for (const bad of [
() => Response.json({ total_results: 1, results: {} }),
() => Response.json({ total_results: 1, observations: [observation()] }),
() => Response.json({ total_results: '1', results: [observation()] }),
() => Response.json(envelope([], 1)),
() => Response.json(envelope([{ ...observation(), id: null }], 1)),
() => new Response('{malformed'),
() =>
new Response('{}', {
headers: { 'Content-Length': String(8 * 1024 * 1024 + 1) },
}),
]) {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return calls === 1 ? bad() : Response.json(envelope());
},
});
const first = await request();
assert.equal(first.status, 502);
assert.equal(first.body.error, 'inaturalist_unavailable');
assert.equal(first.headers['Retry-After'], undefined);
assert.equal((await request(boxUrl(1))).status, 200);
assert.equal(calls, 2);
}
});
test('a wholly invalid iNat page cannot replace a previously valid snapshot', async () => {
let at = NOW;
let fail = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () =>
Response.json(
fail
? envelope([{ ...observation(), quality_grade: 'casual' }])
: envelope(),
),
});
const first = await request();
const original = cache.values().next().value;
fail = true;
at += 300_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(cache.values().next().value, original);
});
test('429 cooldown honors seconds, HTTP dates, and long upstream deadlines', async () => {
assert.equal(inatRetryCooldownMs(null, NOW), 60_000);
assert.equal(inatRetryCooldownMs('0', NOW), 0);
assert.equal(inatRetryCooldownMs('2', NOW), 2000);
assert.equal(inatRetryCooldownMs('999999', NOW), 999_999_000);
assert.equal(inatRetryCooldownMs('7200', NOW), 7_200_000);
assert.equal(
inatRetryCooldownMs(new Date(NOW + 7_200_000).toUTCString(), NOW),
7_200_000,
);
let at = NOW;
const releases = [];
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const first = request();
const later = request(boxUrl(1));
await until(() => releases.length === 1);
releases[0](
new Response('limit', {
status: 429,
headers: { 'Retry-After': '999999' },
}),
);
assert.equal((await first).headers['Retry-After'], '999999');
assert.equal((await later).headers['Retry-After'], '999999');
at += 1000;
assert.equal((await request(boxUrl(1))).headers['Retry-After'], '999998');
at += 5000;
const cooling = await request(boxUrl(2));
assert.equal(cooling.status, 429);
assert.equal(cooling.headers['Retry-After'], '999993');
assert.equal(
releases.length,
1,
'queued calls do not dispatch during cooldown',
);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
0,
);
});
for (const [upstreamStatus, expectedStatus, code, retryAfterS] of [
[429, 429, 'inaturalist_rate_limited', 120],
[503, 502, 'inaturalist_unavailable', 120],
]) {
test(`concurrent reservations honor an immediate upstream ${upstreamStatus} before dispatch`, async () => {
let calls = 0;
const warnings = [];
const client = createInatClient({
now: () => 0,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: async () => {
calls += 1;
return new Response('synthetic failure', {
status: upstreamStatus,
headers: { 'Retry-After': '120' },
});
},
});
const params = {
scope: 'global',
quality: 'all',
taxa: 'all',
observedD1: '1969-12-30',
};
const results = await Promise.allSettled(
Array.from({ length: 3 }, () => client.getObservations(params)),
);
assert.equal(
calls,
1,
'pending reservations must not dispatch during cooldown',
);
for (const result of results) {
assert.equal(result.status, 'rejected');
assert.equal(result.reason.status, expectedStatus);
assert.equal(result.reason.code, code);
assert.equal(result.reason.retryAfterS, retryAfterS);
}
assert.equal(
warnings.length,
1,
'only the dispatched refresh logs a failure',
);
await assert.rejects(client.getObservations(params), {
status: expectedStatus,
code,
retryAfterS,
});
assert.equal(calls, 1);
});
}
test('budget is read lazily, persisted as counters only, enforced across restart and UTC rollover', async (t) => {
const parent = await mkdtemp(path.join(os.tmpdir(), 'gev-inat-budget-'));
t.after(() => rm(parent, { recursive: true, force: true }));
const cacheDir = path.join(parent, 'not-created');
const before = process.env.INATURALIST_DAILY_BUDGET;
process.env.INATURALIST_DAILY_BUDGET = '2';
t.after(() =>
before === undefined
? delete process.env.INATURALIST_DAILY_BUDGET
: (process.env.INATURALIST_DAILY_BUDGET = before),
);
let at = NOW;
let calls = 0;
const options = {
now: () => at,
cacheDir,
warn() {},
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
};
const client = createInatClient(options);
assert.deepEqual(
await readdir(parent),
[],
'construction performs no directory write',
);
const params = {
scope: 'global',
quality: 'all',
taxa: 'all',
observedD1: '2026-11-02',
};
await Promise.all([
client.getObservations(params),
client.getObservations(params),
]);
assert.deepEqual(
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
{ date: '2026-11-04', count: 2 },
);
assert.deepEqual(
await readdir(cacheDir),
['budget.json'],
'atomic temporary files are retired',
);
const restarted = createInatClient(options);
await assert.rejects(restarted.getObservations(params), {
status: 503,
code: 'inaturalist_budget_exhausted',
});
assert.equal(calls, 2);
at = Date.parse('2026-11-05T00:00:00Z');
await restarted.getObservations(params);
assert.deepEqual(restarted.budgetState(), {
date: '2026-11-05',
count: 1,
budget: 2,
});
assert.deepEqual(
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
{ date: '2026-11-05', count: 1 },
);
assert.equal(calls, 3);
});
test('memory cache is an LRU bounded to 400 projected entries', async () => {
let at = NOW;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => Response.json(envelope()),
});
for (let i = 0; i < 400; i += 1) {
if (i && i % 20 === 0) at += 61_000;
await request(boxUrl(i), { peer: String(i) });
}
assert.equal(cache.size, 400);
const firstKey = cache.keys().next().value;
const secondKey = [...cache.keys()][1];
await request(boxUrl(0));
await request(boxUrl(400));
assert.equal(cache.size, 400);
assert.equal(cache.has(firstKey), true);
assert.equal(cache.has(secondKey), false);
});
test('aborted client calls spend no budget and never fetch', async () => {
const controller = new AbortController();
controller.abort();
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
fetchImpl: () => assert.fail('aborted call fetched'),
});
await assert.rejects(
client.getObservations({}, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(client.budgetState().count, 0);
});
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { inaturalistProxy } from './observations.js';
export const NOW = Date.parse('2026-11-04T18:05:00Z');
/** Mount the proxy over synthetic HTTP objects, without opening a socket. */
export function installInatProxy(
defaultUrl,
options = {},
hook = 'configureServer',
) {
let handler;
const plugin = inaturalistProxy({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0, windowMs: 0 },
warn() {},
...options,
});
assert.equal(plugin.name, 'inaturalist');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(mount, callback) {
assert.equal(mount, '/api/inaturalist');
handler = callback;
},
},
});
return async (
url = defaultUrl,
{ method = 'GET', peer = 'local', headers = {}, signal } = {},
) => {
const res = Object.assign(new EventEmitter(), {
headers: {},
writeHead(status, headers) {
this.status = status;
Object.assign(this.headers, headers);
},
setHeader(key, value) {
this.headers[key] = value;
},
end(value) {
this.status ??= this.statusCode;
this.body = JSON.parse(value);
},
});
const abort = () => {
res.destroyed = true;
res.emit('close');
};
signal?.addEventListener('abort', abort, { once: true });
try {
await handler(
Object.assign(new EventEmitter(), {
url,
method,
headers,
socket: { remoteAddress: peer },
}),
res,
);
} finally {
signal?.removeEventListener('abort', abort);
}
return res;
};
}
+109
View File
@@ -0,0 +1,109 @@
import { setTimeout as delay } from 'node:timers/promises';
// A four-request burst keeps a cold view responsive; thereafter every provider
// call shares the same one-per-second token supply. The rolling minute ceiling
// queues the few requests a token bucket alone could allow above 60/min.
const INAT_INTERVAL_MS = 1000;
const INAT_BURST = 4;
const INAT_WINDOW_MS = 60_000;
const INAT_MAX_PER_WINDOW = 60;
const INAT_MAX_IN_FLIGHT = 4;
/** Observation queries outrank tiles waiting for a token. Aborted consumers
* leave the queue before an attempt or daily-budget reservation is spent. */
export function createInatQueue({
now = () => performance.now(),
wait = (ms) => delay(ms),
intervalMs = INAT_INTERVAL_MS,
burst = INAT_BURST,
windowMs = INAT_WINDOW_MS,
maxInFlight = INAT_MAX_IN_FLIGHT,
} = {}) {
const pending = [];
const dispatchedAt = [];
let tokens = burst;
let refilledAt = null;
let running = false;
let inFlight = 0;
let slotFreed = null;
function refill() {
const at = now();
if (refilledAt !== null && intervalMs > 0)
tokens = Math.min(
burst,
tokens + Math.max(0, at - refilledAt) / intervalMs,
);
if (intervalMs <= 0) tokens = burst;
refilledAt = at;
}
function schedule() {
if (running) return;
running = true;
// A microtask also lets same-turn observation queries take priority.
void Promise.resolve().then(drain);
}
async function drain() {
try {
while (pending.length) {
if (inFlight >= maxInFlight) {
await new Promise((resolve) => (slotFreed = resolve));
continue;
}
refill();
const at = now();
while (dispatchedAt.length && dispatchedAt[0] <= at - windowMs)
dispatchedAt.shift();
const tokenWait = tokens < 1 ? (1 - tokens) * intervalMs : 0;
const windowWait =
windowMs > 0 && dispatchedAt.length >= INAT_MAX_PER_WINDOW
? dispatchedAt[0] + windowMs - at
: 0;
if (tokenWait > 0 || windowWait > 0) {
await wait(Math.ceil(Math.max(tokenWait, windowWait)));
continue;
}
// Stable sorting preserves FIFO order within each priority.
pending.sort((a, b) => a.priority - b.priority);
const job = pending.shift();
job.signal?.removeEventListener('abort', job.abort);
if (job.signal?.aborted) {
job.reject(job.signal.reason);
continue;
}
tokens -= 1;
if (windowMs > 0) dispatchedAt.push(at);
inFlight += 1;
// A rejected task releases its slot without stopping the queue.
void new Promise((resolve) => resolve(job.task()))
.then(job.resolve, job.reject)
.finally(() => {
inFlight -= 1;
const wake = slotFreed;
slotFreed = null;
wake?.();
});
}
} finally {
running = false;
}
}
return function enqueue(task, { signal, priority = 1 } = {}) {
if (signal?.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const job = { task, signal, priority, resolve, reject };
job.abort = () => {
const index = pending.indexOf(job);
if (index >= 0) pending.splice(index, 1);
signal.removeEventListener('abort', job.abort);
reject(signal.reason);
};
signal?.addEventListener('abort', job.abort, { once: true });
pending.push(job);
schedule();
});
};
}
+111
View File
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatQueue } from './queue.js';
async function until(check) {
for (let i = 0; i < 100; i += 1) {
if (check()) return;
await new Promise(setImmediate);
}
assert.fail('queue did not reach its expected state');
}
test('shared governor paces a four-call burst within the rolling minute ceiling', async () => {
let at = 0;
const waits = [];
const queue = createInatQueue({
now: () => at,
wait: async (ms) => {
waits.push(ms);
at += ms;
},
});
const dispatched = [];
await Promise.all(
Array.from({ length: 65 }, () => queue(() => dispatched.push(at))),
);
assert.equal(dispatched.length, 65);
assert.deepEqual(dispatched.slice(0, 7), [0, 0, 0, 0, 1000, 2000, 3000]);
assert.deepEqual(waits.slice(0, 3), [1000, 1000, 1000]);
for (const time of dispatched)
assert.ok(
dispatched.filter((other) => other > time - 60_000 && other <= time)
.length <= 60,
);
assert.equal(dispatched[60], 60_000);
});
test('an observation arriving during the pacing wait overtakes queued tiles', async () => {
let at = 0;
const waits = [];
const queue = createInatQueue({
burst: 1,
now: () => at,
wait: (ms) => new Promise((resolve) => waits.push({ ms, resolve })),
});
const dispatched = [];
await queue(() => dispatched.push('first tile'));
const tile = queue(() => dispatched.push('waiting tile'));
await until(() => waits.length === 1);
const observation = queue(() => dispatched.push('observation'), {
priority: 0,
});
at += waits[0].ms;
waits[0].resolve();
await observation;
assert.deepEqual(dispatched, ['first tile', 'observation']);
await until(() => waits.length === 2);
at += waits[1].ms;
waits[1].resolve();
await tile;
assert.deepEqual(dispatched, ['first tile', 'observation', 'waiting tile']);
});
test('superseded tasks leave before dispatch, and a rejection cannot poison the tail', async () => {
const queue = createInatQueue({ intervalMs: 0 });
const dispatched = [];
let release;
const first = queue(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
await until(() => release);
const controller = new AbortController();
const superseded = queue(() => assert.fail('superseded tile dispatched'), {
signal: controller.signal,
});
const dropped = assert.rejects(superseded, { name: 'AbortError' });
controller.abort();
await dropped;
const failed = queue(() => {
dispatched.push('failed');
throw new Error('synthetic');
});
const failure = assert.rejects(failed, /synthetic/);
const succeeding = queue(() => dispatched.push('recovered'));
release();
await Promise.all([first, failure, succeeding]);
assert.deepEqual(dispatched, ['failed', 'recovered']);
});
test('a slow call does not hold later calls; at most four run at once', async () => {
const queue = createInatQueue({ intervalMs: 0, windowMs: 0 });
const releases = [];
const started = [];
const slow = (id) => () =>
new Promise((resolve) => {
started.push(id);
releases.push(() => resolve(id));
});
const jobs = [1, 2, 3, 4, 5].map((id) => queue(slow(id)));
await until(() => started.length === 4);
assert.deepEqual(started, [1, 2, 3, 4], 'four start without waiting');
releases[1]();
assert.equal(await jobs[1], 2, 'a later call finishes before the first');
await until(() => started.length === 5);
assert.deepEqual(started, [1, 2, 3, 4, 5], 'a freed slot starts the next');
for (const release of releases) release();
assert.deepEqual(await Promise.all(jobs), [1, 2, 3, 4, 5]);
});
+218
View File
@@ -0,0 +1,218 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatClient } from './client.js';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { syntheticGrid } from './grid.fixture.mjs';
import { INAT_GRID_DECODE_MEASURE } from './grid.js';
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves';
const params = {
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
};
const tick = () => new Promise(setImmediate);
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
test('tile cache lasts fifteen minutes, then returns stale before revalidation finishes', async () => {
let at = NOW;
let calls = 0;
let release;
const request = install({
now: () => at,
fetchImpl: () => {
calls += 1;
return calls === 1
? Response.json(syntheticGrid())
: new Promise((resolve) => {
release = resolve;
});
},
});
const first = await request();
at += 899_999;
const freshPromise = request();
await tick();
assert.equal(calls, 1, 'a fresh tile never spends another upstream attempt');
assert.deepEqual((await freshPromise).body, first.body);
at += 1;
const stalePromise = request();
let resolved = false;
void stalePromise.then(() => {
resolved = true;
});
await tick();
try {
assert.equal(
resolved,
true,
'stale cells publish while refresh is pending',
);
const stale = await stalePromise;
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, NOW);
assert.deepEqual(stale.body.cells, first.body.cells);
await request();
assert.equal(calls, 2, 'all stale consumers share one background refresh');
const refreshPromise = request(`${gridUrl}&refresh=wait`);
let refreshed = false;
void refreshPromise.then(() => {
refreshed = true;
});
await tick();
assert.equal(
refreshed,
false,
'a background consumer can await the same refresh',
);
release(Response.json(syntheticGrid()));
const fresh = await refreshPromise;
assert.equal(fresh.body.stale, undefined);
assert.equal(fresh.body.fetchedAt, at);
assert.equal(
calls,
2,
'waiting for fresh cells never duplicates upstream work',
);
release = null;
} finally {
release?.(Response.json(syntheticGrid()));
}
});
test('more than sixteen queued views and thirty local requests never report local rate limiting', async () => {
let release;
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
if (calls === 1)
await new Promise((resolve) => {
release = resolve;
});
return Response.json(syntheticGrid());
},
});
const pending = Array.from({ length: 40 }, (_, x) =>
request(`/grid?z=6&x=${x}&y=1`),
);
await tick();
release();
const results = await Promise.all(pending);
assert.ok(results.every((result) => result.status === 200));
assert.equal(calls, 40);
});
test('only real upstream 429 or 5xx failures start cooldown', async () => {
for (const bad of [
() => Promise.reject(new Error('network')),
() => Response.json({ malformed: true }),
() => new Response('', { status: 404 }),
]) {
let calls = 0;
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0, windowMs: 0 },
warn() {},
fetchImpl: () => {
calls += 1;
return calls === 1
? bad()
: Response.json({ results: [], total_results: 0 });
},
});
await assert.rejects(client.getObservations(params), { status: 502 });
await client.getObservations(params);
assert.equal(
calls,
2,
'a non-cooldown failure must not hold the next attempt',
);
}
});
test('actual upstream cooldown is marked separately from a local budget stop', async () => {
for (const upstream of [429, 503]) {
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return new Response('', {
status: upstream,
headers: { 'Retry-After': '120' },
});
},
});
const first = await request();
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
assert.equal(first.headers['Retry-After'], '120');
await request();
assert.equal(calls, 1);
}
});
test('upstream Retry-After zero survives the HTTP response and permits an immediate next request', async () => {
for (const upstream of [429, 503]) {
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return calls === 1
? new Response('', {
status: upstream,
headers: { 'Retry-After': '0' },
})
: Response.json(syntheticGrid());
},
});
const first = await request();
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
assert.equal(first.headers['Retry-After'], '0');
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
}
});
test('a local daily budget stop has a distinct code and no upstream cooldown marker', async (t) => {
const before = process.env.INATURALIST_DAILY_BUDGET;
process.env.INATURALIST_DAILY_BUDGET = '1';
t.after(() => {
if (before === undefined) delete process.env.INATURALIST_DAILY_BUDGET;
else process.env.INATURALIST_DAILY_BUDGET = before;
});
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return Response.json(syntheticGrid());
},
});
assert.equal((await request()).status, 200);
const stopped = await request('/grid?z=2&x=0&y=1');
assert.equal(stopped.status, 503);
assert.equal(stopped.body.error, 'inaturalist_budget_exhausted');
assert.equal(stopped.headers['X-Upstream-Status'], undefined);
assert.equal((await request()).status, 200, 'cached data remains available');
assert.equal(calls, 1);
});
test('cached tiles avoid UTFGrid decode and decode timing entries stay bounded', async () => {
const request = install({
fetchImpl: () => Response.json(syntheticGrid()),
});
await request();
const sample = performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0];
assert.ok(sample.duration >= 0);
for (let i = 0; i < 10; i += 1) await request();
assert.equal(
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0],
sample,
);
for (let x = 0; x < 4; x += 1) await request(`/grid?z=3&x=${x}&y=1`);
assert.equal(
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE).length,
1,
);
});
+6
View File
@@ -28,6 +28,9 @@ import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js';
import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { ghostMapsProxy } from './ghostMaps.js';
import { droneAirspaceProxy } from './droneAirspace.js';
import { inaturalistProxy } from './inaturalist/observations.js';
import { acartiaProxy } from './acartia.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
@@ -79,6 +82,9 @@ function localProviderPlugins({
firePerimetersProxy(),
gnssIntegrityProxy(),
ghostMapsProxy(),
droneAirspaceProxy(),
inaturalistProxy(),
acartiaProxy(),
keySetupEndpoint(),
];
}
+5
View File
@@ -1,3 +1,4 @@
import { createOpenAiChatHandler } from './openai/chat.js';
import { defaultSourceRoot } from './common/source-root.js';
import { handleHudSummary } from './openai/hud-summary.js';
import { createDebugLogHandler } from './openai/debug-log.js';
@@ -22,6 +23,10 @@ function openAiRealtimeProxy({
} = {}) {
const oauthLogin = createCodexOAuthLogin(realtime);
function install(middlewares) {
middlewares.use(
'/api/openai/chat',
createOpenAiChatHandler({ ...realtime, annotationGuidance }),
);
// Cost-bearing and log endpoints refuse cross-site browser requests
// (see server/providers/common/same-site.js and SECURITY.md).
middlewares.use('/api/openai/hud-summary', sameSiteGated(handleHudSummary));
+198
View File
@@ -0,0 +1,198 @@
import { createTextChatHandler } from '../common/textChat.js';
import { preferCodexOAuth } from './codex-auth.js';
import { OPENAI_DEFAULT_PER_MIN } from './rate-limit.js';
import { invalidCalls } from '../common/textTools.js';
import { textInstructions } from '../common/textInstructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
const object = (value) =>
value && typeof value === 'object' && !Array.isArray(value);
const keys = (value, allowed) =>
Object.keys(value).every((key) => allowed.includes(key));
const callShape = (call) =>
object(call) &&
keys(call, ['id', 'type', 'function']) &&
typeof call.id === 'string' &&
call.id.length > 0 &&
call.id.length <= 1024 &&
call.type === 'function' &&
object(call.function) &&
keys(call.function, ['name', 'arguments']) &&
typeof call.function.name === 'string' &&
call.function.name.length <= 128 &&
typeof call.function.arguments === 'string';
function assistantShape(message) {
return (
object(message) &&
keys(message, ['role', 'content', 'tool_calls']) &&
message.role === 'assistant' &&
(message.content == null || typeof message.content === 'string') &&
(message.tool_calls === undefined ||
(Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0 &&
message.tool_calls.length <= 32 &&
message.tool_calls.every(callShape) &&
new Set(message.tool_calls.map((call) => call.id)).size ===
message.tool_calls.length))
);
}
function validMessages(messages) {
if (!Array.isArray(messages) || !messages.length || messages.length > 96)
return false;
let pending = [];
for (const message of messages) {
if (!object(message)) return false;
if (pending.length) {
if (
!keys(message, ['role', 'content', 'tool_call_id']) ||
message.role !== 'tool' ||
typeof message.content !== 'string' ||
message.tool_call_id !== pending.shift()
)
return false;
} else if (message.role === 'user') {
if (
!keys(message, ['role', 'content']) ||
typeof message.content !== 'string'
)
return false;
} else if (assistantShape(message)) {
pending = (message.tool_calls || []).map((call) => call.id);
} else return false;
}
return !pending.length;
}
/**
* A tool schema without the `pattern`s Chat Completions refuses: it answers
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
* as `resolve_area`'s area ids do. Every argument is still checked against
* the action's own contract before the tool runs, so the model loses a hint,
* not a rule. Realtime voice accepts these patterns and keeps them.
*/
export function chatSchema(schema) {
if (Array.isArray(schema)) return schema.map(chatSchema);
if (!object(schema)) return schema;
return Object.fromEntries(
Object.entries(schema)
.filter(
([key, value]) =>
!(
key === 'pattern' &&
typeof value === 'string' &&
/\\[pP]\{/.test(value)
),
)
.map(([key, value]) => [key, chatSchema(value)]),
);
}
export function createOpenAiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
// osm_query is offered, and the prompt names it, only with Overpass, as
// for voice.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
resolveApiKey = () => process.env.OPENAI_API_KEY,
resolveChatModel = () => process.env.OPENAI_CHAT_MODEL || 'gpt-4.1-mini',
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
environment = process.env,
...options
} = {}) {
return createTextChatHandler({
...options,
provider: 'openai',
resolveApiKey,
resolveProviderRateLimit,
providerPerMin: OPENAI_DEFAULT_PER_MIN,
// Chat can only bill the API key, which this operator lock forbids.
refuse() {
let error =
'ChatGPT OAuth is required by GEV_PREFER_CODEX_OAUTH, and typed OpenAI chat needs an API key.';
try {
if (!preferCodexOAuth(environment)) return null;
} catch (invalid) {
error = invalid.message;
}
return { error, code: 'CODEX_OAUTH_REQUIRED' };
},
prepare(input) {
const model = String(resolveChatModel()).trim();
if (
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model) ||
/realtime|audio|tts/i.test(model)
)
return { errorStatus: 503 };
if (
!object(input) ||
!keys(input, ['messages']) ||
!validMessages(input.messages)
)
return null;
const overpass = overpassConfigured();
return {
endpoint: 'https://api.openai.com/v1/chat/completions',
headers: (key) => ({ Authorization: `Bearer ${key}` }),
body: {
model,
n: 1,
stream: false,
store: false,
max_completion_tokens: 4096,
messages: [
{
role: 'system',
content: textInstructions(annotationGuidance, { overpass }),
},
...input.messages,
],
tools: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
type: 'function',
function: {
name,
description,
parameters: chatSchema(parameters),
strict: false,
},
}),
),
},
};
},
parse(data) {
const choice = data?.choices?.[0];
const raw = choice?.message;
if (!raw) return null;
const content = {
role: raw.role,
content: raw.content ?? null,
...(raw.tool_calls?.length ? { tool_calls: raw.tool_calls } : {}),
};
if (
!assistantShape(content) ||
(!content.content && !content.tool_calls?.length)
)
return null;
const calls = (content.tool_calls || []).map((call) => {
try {
return {
name: call.function.name,
args: JSON.parse(call.function.arguments),
};
} catch {
return { name: call.function.name, parseError: true };
}
});
return {
content,
finishReason: choice.finish_reason,
invalidCalls: invalidCalls(
calls,
offeredTools(tools, { overpass: overpassConfigured() }),
),
};
},
});
}
+22 -8
View File
@@ -1,8 +1,14 @@
const DEFAULT_ANNOTATION_GUIDANCE =
'Put related places in one annotate_map call. Set flyTo true only when the user is not already looking at the place (off-screen marks auto-frame, so when unsure leave it false). Do not say you are drawing or annotating — talk about the places while the marks appear. Marks ACCUMULATE AND PERSIST across flights and topics so the user can build up the map: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
function realtimeInstructions(
/**
* The Realtime session directives, one per line. `overpass` says an operator
* configured Overpass, which is what offers osm_query (see tools.js); without
* it the prompt never mentions the tool.
*/
function realtimeDirectives(
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
{ overpass = false } = {},
) {
return [
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
@@ -21,16 +27,20 @@ function realtimeInstructions(
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.',
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names, or an areaId you already have; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
'AREAS: resolve_area turns a named boundary (country, state, province, county, city, district, natural region) into an areaId; to outline it pass draw=true; annotate_map is for marks, not boundaries. Count inside it → analyst_query kind=area with the areaId (or kind=region); "the area I drew" → kind=drawn; "the area you outlined" → kind=annotation. Imagery of a place → resolve_area, then find_imagery. Ambiguous name: call resolve_area; on needsClarification ask which, never guess.' +
(overpass
? ' "All hospitals/schools/… in <place>" → resolve_area, then osm_query; in view → osm_query alone; follow-ups → analyst_query layers [osm-places], scope anywhere.'
: ''),
'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.',
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.',
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
'Do not mention disabled layers or stale selections.',
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.',
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers, approximate outlines and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
// INSTRUCTION-ONLY mapping for the two globe-scale named views.
//
@@ -60,11 +70,15 @@ function realtimeInstructions(
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.',
'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.',
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus, district or boundary, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park, district or boundary outline; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus or district, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park or district footprint; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
annotationGuidance,
'annotate_map results: work any say line in naturally (places not found, a straight-line fallback with no travel time, or a full map — then ask before clearing). outlinePending means the boundary is still being traced: never claim it is drawn. A later map_annotation_outline system item reports each outcome; use it to quietly confirm or correct yourself.',
'ROUTES vs DISTANCES: "walking/driving route from A to B" → type=route with the ordered points and the mode; the app draws the street path and reports distance and time you can read aloud. "How far is X from Y" or "X is next to Y" → type=arrow (straight-line distance), never route.',
].join('\n');
];
}
export { realtimeInstructions };
function realtimeInstructions(annotationGuidance, options) {
return realtimeDirectives(annotationGuidance, options).join('\n');
}
export { realtimeInstructions, realtimeDirectives };
+7 -3
View File
@@ -13,7 +13,8 @@ import {
OPENAI_REALTIME_TRANSCRIBE_MODEL_DEFAULT,
} from './constants.js';
import { realtimeInstructions } from './instructions.js';
import { GEV_REALTIME_TOOLS } from './tools.js';
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
import {
preferCodexOAuth,
readCodexOAuthAccessToken,
@@ -136,6 +137,8 @@ function createRealtimeTokenHandler({
environment = process.env,
models = {},
tools = GEV_REALTIME_TOOLS,
// osm_query is offered only when an operator configured Overpass.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
} = {}) {
return async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
@@ -273,6 +276,7 @@ function createRealtimeTokenHandler({
transcribeModel.toLowerCase() === 'off'
? {}
: { transcription: { model: transcribeModel } };
const overpass = overpassConfigured();
const sessionConfig = {
session: {
type: 'realtime',
@@ -298,8 +302,8 @@ function createRealtimeTokenHandler({
},
output: { voice },
},
instructions: realtimeInstructions(annotationGuidance),
tools,
instructions: realtimeInstructions(annotationGuidance, { overpass }),
tools: offeredTools(tools, { overpass }),
tool_choice: 'auto',
},
};
+48 -5
View File
@@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = {
},
control_cockpit: {
description:
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
$position: 1,
parameters: {
properties: {
@@ -405,7 +405,7 @@ export const ACTION_DESCRIPTIONS = {
},
annotate_map: {
description:
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints and boundaries, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
$position: 1,
parameters: {
properties: {
@@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = {
properties: {
direction: {
description:
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
'Pan/rotate/tilt steps; orbit/rotate_globe default right.',
$position: 2,
},
mode: {
@@ -531,6 +531,10 @@ export const ACTION_DESCRIPTIONS = {
'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.',
$position: 2,
},
pitchDeg: {
description: 'Absolute; -45 default, -89 top-down.',
$position: 1,
},
},
},
},
@@ -547,9 +551,48 @@ export const ACTION_DESCRIPTIONS = {
},
},
},
resolve_area: {
description:
'Named place → reusable areaId for analyst_query scope {kind:area} and other area tools. draw=true outlines it ("outline Bagmati province"); around=true is "an area around <landmark>": its enclosing mapped area, else a buffer marked approximate. needsClarification: ask which, then call again with its candidateId. AREA_TIMEOUT: retry once.',
$position: 1,
parameters: {
properties: {
query: {
description: 'The place as said, with any kind word.',
$position: 1,
},
level: {
description: 'Only when the user names the kind.',
$position: 1,
},
within: {
description: 'Country or state the user added.',
$position: 1,
},
},
},
},
find_imagery: {
description:
'Recent satellite imagery (Sentinel-2/Landsat 30 m, VIIRS daily; last 30 days): pins the best acquisition in the Recent Imagery panel. areaId from resolve_area; area=pointer for "this spot"; omit for the view. BOX_REFUSED: offer to zoom in. DISPLACED: leave it.',
$position: 1,
parameters: {
properties: {
maxCloudPct: {
description: '"cloud-free" → 10.',
$position: 1,
},
},
},
},
osm_query: {
description:
'OpenStreetMap places of one kind ("hospitals in Kathmandu"). areaId from resolve_area; omit for the view. Shows them in OSM Places with numbered referents; they become the last answer. countScope bounding-box means an approximate count. OSM_TIMEOUT: retry once.',
$position: 1,
},
analyst_query: {
description:
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.',
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), NEEDS_CLARIFICATION (ask which), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.',
$position: 1,
parameters: {
properties: {
@@ -560,7 +603,7 @@ export const ACTION_DESCRIPTIONS = {
},
scope: {
description:
'Always set it (see WHERE). Explicit "in view" uses view; "nearby" uses view when Contacts is off. Around the active Contacts subject uses radius with the requested km and center omitted, never view: the five closest within 250 km use scope:{kind:"radius",km:250}, sortBy:"distance", limit:5. For another requested radius use its actual km, not 250. pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>" or explicit coordinates; anywhere = no place ("biggest anywhere", "today"). Preserve explicit view, named place, pointer and explicit center even while Contacts is active; never invent coordinates. There is no drawn-area scope yet: say so rather than answering for the view.',
'Always set it (see WHERE). Explicit "in view" uses view; "nearby" uses view when Contacts is off. Around the active Contacts subject uses radius with the requested km and center omitted, never view: the five closest within 250 km use scope:{kind:"radius",km:250}, sortBy:"distance", limit:5. For another requested radius use its actual km, not 250. pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>" or explicit coordinates; anywhere = no place ("biggest anywhere", "today"); area + areaId from resolve_area; drawn = "the area I drew"; annotation = "the area you outlined". Preserve explicit view, named place, pointer and explicit center even while Contacts is active; never invent coordinates.',
$position: 2,
properties: {
name: {
+25
View File
@@ -1,6 +1,7 @@
import { createActionTools } from '../../../src/voice/actionSchemas.js';
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
/** Every voice tool, as the Realtime session describes them. */
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
/**
@@ -14,3 +15,27 @@ export function realtimeSessionTools(additional = []) {
...additional.filter((tool) => !names.has(tool.name)),
];
}
/** Tools that need an operator-configured Overpass (OVERPASS_UPSTREAMS). */
export const OVERPASS_ONLY_TOOLS = Object.freeze(['osm_query']);
/**
* The tools one session is offered. Bulk OpenStreetMap place search runs
* only on an operator's Overpass, so without one `osm_query` is left out
* rather than offered and refused.
* @param {Array<{name: string}>} tools
* @param {{overpass?: boolean}} [options]
*/
export function offeredTools(tools, { overpass = false } = {}) {
return overpass
? tools
: tools.filter((tool) => !OVERPASS_ONLY_TOOLS.includes(tool.name));
}
/**
* The app actions one session is offered.
* @param {{overpass?: boolean}} [options]
*/
export function realtimeTools({ overpass = false } = {}) {
return offeredTools(GEV_REALTIME_TOOLS, { overpass });
}
+2
View File
@@ -28,6 +28,7 @@ import {
overpassNotConfigured,
} from './overpass/transport.js';
import { installRouteMiddleware } from './places/routes.js';
import { installOsmFeaturesRoute } from './overpass/osmFeaturesRoute.js';
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
const _overpassInFlight = new Map();
@@ -274,6 +275,7 @@ function overpassProxy({ routing = {} } = {}) {
});
installRouteMiddleware(server.middlewares, routing);
installOsmFeaturesRoute(server.middlewares);
};
return {
name: 'overpass-proxy',
+205
View File
@@ -0,0 +1,205 @@
import { OSM_PRESETS } from '../../../src/data/osmPresets.js';
/**
* Bounded, typed OpenStreetMap feature search: one preset from the curated
* table inside a bounding box.
*
* The request carries a preset id, a box, a mode and a limit — never query
* text. The server writes the Overpass query itself and bounds it: box size
* per mode (a count may cover more ground than a fetch), result count,
* Overpass timeout and memory, response bytes, and one query in flight. It
* runs only against the operator's `OVERPASS_UPSTREAMS`; the generic
* `/api/overpass` guard is untouched.
*
* There is deliberately no boundary (`area`) filter: Overpass servers differ
* in whether they hold area data, and one without it answers zero rather than
* failing. The browser filters the box's features by the exact area geometry
* instead, so a count inside a province is exact and a box count is labelled
* as a box count.
*/
export const OSM_FEATURE_LIMITS = Object.freeze({
/** Largest box a fetch may cover, in square degrees (~200 × 200 km at the equator). */
maxFetchDeg2: 4,
/** Largest box a count may cover. */
maxCountDeg2: 36,
/** Most features one fetch returns. */
maxLimit: 1000,
/** Overpass server-side timeout, seconds: it gives up before we stop waiting. */
timeoutS: 20,
/** Overpass server-side memory cap, bytes. */
maxsize: 64 * 1024 * 1024,
/** Response bytes read from the upstream. */
maxResponseBytes: 16 * 1024 * 1024,
/** Per-upstream wait: the Overpass timeout plus transfer time. */
upstreamTimeoutMs: 22_000,
/**
* The whole search, across every configured endpoint in turn. It ends
* before the voice answer's own deadline (`OSM_QUERY_LIMITS.budgetMs` in
* `src/voice/osmActions.js`, 25 s): a browser that gives up first closes
* the request, which cancels the upstream query, so nothing would be
* cached for the retry.
*/
upstreamBudgetMs: 22_000,
});
/** Tags a returned feature keeps, besides the preset's own. */
const KEPT_TAGS = [
'name',
'name:en',
'operator',
'brand',
'opening_hours',
'wheelchair',
'emergency',
'healthcare',
'addr:street',
'addr:city',
];
const quote = (text) =>
`"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
/**
* Parse and bound a request. Returns the compiled query or a refusal.
* @param {{preset: string, bbox: string|number[], mode?: string, limit?: string|number}} request
* @returns {{ok: true, ql: string, mode: string, preset: object, limit: number, boxes: number[][]}|{ok: false, code: string, error: string}}
*/
export function compileOsmFeatureQuery(request = {}) {
const refuse = (code, error) => ({ ok: false, code, error });
const presetId = String(request.preset || '');
const preset = Object.hasOwn(OSM_PRESETS, presetId)
? OSM_PRESETS[presetId]
: null;
if (!preset) return refuse('UNKNOWN_PRESET', 'Unknown feature kind.');
const mode = request.mode === 'count' ? 'count' : 'features';
const parts = (
Array.isArray(request.bbox)
? request.bbox
: String(request.bbox || '').split(',')
).map(Number);
if (parts.length !== 4 || !parts.every(Number.isFinite))
return refuse('BAD_BOX', 'bbox needs west,south,east,north.');
const [west, south, east, north] = parts;
if (
Math.abs(west) > 180 ||
Math.abs(east) > 180 ||
south < -90 ||
north > 90 ||
south >= north ||
west === east
)
return refuse('BAD_BOX', 'bbox edges are out of range.');
// A box crossing the antimeridian is searched as its two halves.
const boxes =
west < east
? [[south, west, north, east]]
: [
[south, west, north, 180],
[south, -180, north, east],
];
const deg2 = boxes.reduce((sum, [s, w, n, e]) => sum + (n - s) * (e - w), 0);
const cap =
mode === 'count'
? OSM_FEATURE_LIMITS.maxCountDeg2
: OSM_FEATURE_LIMITS.maxFetchDeg2;
if (deg2 > cap)
return refuse(
'AREA_TOO_LARGE',
mode === 'count'
? 'That area is too large to search; narrow it down.'
: 'That area is too large to list; count first or narrow it down.',
);
// An absent or empty limit is the default, not Number(null) = 0.
const requested =
request.limit === null ||
request.limit === undefined ||
String(request.limit).trim() === ''
? NaN
: Number(request.limit);
const limit = Math.max(
1,
Math.min(
OSM_FEATURE_LIMITS.maxLimit,
Number.isFinite(requested) ? Math.trunc(requested) : 500,
),
);
const selectors = [];
for (const [key, value] of preset.tags)
for (const [s, w, n, e] of boxes)
selectors.push(
`nwr[${quote(key)}=${quote(value)}](${s},${w},${n},${e});`,
);
const ql =
`[out:json][timeout:${OSM_FEATURE_LIMITS.timeoutS}][maxsize:${OSM_FEATURE_LIMITS.maxsize}];` +
`(${selectors.join('')});` +
(mode === 'count' ? 'out count;' : `out center tags ${limit};`);
return { ok: true, ql, mode, preset, limit, boxes };
}
/**
* Overpass JSON → the compact answer the browser gets.
* @param {object} payload Parsed Overpass JSON.
* @param {{mode: string, preset: object, limit: number}} compiled
*/
export function summarizeOsmFeatures(payload, compiled) {
if (!Array.isArray(payload?.elements))
throw new TypeError('Malformed Overpass feature response');
const elements = payload.elements;
if (compiled.mode === 'count') {
const tags = elements.find((el) => el?.type === 'count')?.tags;
const rawTotal = tags?.total;
const total = Number(rawTotal);
if (
!tags ||
!Object.hasOwn(tags, 'total') ||
!['string', 'number'].includes(typeof rawTotal) ||
String(rawTotal).trim() === '' ||
!Number.isSafeInteger(total) ||
total < 0
)
throw new TypeError('Malformed Overpass count response');
return {
mode: 'count',
preset: compiled.preset.id,
count: total,
nodes: Number(tags.nodes) || 0,
ways: Number(tags.ways) || 0,
relations: Number(tags.relations) || 0,
};
}
const features = [];
let unplaced = 0;
for (const el of elements) {
if (
!['node', 'way', 'relation'].includes(el?.type) ||
(!Number.isFinite(Number(el?.id)) && typeof el?.id !== 'string')
) {
unplaced += 1;
continue;
}
const lat = Number.isFinite(el?.lat) ? el.lat : el?.center?.lat;
const lon = Number.isFinite(el?.lon) ? el.lon : el?.center?.lon;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
unplaced += 1;
continue;
}
const tags = {};
for (const key of [...KEPT_TAGS, ...compiled.preset.tags.map(([k]) => k)])
if (typeof el.tags?.[key] === 'string')
tags[key] = el.tags[key].slice(0, 120);
features.push({ id: `${el.type}/${el.id}`, lat, lon, tags });
}
// A genuinely empty `elements` array is authoritative. A non-empty answer
// whose every row is unusable is a schema failure, not "zero places"; do
// not let the route cache that as current state for a day.
if (elements.length && features.length === 0)
throw new TypeError('Overpass feature response has no usable rows');
return {
mode: 'features',
preset: compiled.preset.id,
count: features.length,
features,
truncated: elements.length >= compiled.limit,
...(unplaced ? { unplaced } : {}),
};
}
@@ -0,0 +1,252 @@
import path from 'node:path';
import { makeRateLimiter, clientNetworkKey } from '../common/rate-limit.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { createBoundedCache } from '../common/boundedCache.js';
import {
compileOsmFeatureQuery,
OSM_FEATURE_LIMITS,
summarizeOsmFeatures,
} from './osmFeatures.js';
import {
resolveOverpassUpstreams,
resolveOverpassUpstreamTag,
} from './constants.js';
import { fetchOverpassPayload, overpassPayloadIsData } from './transport.js';
/** Places of interest change slowly: answers are kept a day. */
const OSM_FEATURES_TTL_MS = 86_400_000;
/** Upstream searches at once, across all clients (one per client). */
const OSM_MAX_RUNNING = 2;
/** Cache-key version; nothing cached under another version is reused. */
const CACHE_VERSION = 'v2';
/** Never simplify: these answers are points and counts, not drawings. */
const keepBody = (body) => body;
const jsonError = (status, error, extra = {}) => ({
status,
body: JSON.stringify({ error, ...extra }),
});
/**
* One upstream Overpass query at a time, with identical requests sharing it
* and answers in a bounded memory + disk cache.
*/
function createOverpassAnswerer({
fetchPayload,
cache,
maxResponseBytes,
upstreamTimeoutMs,
upstreamBudgetMs,
maxRunning = OSM_MAX_RUNNING,
}) {
const inFlight = new Map();
// One search per client, a few in all: one client can't hold the route.
const runningBy = new Map();
let running = 0;
return async function answer(key, ql, summarize, signal = null, owner = '') {
signal?.throwIfAborted?.();
const held = await cache.get(key);
signal?.throwIfAborted?.();
if (typeof held === 'string')
return { status: 200, body: held, cache: 'HIT' };
let operation = inFlight.get(key);
if (operation?.controller.signal.aborted) {
inFlight.delete(key);
operation = null;
}
if (!operation) {
if (runningBy.get(owner) || running >= maxRunning)
return {
...jsonError(
503,
runningBy.get(owner)
? 'Your previous map search is still running — try again shortly.'
: 'Another map search is running — try again shortly.',
),
retryAfter: '3',
};
running += 1;
runningBy.set(owner, (runningBy.get(owner) || 0) + 1);
const controller = new AbortController();
// The whole search, every configured endpoint in turn, gets one budget.
const budget = AbortSignal.timeout(upstreamBudgetMs);
operation = { controller, waiters: 0, settled: false, promise: null };
const owned = operation;
owned.promise = (async () => {
try {
const payload = await fetchPayload(
`data=${encodeURIComponent(ql)}`,
maxResponseBytes,
{
simplify: keepBody,
timeoutMs: upstreamTimeoutMs,
signal: AbortSignal.any([controller.signal, budget]),
},
);
if (!overpassPayloadIsData(payload))
return {
...jsonError(
payload?.rateLimited ? 429 : 503,
'OpenStreetMap search is unavailable right now.',
),
retryAfter: '10',
};
let parsed;
try {
parsed = JSON.parse(payload.body);
} catch {
return jsonError(
502,
'OpenStreetMap returned an unreadable answer.',
);
}
const body = JSON.stringify(summarize(parsed));
controller.signal.throwIfAborted();
await cache.set(key, body);
controller.signal.throwIfAborted();
return { status: 200, body, cache: 'MISS' };
} catch {
return budget.aborted && !controller.signal.aborted
? jsonError(504, 'OpenStreetMap took too long to answer.')
: jsonError(502, 'OpenStreetMap search failed.');
} finally {
owned.settled = true;
running -= 1;
const left = (runningBy.get(owner) || 1) - 1;
if (left > 0) runningBy.set(owner, left);
else runningBy.delete(owner);
if (inFlight.get(key) === owned) inFlight.delete(key);
}
})();
inFlight.set(key, owned);
}
operation.waiters += 1;
let abort = null;
const abandoned = signal
? new Promise((_, reject) => {
abort = () => {
operation.waiters -= 1;
if (operation.waiters === 0 && !operation.settled)
operation.controller.abort();
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
})
: null;
try {
return await (abandoned
? Promise.race([operation.promise, abandoned])
: operation.promise);
} finally {
if (signal && abort) signal.removeEventListener('abort', abort);
if (!signal?.aborted) operation.waiters -= 1;
}
};
}
function sender(res) {
return ({ status, body, cache, retryAfter }) => {
if (res.writableEnded) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(cache ? { 'X-OSM-Cache': cache } : {}),
...(retryAfter ? { 'Retry-After': retryAfter } : {}),
});
res.end(body);
};
}
/**
* Install `GET /api/osm/features?preset=&bbox=w,s,e,n[&mode=count][&limit=]`:
* one curated preset inside a box, answered by the operator's Overpass
* (`OVERPASS_UPSTREAMS`). With none configured the route answers
* `OVERPASS_NOT_CONFIGURED` and fetches nothing; public Nominatim is never a
* substitute for bulk place search.
*
* @param {{use: Function}} middlewares
* @param {{fetchPayload?: Function, featuresCache?: object, cacheRoot?: string|null, configured?: () => boolean, upstreamTag?: () => string}} [deps]
*/
export function installOsmFeaturesRoute(
middlewares,
{
fetchPayload = fetchOverpassPayload,
configured = () => resolveOverpassUpstreams().length > 0,
// Keys each answer to the instance that gave it, as the other Overpass
// caches do, so repointing OVERPASS_UPSTREAMS never serves the old one's.
upstreamTag = resolveOverpassUpstreamTag,
upstreamBudgetMs = OSM_FEATURE_LIMITS.upstreamBudgetMs,
cacheRoot = path.join(process.cwd(), '.gev-cache'),
featuresCache = createBoundedCache({
dir: cacheRoot ? path.join(cacheRoot, 'osm-features') : null,
ttlMs: OSM_FEATURES_TTL_MS,
maxMemoryEntries: 60,
maxDiskEntries: 400,
maxDiskBytes: 256 * 1024 * 1024,
}),
} = {},
) {
const limiter = makeRateLimiter({ windowMs: 60_000, max: 12, globalMax: 30 });
const features = createOverpassAnswerer({
fetchPayload,
cache: featuresCache,
maxResponseBytes: OSM_FEATURE_LIMITS.maxResponseBytes,
upstreamTimeoutMs: OSM_FEATURE_LIMITS.upstreamTimeoutMs,
upstreamBudgetMs,
});
middlewares.use('/api/osm/features', async (req, res) => {
const send = sender(res);
if (req.method !== 'GET') return send(jsonError(405, 'Method Not Allowed'));
// A page elsewhere must not spend the operator's Overpass. The keyless
// gate, as on /api/overpass: voice works behind a reverse proxy too.
if (admitSameSiteKeyless(req, res)) return undefined;
if (!configured())
return send(
jsonError(503, 'Place search needs a configured Overpass server.', {
code: 'OVERPASS_NOT_CONFIGURED',
retryable: false,
}),
);
const url = new URL(req.url || '', 'http://localhost');
const compiled = compileOsmFeatureQuery({
preset: url.searchParams.get('preset'),
bbox: url.searchParams.get('bbox'),
mode: url.searchParams.get('mode'),
limit: url.searchParams.get('limit'),
});
if (!compiled.ok)
return send(jsonError(400, compiled.error, { code: compiled.code }));
// IPv6 peers by /64, as /api/overpass does: rotating addresses inside
// one prefix earns neither a second rate bucket nor a second slot.
const client = clientNetworkKey(req);
if (!limiter(client))
return send({
...jsonError(429, 'Rate limit exceeded'),
retryAfter: '10',
});
const controller = new AbortController();
const abort = () => controller.abort();
req.once?.('aborted', abort);
res.once?.('close', abort);
try {
const answer = await features(
`${CACHE_VERSION}|features|${upstreamTag()}|${compiled.ql}`,
compiled.ql,
(parsed) => summarizeOsmFeatures(parsed, compiled),
controller.signal,
client,
);
if (!controller.signal.aborted) send(answer);
} catch {
if (!controller.signal.aborted)
send(jsonError(502, 'OpenStreetMap search failed.'));
} finally {
req.removeListener?.('aborted', abort);
res.removeListener?.('close', abort);
}
});
}
+13 -3
View File
@@ -124,6 +124,8 @@ async function fetchOverpassPayload(
readBody = readResponseTextCapped,
simplify = simplifyOverpassPayloadBody,
now = Date.now,
timeoutMs = OVERPASS_TIMEOUT_MS,
signal = null,
} = {},
) {
if (!endpoints.length) return overpassNotConfigured();
@@ -135,7 +137,10 @@ async function fetchOverpassPayload(
continue;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const requestSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
try {
const requestUrl = new URL(endpoint);
const authorization =
@@ -156,7 +161,7 @@ async function fetchOverpassPayload(
...(authorization ? { Authorization: authorization } : {}),
},
body,
signal: controller.signal,
signal: requestSignal,
});
const responseBody = await readBody(upstream, maxResponseBytes);
const parsed = parseJson(responseBody);
@@ -207,7 +212,12 @@ async function fetchOverpassPayload(
upstreams: resolveOverpassUpstreamTag(),
rateLimited: false,
};
} catch {
} catch (error) {
// A route whose last requester disconnected owns this cancellation. It
// is not evidence that the configured upstream is unhealthy, so do not
// put the endpoint on cooldown or translate the abort into a response
// that a now-gone client could cache.
if (signal?.aborted) throw signal.reason ?? error;
const failures = (previous?.failures || 0) + 1;
const delay = retryDelay(null, failures - 1, now());
cooldowns.set(endpoint, { until: now() + delay, failures, status: 502 });
@@ -204,6 +204,97 @@ const ADMIN_EVIDENCE = Object.freeze({
},
});
/**
* Nominatim address types whose meaning is stable enough for the area consumer's
* country/admin1/admin2 contract. `region`, `district` and numeric admin levels
* vary by country, so they remain unverified instead of being guessed.
*/
const VERIFIED_ADMIN_LEVEL = Object.freeze({
country: 'country',
state: 'admin1',
province: 'admin1',
county: 'admin2',
state_district: 'admin2',
});
const EXPLICIT_NAME_LEVEL = Object.freeze({
country: 'country',
nation: 'country',
province: 'admin1',
state: 'admin1',
region: 'admin1',
oblast: 'admin1',
prefecture: 'admin1',
canton: 'admin1',
governorate: 'admin1',
county: 'admin2',
district: 'admin2',
department: 'admin2',
zone: 'retired',
});
function explicitNameEvidence(value) {
const normalized = String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
const prefix = normalized.match(
/^(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)\s+of\b/,
)?.[1];
const suffix = normalized.match(
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
)?.[1];
const levels = new Set(
[prefix, suffix]
.filter(Boolean)
.map((type) => EXPLICIT_NAME_LEVEL[type])
.filter(Boolean),
);
if (levels.size > 1) return { contradictory: true, level: null };
return { contradictory: false, level: levels.values().next().value || null };
}
/**
* Verified administrative identity carried by the selected result itself.
* Never infer it from the request, desired kind, rank, or a separate geocoder
* anchor. A retired name is preserved without a level so downstream policy can
* reject it explicitly; every other contradiction stays wholly unverified.
*/
function selectedAdminIdentity(kind, row) {
if (kind !== 'admin') return null;
const adminLevel =
VERIFIED_ADMIN_LEVEL[String(row?.addresstype || '').toLowerCase()];
const authoritativeAdminArea = String(row?.name || '').trim();
if (!adminLevel || !authoritativeAdminArea) return null;
// Validate the complete authoritative name. `adminArea` is truncated only
// for display after every prefix/suffix signal has been considered.
const { contradictory, level: nameLevel } = explicitNameEvidence(
authoritativeAdminArea,
);
if (contradictory) return null;
const adminArea = authoritativeAdminArea.slice(0, 160);
if (nameLevel === 'retired') {
// Nepal replaced its zones in 2015, but zones are current units elsewhere
// (Ethiopia). Without a country the name stays retired.
if (!zoneIsCurrentIn(row)) return { adminArea };
return { adminArea, adminLevel };
}
if (nameLevel && nameLevel !== adminLevel) return null;
return { adminArea, adminLevel };
}
/** Whether the row names a country (last display_name part) other than Nepal. */
function zoneIsCurrentIn(row) {
const country = String(row?.display_name || '')
.split(',')
.slice(1)
.pop()
?.trim()
.toLocaleLowerCase();
return Boolean(country) && country !== 'nepal';
}
/** Whether supplied administrative evidence contradicts the accepted identity. */
function adminLevelFits(kind, row) {
const evidence = ADMIN_EVIDENCE[kind];
@@ -584,6 +675,7 @@ export function selectOutlineResult(rows, { kind, query }) {
row.osm_type && row.osm_id != null
? `${row.osm_type}/${row.osm_id}`
: null,
...(selectedAdminIdentity(kind, row) || {}),
polygons,
},
});