mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: split terrain traffic fire and bike-share providers
This commit is contained in:
1 parent
3ec3fe6822
commit
f4054469d5
13 files changed
+1024
-797
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
|
||||
import {
|
||||
fetchTerrainChunkWithRetry,
|
||||
parseTerrainPoints,
|
||||
resolveTerrainHeightRequest,
|
||||
terrainPointKey,
|
||||
validTerrainResult,
|
||||
} from '../../src/data/terrainHeightsProxy.js';
|
||||
|
||||
/**
|
||||
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
|
||||
* lookups, keyless. Upstream: https://terrain.reearth.land/heights.json
|
||||
* (≤256 points per call). 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. Oversized requests (>256 points) are chunked sequentially.
|
||||
*/
|
||||
export function terrainHeightsProxy() {
|
||||
const TTL_MS = 30 * 24 * 3600_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json');
|
||||
const UPSTREAM_CHUNK = 256;
|
||||
const MAX_POINTS = 2000;
|
||||
|
||||
/** @type {Map<string, {at:number, result:object}>} keyed by canonical 5dp lon/lat. */
|
||||
const mem = new Map();
|
||||
/** @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 {
|
||||
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;
|
||||
}
|
||||
} 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 caps each call at 256).
|
||||
*
|
||||
* 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.
|
||||
* @param {Array<[number, number]>} points
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
async function fetchUpstreamAll(points) {
|
||||
const results = [];
|
||||
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
|
||||
const chunk = points.slice(i, i + UPSTREAM_CHUNK);
|
||||
const chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'terrain-heights-proxy',
|
||||
configureServer(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 {
|
||||
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) diskDirty = true;
|
||||
if (outcome.upstreamError) {
|
||||
console.warn(
|
||||
'[terrain-heights-proxy] refresh incomplete'
|
||||
+ ' — serving stale points when available'
|
||||
);
|
||||
}
|
||||
send(outcome.status, outcome.body);
|
||||
} catch (err) {
|
||||
console.error('[terrain-heights-proxy] request failed');
|
||||
send(500, { error: 'terrain heights proxy error' });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user