Files
gods-eye-view/server/providers/gnssIntegrity.js

239 lines
8.3 KiB
JavaScript

import {
GNSS_CLASSIFIER,
GNSS_WINDOW_MS,
normalizeGnssAircraft,
} from '../../src/layers/gnss/records.js';
import { adsbLolFallbackAnchor } from './aircraft/opensky.js';
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
// adsb.lol regional snapshot (keyless, ODbL). Only the integrity fields the
// GNSS layer needs leave this proxy, so the browser never sees the full feed.
// This is a separate read from the flights fallback on purpose: that point
// cache keeps only the fields the flights layer renders (no nic / nac_p), is
// keyed on 0.25° anchors and lives for 12 s, while integrity cells need
// whole-degree anchors held for a minute.
const RADIUS_NM = 250;
const CACHE_MS = 60_000;
const CACHE_MAX = 64;
/** Default 429 cooldown when upstream sends no usable Retry-After. */
const COOLDOWN_MS = 60_000;
/** Bound sane upstream deadlines to one hour; never shorten a deadline within that range. */
const COOLDOWN_MIN_MS = 5_000;
const COOLDOWN_MAX_MS = 60 * 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
const MAX_IN_FLIGHT = 8;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const TIMEOUT_MS = 10_000;
/** Snap a view anchor to a whole degree so nearby views share one upstream read. */
export function gnssIntegrityAnchor(url) {
const anchor = adsbLolFallbackAnchor({ url });
if (!anchor) return null;
return {
lat: Math.round(anchor.latitude),
lon: Math.round(anchor.longitude),
};
}
/** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */
export function gnssRetryCooldownMs(raw, nowMs) {
if (raw) {
const seconds = /^\d+(?:\.\d+)?$/.test(raw.trim()) ? Number(raw) : NaN;
const ms = seconds * 1000;
if (Number.isFinite(seconds) && seconds > 0)
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms));
const at =
Number.isNaN(seconds) && /[a-z]/i.test(raw) ? Date.parse(raw) : NaN;
if (Number.isFinite(at) && at > nowMs)
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, at - nowMs));
}
return COOLDOWN_MS;
}
/** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */
export function gnssIntegrityProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
monotonicNow = () => performance.now(),
warn = (...args) => console.warn(...args),
} = {}) {
const cache = new Map();
const inFlight = new Map();
const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 });
let cooldownUntil = 0;
let cooldownStatus = 429;
async function load({ lat, lon }) {
if (monotonicNow() < cooldownUntil)
throw Object.assign(new Error('cooling_down'), {
status: cooldownStatus,
});
const signal = AbortSignal.timeout(TIMEOUT_MS);
try {
const response = await fetchImpl(
`https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`,
{
signal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-gnss-integrity/1.0',
},
},
);
if (!response.ok) {
await response.body?.cancel();
if (response.status === 429) {
const at = monotonicNow();
const raw = response.headers.get('retry-after');
const delay = gnssRetryCooldownMs(raw, now());
if (delay === COOLDOWN_MAX_MS)
warn('[gnss-integrity] upstream cooldown capped at one hour');
cooldownStatus = 429;
cooldownUntil = Math.max(cooldownUntil, at + delay);
throw Object.assign(new Error('upstream_rate_limited'), {
status: 429,
});
}
throw new Error('upstream_unavailable');
}
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
signal,
);
const rows = normalizeGnssAircraft(payload);
if (!rows) throw new Error('invalid_snapshot');
const sourceNow = payload.now;
if (!Number.isFinite(sourceNow) || sourceNow <= 0)
throw new Error('undated_snapshot');
const fetchedAt = Math.min(
now(),
sourceNow > 10_000_000_000 ? sourceNow : sourceNow * 1000,
);
if (now() - fetchedAt > GNSS_WINDOW_MS)
throw new Error('expired_snapshot');
return {
fetchedAt,
anchor: { lat, lon },
radiusNm: RADIUS_NM,
// `degraded` on each row is this classifier's verdict, not gpsjam's.
classifier: GNSS_CLASSIFIER.id,
rows,
};
} catch (error) {
if (error.status !== 429) {
const until = monotonicNow() + FAILURE_COOLDOWN_MS;
if (until > cooldownUntil) {
cooldownUntil = until;
cooldownStatus = 502;
}
}
throw error;
}
}
function age(entry) {
if (!entry) return Infinity;
const elapsed = monotonicNow() - entry.savedAt;
// A backwards injected clock cannot rejuvenate a snapshot.
return elapsed < 0 ? Infinity : entry.ageAtReceipt + elapsed;
}
async function acquire(anchor, peer) {
const key = `${anchor.lat},${anchor.lon}`;
const previous = cache.get(key);
if (age(previous) < CACHE_MS)
return { value: previous.value, ageMs: age(previous), stale: false };
try {
if (!inFlight.has(key) && inFlight.size >= MAX_IN_FLIGHT)
throw Object.assign(new Error('busy'), { status: 429, local: true });
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
if (monotonicNow() >= cooldownUntil && !allow(peer))
throw Object.assign(new Error('rate_limited'), {
status: 429,
local: true,
});
const value = await load(anchor);
const entry = {
value,
savedAt: monotonicNow(),
ageAtReceipt: Math.max(0, now() - value.fetchedAt),
};
cache.delete(key);
cache.set(key, entry);
while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return entry;
});
const entry = await promise;
const ageMs = age(entry);
return { value: entry.value, ageMs, stale: ageMs >= CACHE_MS };
} catch (error) {
if (age(previous) <= GNSS_WINDOW_MS)
return { value: previous.value, ageMs: age(previous), stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, stale = false, retryAfterS = 60) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...([429, 502].includes(status)
? { 'Retry-After': String(retryAfterS) }
: {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
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' });
const anchor = gnssIntegrityAnchor(req.url);
if (!anchor) return json(400, { error: 'invalid_anchor' });
try {
const { value, stale, ageMs } = await acquire(anchor, clientKey(req));
// ageMs is measured on this server's clock, so the browser can date
// the rows without comparing its clock to ours.
json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale);
} catch (error) {
if (error.status === 429) {
// Tell the browser how long the shared upstream cooldown has left.
const retryAfterS = Math.max(
1,
error.local ? 60 : Math.ceil((cooldownUntil - monotonicNow()) / 1000),
);
return json(
429,
{
error: error.local ? error.message : 'gnss_integrity_rate_limited',
},
false,
retryAfterS,
);
}
json(
502,
{ error: 'gnss_integrity_unavailable' },
false,
Math.max(1, Math.ceil((cooldownUntil - monotonicNow()) / 1000)),
);
}
}
return {
name: 'gnss-integrity',
configureServer({ middlewares }) {
middlewares.use('/api/gnss-integrity', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/gnss-integrity', handler);
},
};
}