mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Every distinct 5-decimal point was a permanent in-memory entry, a permanent line in an uncapped `.gev-cache/terrain-heights.json`, and a live upstream call. Nothing limited the rate, and coordinates were checked for being finite rather than for being on Earth, so an out-of-range pair was still a valid cache key. Four bounds, each following a pattern this repo already uses: - WGS-84 range is enforced in `parseTerrainPoints`, at the edge, so no later stage has to. Poles and the antimeridian still parse. - The point cache holds at most 50,000 entries and evicts oldest-first, the shape `trackBackfillProxies` uses. Sized from measurement: this checkout's cache file held 6,189 entries in 1.0 MB of real use, so the ceiling is eight times that working set. Terrain does not move, so an evicted point costs one refetch and never a wrong answer. - A cache file above 24 MB is not read back, mirroring `rocketLaunchesProxy`. An unmeasurable file is not treated as an oversized one: without stat, readFile decides, exactly as before. - The route carries an always-on limiter of 90/min per client, 270 global — the `/api/overpass` band, and an order of magnitude above measured demand (a full test:track run drove 10 requests, peak 8/min). The ceilings are constructor options so they can be exercised as behaviour rather than asserted as constants; production passes none. They are destructured in the body because `proxyErrorResponses.test.mjs` slices these proxies to the first column-zero closing brace.
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
import path from 'node:path';
|
|
import { promises as fsp } from 'node:fs';
|
|
|
|
import {
|
|
fetchTerrainChunkWithRetry,
|
|
parseTerrainPoints,
|
|
resolveTerrainHeightRequest,
|
|
terrainPointKey,
|
|
validTerrainResult,
|
|
} from '../../src/data/terrainHeightsProxy.js';
|
|
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
|
|
|
/**
|
|
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
|
|
* lookups, keyless. Upstream: https://terrain.reearth.land/heights.json
|
|
* (UPSTREAM_CHUNK points per call — sized against measured latency, see
|
|
* below). Terrain doesn't move, so results are cached to
|
|
* disk with a long TTL (30 days) — mirrors celestrakProxy's memory+disk
|
|
* cache and serve-stale shape. Cache entries and stale fallback are keyed per
|
|
* 5dp point, so reordered and partially overlapping batches reuse prior work.
|
|
* Only missing/stale points go upstream; the response is rebuilt in exact
|
|
* request order. Larger requests are chunked sequentially, and one failing
|
|
* chunk does not discard the chunks that resolved.
|
|
*
|
|
* Bounds, because every distinct 5dp point is its own key: coordinates must be
|
|
* in WGS-84 range (rejected at parse), the point cache has an entry ceiling
|
|
* with oldest-first eviction, an over-large cache file is not read back, and
|
|
* the route carries an always-on per-client limiter.
|
|
*/
|
|
export function terrainHeightsProxy(options = {}) {
|
|
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
|
|
// extracts these proxies by slicing to the first closing brace in column
|
|
// zero, so a multi-line parameter list would cut the function in half.
|
|
// Overridable so the ceilings can be exercised as behaviour rather than
|
|
// asserted as constants; production constructs this with no arguments.
|
|
const {
|
|
cacheMaxEntries = 50_000,
|
|
cacheMaxDiskBytes = 24 * 1024 * 1024,
|
|
cachePath,
|
|
} = options;
|
|
const TTL_MS = 30 * 24 * 3600_000;
|
|
const CACHE_PATH =
|
|
cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json');
|
|
const CACHE_DIR = path.dirname(CACHE_PATH);
|
|
// Sized against measured upstream latency, not the documented page cap.
|
|
// Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12,
|
|
// a 2x swing within one hour). 256 points therefore costs 22-48 s and blows
|
|
// the 30 s attempt timeout whenever the service is slow — the long-standing
|
|
// cause of "[terrain-heights-proxy] refresh incomplete" (owner incident
|
|
// 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of
|
|
// that range.
|
|
const UPSTREAM_CHUNK = 64;
|
|
const MAX_POINTS = 2000;
|
|
/**
|
|
* Entry ceiling for the point cache, and with it the file written from it.
|
|
*
|
|
* Every distinct 5dp point is a separate key, so without a ceiling the map
|
|
* — and the JSON dumped from it verbatim — grow for as long as a caller
|
|
* supplies new coordinates. Measured against real use rather than guessed:
|
|
* this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in
|
|
* 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is
|
|
* eight times that working set and ~8 MB on disk, comfortably inside the
|
|
* 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file.
|
|
*/
|
|
const CACHE_MAX_ENTRIES = cacheMaxEntries;
|
|
/** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */
|
|
const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes;
|
|
/**
|
|
* Requests/min/client. Terrain is keyless and free, so this guards the point
|
|
* cache and the upstream community service rather than a bill. Sized from
|
|
* measurement: across a full `npm run test:track` run — the heaviest
|
|
* exercise this app has — the route served 10 requests, peaking at 8/min.
|
|
* 90/min is an order of magnitude above that, and the band `/api/overpass`
|
|
* and `/api/military-installations` already use.
|
|
*/
|
|
const RATE_PER_MIN = 90;
|
|
const allow = makeRateLimiter({
|
|
windowMs: 60_000,
|
|
max: RATE_PER_MIN,
|
|
globalMax: RATE_PER_MIN * 3,
|
|
});
|
|
|
|
/** @type {Map<string, {at:number, result:object}>} keyed by canonical 5dp lon/lat. */
|
|
const mem = new Map();
|
|
|
|
/**
|
|
* Drop the oldest entries until the map is back inside its ceiling.
|
|
* Oldest-first by fetch time, the same shape `trackBackfillProxies` uses.
|
|
* Terrain does not move, so evicting an old point costs one refetch, never
|
|
* a wrong answer.
|
|
*/
|
|
function evictOverflow() {
|
|
if (mem.size <= CACHE_MAX_ENTRIES) return;
|
|
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1)
|
|
mem.delete(byAge[i][0]);
|
|
}
|
|
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
|
|
const inflight = new Map();
|
|
let diskLoaded = false;
|
|
let diskDirty = false;
|
|
|
|
/** Load the on-disk cache into memory once, lazily (first request only). */
|
|
async function loadDiskOnce() {
|
|
if (diskLoaded) return;
|
|
diskLoaded = true;
|
|
try {
|
|
// A cache file past the ceiling is not read back into memory: loading it
|
|
// would reinstate exactly the unbounded map the ceiling exists to stop.
|
|
// The next flush rewrites a bounded one.
|
|
//
|
|
// "Cannot measure it" is not "too large": if stat is unavailable, fall
|
|
// through and let readFile decide, so an absent file behaves exactly as
|
|
// it did before this guard existed.
|
|
let sizeBytes = null;
|
|
try {
|
|
sizeBytes = (await fsp.stat(CACHE_PATH)).size;
|
|
} catch {
|
|
sizeBytes = null;
|
|
}
|
|
if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES)
|
|
throw new Error('cache file too large');
|
|
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
|
|
const pointEntries =
|
|
parsed?.version === 2 &&
|
|
parsed.points &&
|
|
typeof parsed.points === 'object'
|
|
? parsed.points
|
|
: null;
|
|
if (pointEntries) {
|
|
for (const [key, entry] of Object.entries(pointEntries)) {
|
|
if (
|
|
entry &&
|
|
Number.isFinite(entry.at) &&
|
|
validTerrainResult(entry.result)
|
|
) {
|
|
mem.set(key, entry);
|
|
}
|
|
}
|
|
} else if (parsed && typeof parsed === 'object') {
|
|
// One-time migration from the former raw-batch cache. Zip only real,
|
|
// positionally present results; an absent value never becomes height 0.
|
|
for (const [rawPoints, entry] of Object.entries(parsed)) {
|
|
const points = parseTerrainPoints(rawPoints);
|
|
if (
|
|
!points ||
|
|
!entry ||
|
|
!Number.isFinite(entry.at) ||
|
|
!Array.isArray(entry.results)
|
|
)
|
|
continue;
|
|
for (let i = 0; i < points.length; i += 1) {
|
|
const result = entry.results[i];
|
|
if (!validTerrainResult(result)) continue;
|
|
const key = terrainPointKey(points[i]);
|
|
const existing = mem.get(key);
|
|
if (!existing || entry.at > existing.at)
|
|
mem.set(key, { at: entry.at, result });
|
|
}
|
|
}
|
|
diskDirty = mem.size > 0;
|
|
}
|
|
evictOverflow();
|
|
} catch {
|
|
/* no disk cache yet */
|
|
}
|
|
// Periodic flush, same shape as adsbdbProxy: coalesce writes instead of
|
|
// hitting disk on every request.
|
|
setInterval(async () => {
|
|
if (!diskDirty) return;
|
|
diskDirty = false;
|
|
try {
|
|
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
|
const obj = { version: 2, points: Object.fromEntries(mem.entries()) };
|
|
await fsp.writeFile(CACHE_PATH, JSON.stringify(obj), 'utf8');
|
|
} catch (err) {
|
|
diskDirty = true; // retry next tick
|
|
console.warn('[terrain-heights-proxy] cache write failed');
|
|
}
|
|
}, 15_000).unref?.();
|
|
}
|
|
|
|
/**
|
|
* Fetch all missing chunks sequentially, UPSTREAM_CHUNK points per call.
|
|
*
|
|
* The first try keeps its empirically required 30s timeout; network errors,
|
|
* 429, and 5xx receive up to three jittered retries sharing a 10s added-time
|
|
* budget, with Retry-After honored within that bound.
|
|
*
|
|
* A chunk that still fails yields nulls for its own positions rather than
|
|
* rejecting: with several chunks per request, throwing would discard every
|
|
* chunk that did resolve and re-fetch it on the next poll. The resolver
|
|
* caches the resolved positions and reports the null ones as omitted. Total
|
|
* failure (nothing resolved) still rejects, so the real upstream error
|
|
* reaches the caller instead of a generic omission count.
|
|
* @param {Array<[number, number]>} points
|
|
* @returns {Promise<Array<object>>}
|
|
*/
|
|
async function fetchUpstreamAll(points) {
|
|
const results = [];
|
|
let firstError = null;
|
|
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
|
|
const chunk = points.slice(i, i + UPSTREAM_CHUNK);
|
|
let chunkResults = [];
|
|
try {
|
|
chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
|
} catch (error) {
|
|
firstError = firstError || error;
|
|
}
|
|
// Keep later chunks aligned even if a malformed upstream response omits
|
|
// trailing positions. The resolver will reject each null individually.
|
|
for (let j = 0; j < chunk.length; j += 1)
|
|
results.push(chunkResults[j] ?? null);
|
|
}
|
|
if (firstError && results.every((result) => result == null))
|
|
throw firstError;
|
|
return results;
|
|
}
|
|
|
|
/** Coalesce concurrent requests for the same canonical missing-point list. */
|
|
function fetchMissingSingleFlight(points) {
|
|
const key = points.map(terrainPointKey).join(';');
|
|
if (!inflight.has(key)) {
|
|
const request = fetchUpstreamAll(points).finally(() => {
|
|
if (inflight.get(key) === request) inflight.delete(key);
|
|
});
|
|
inflight.set(key, request);
|
|
}
|
|
return inflight.get(key);
|
|
}
|
|
|
|
const installMiddleware = (server) => {
|
|
server.middlewares.use('/api/terrain/heights', async (req, res) => {
|
|
const send = (status, bodyObj) => {
|
|
if (res.headersSent) return;
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(bodyObj));
|
|
};
|
|
try {
|
|
if (!allow(clientKey(req))) {
|
|
res.writeHead(429, {
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': '10',
|
|
});
|
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
return;
|
|
}
|
|
await loadDiskOnce();
|
|
const parsedUrl = new URL(req.url || '', 'http://internal');
|
|
const rawPoints = parsedUrl.searchParams.get('points');
|
|
const points = parseTerrainPoints(rawPoints);
|
|
if (!points) {
|
|
send(400, {
|
|
error:
|
|
'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers',
|
|
});
|
|
return;
|
|
}
|
|
if (points.length > MAX_POINTS) {
|
|
send(500, {
|
|
error: `too many points (${points.length}); max ${MAX_POINTS} per request`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const outcome = await resolveTerrainHeightRequest({
|
|
points,
|
|
cache: mem,
|
|
fetchMissing: fetchMissingSingleFlight,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
if (outcome.cacheChanged) {
|
|
evictOverflow();
|
|
diskDirty = true;
|
|
}
|
|
if (outcome.upstreamError) {
|
|
console.warn(
|
|
'[terrain-heights-proxy] refresh incomplete' +
|
|
' — serving stale points when available',
|
|
);
|
|
} else if (outcome.absentPoints > 0) {
|
|
// Not a refresh failure. The upstream answered every position and
|
|
// returned a null height for a few of them (~0.16%, transient);
|
|
// the next poll re-asks and the client meanwhile resolves those
|
|
// through its bundled geoid. Informational, not actionable.
|
|
console.info(
|
|
`[terrain-heights-proxy] ${outcome.absentPoints}` +
|
|
` of ${outcome.requestedPoints} position(s) had no upstream` +
|
|
' height this poll — retrying next cycle',
|
|
);
|
|
}
|
|
send(outcome.status, outcome.body);
|
|
} catch (err) {
|
|
console.error('[terrain-heights-proxy] request failed');
|
|
send(500, { error: 'terrain heights proxy error' });
|
|
}
|
|
});
|
|
};
|
|
return {
|
|
name: 'terrain-heights-proxy',
|
|
configureServer: installMiddleware,
|
|
configurePreviewServer: installMiddleware,
|
|
};
|
|
}
|