mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Bins airborne ADS-B v1/v2 aircraft that report degraded navigation integrity (NIC < 7, NACp < 8, or readsb's GPS-loss flag) into 0.5 degree cells over a rolling 30-minute window, coloured with the gpsjam.org bands. Keyless: reads the capped adsb.lol point snapshot around the view through /api/gnss-integrity, which returns integrity fields only, caches 60 s per whole-degree anchor, backs off on upstream 429 and serves the last snapshot on failure. Adds scripts/qa-gnss.mjs. Built by the Hex project (Crew Leo Agile dev team). Co-Authored-By: Claude <[email protected]>
136 lines
4.7 KiB
JavaScript
136 lines
4.7 KiB
JavaScript
import { normalizeGnssAircraft } from '../../src/layers/gnss/records.js';
|
|
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
|
|
import { requiredFiniteQueryNumber } from './common/query.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.
|
|
const RADIUS_NM = 250;
|
|
const CACHE_MS = 60_000;
|
|
const CACHE_MAX = 64;
|
|
const COOLDOWN_MS = 60_000;
|
|
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 params = new URL(url || '/', 'http://localhost').searchParams;
|
|
const lat = requiredFiniteQueryNumber(params, 'lat');
|
|
const lon = requiredFiniteQueryNumber(params, 'lon');
|
|
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return null;
|
|
if (!Number.isFinite(lon) || lon < -180 || lon > 180) return null;
|
|
return { lat: Math.round(lat), lon: Math.round(lon) };
|
|
}
|
|
|
|
/** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */
|
|
export function gnssIntegrityProxy({
|
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
now = () => Date.now(),
|
|
} = {}) {
|
|
const cache = new Map();
|
|
const inFlight = new Map();
|
|
const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 });
|
|
let cooldownUntil = 0;
|
|
|
|
async function load({ lat, lon }) {
|
|
if (now() < cooldownUntil)
|
|
throw Object.assign(new Error('cooling_down'), { status: 429 });
|
|
const signal = AbortSignal.timeout(TIMEOUT_MS);
|
|
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 retryS = Number(response.headers.get('retry-after'));
|
|
cooldownUntil =
|
|
now() +
|
|
(Number.isFinite(retryS) && retryS > 0
|
|
? Math.min(retryS * 1000, 10 * COOLDOWN_MS)
|
|
: COOLDOWN_MS);
|
|
}
|
|
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');
|
|
return {
|
|
fetchedAt: now(),
|
|
anchor: { lat, lon },
|
|
radiusNm: RADIUS_NM,
|
|
rows,
|
|
};
|
|
}
|
|
|
|
async function acquire(anchor) {
|
|
const key = `${anchor.lat},${anchor.lon}`;
|
|
const previous = cache.get(key);
|
|
if (previous && now() - previous.fetchedAt < CACHE_MS)
|
|
return { value: previous, stale: false };
|
|
try {
|
|
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
|
|
const value = await load(anchor);
|
|
cache.delete(key);
|
|
cache.set(key, value);
|
|
while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
return value;
|
|
});
|
|
return { value: await promise, stale: false };
|
|
} catch (error) {
|
|
if (previous) return { value: previous, 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));
|
|
};
|
|
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' });
|
|
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
|
try {
|
|
const { value, stale } = await acquire(anchor);
|
|
json(200, stale ? { ...value, stale: true } : value, stale);
|
|
} catch (error) {
|
|
json(error.status === 429 ? 429 : 502, {
|
|
error: 'gnss_integrity_unavailable',
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: 'gnss-integrity',
|
|
configureServer({ middlewares }) {
|
|
middlewares.use('/api/gnss-integrity', handler);
|
|
},
|
|
configurePreviewServer({ middlewares }) {
|
|
middlewares.use('/api/gnss-integrity', handler);
|
|
},
|
|
};
|
|
}
|