refactor: separate aircraft and vessel server providers

This commit is contained in:
Sameh Khamis committed 2026-09-11 19:06:09 -07:00
1 parent 149517da81
commit 0e221441b4
17 files changed
+1892 -1615

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
/**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
*
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. On upstream
* failure, serves a stale cached response if one exists.
*
* @returns {import('vite').Plugin}
*/
export function adsbLolProxy() {
/** @type {string|null} Cached upstream JSON body. */
let _cache = null;
/** @type {number} Epoch-ms when the cache was populated. */
let _cacheAt = 0;
/** Response cache TTL (ms). */
const CACHE_MS = 12000;
return {
name: 'adsblol-proxy',
configureServer(server) {
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
try {
const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-ADS-B-Cache': 'HIT' });
res.end(_cache);
return;
}
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
});
const body = await upstream.text();
if (upstream.ok) {
_cache = body;
_cacheAt = now;
}
res.writeHead(upstream.status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-ADS-B-Cache': 'MISS' });
res.end(body);
} catch (e) {
console.error('[adsb.lol Proxy]', e.message);
if (_cache) {
res.writeHead(200, { 'Content-Type': 'application/json', 'X-ADS-B-Cache': 'STALE' });
res.end(_cache);
return;
}
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ADS-B proxy error' }));
}
});
},
};
}
+123
View File
@@ -0,0 +1,123 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
/**
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
* airports) and hex → aircraft type/registration. Free community API — cached
* aggressively: ONE upstream request per new key ever (404s negative-cached),
* persisted to disk so restarts don't re-hammer it. Adapted from skylight
* (MIT) server/src/enrich/routes.ts.
*/
export function adsbdbProxy() {
const TTL_MS = 24 * 3600_000;
const CACHE_PATH = path.join(process.cwd(), '.gev-cache', 'adsbdb.json');
let cache = { routes: {}, aircraft: {} };
let dirty = false;
let loaded = false;
const inflight = new Map();
async function loadOnce() {
if (loaded) return;
loaded = true;
try {
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
cache = { routes: parsed.routes ?? {}, aircraft: parsed.aircraft ?? {} };
} catch { /* first run */ }
setInterval(async () => {
if (!dirty) return;
dirty = false;
try {
await fsp.mkdir(path.dirname(CACHE_PATH), { recursive: true });
await fsp.writeFile(CACHE_PATH, JSON.stringify(cache), 'utf8');
} catch { dirty = true; } // retry next tick
}, 15_000).unref?.();
}
const fresh = (e) => e && Date.now() - e.at < TTL_MS;
function parseRoute(json) {
const fr = json?.response?.flightroute;
if (!fr?.origin || !fr?.destination) return null;
const airport = (a) => ({
code: a.iata_code || a.icao_code || '',
name: a.municipality || a.name || '',
lat: Number.isFinite(a.latitude) ? a.latitude : null,
lon: Number.isFinite(a.longitude) ? a.longitude : null,
});
return { airline: fr.airline?.name || null, origin: airport(fr.origin), destination: airport(fr.destination) };
}
function parseAircraft(json) {
const a = json?.response?.aircraft;
if (!a) return null;
return {
typeCode: a.icao_type || null, // ICAO designator, e.g. "B738" — feeds classifyAircraft
typeName: a.manufacturer && a.type ? `${a.manufacturer} ${a.type}` : (a.type || null),
registration: a.registration || null,
};
}
function lookup(kind, key) {
const store = kind === 'route' ? cache.routes : cache.aircraft;
if (fresh(store[key])) return Promise.resolve(store[key].data);
const ik = `${kind}:${key}`;
if (!inflight.has(ik)) {
inflight.set(ik, (async () => {
try {
const url = kind === 'route'
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (res.ok) {
const data = kind === 'route' ? parseRoute(await res.json()) : parseAircraft(await res.json());
store[key] = { at: Date.now(), data }; // data may be null — negative cache
dirty = true;
return data;
}
if (res.status === 404) {
store[key] = { at: Date.now(), data: null }; // known-missing — cache the miss
dirty = true;
}
// other statuses: leave uncached so we retry later
return fresh(store[key]) ? store[key].data : null;
} catch {
return fresh(store[key]) ? store[key].data : null; // network error → stale if any
} finally {
inflight.delete(ik);
}
})());
}
return inflight.get(ik);
}
return {
name: 'adsbdb-proxy',
configureServer(server) {
server.middlewares.use('/api/adsbdb', async (req, res) => {
await loadOnce();
const send = (status, obj) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
};
try {
const [, kind, rawKey] = String(req.url || '').split('?')[0].split('/');
if (kind === 'route') {
const cs = String(rawKey || '').toUpperCase();
if (!/^[A-Z0-9]{2,8}$/.test(cs)) return send(400, { error: 'invalid callsign' });
const data = await lookup('route', cs);
return send(200, data ? { found: true, ...data } : { found: false });
}
if (kind === 'type') {
const hex = String(rawKey || '').toLowerCase();
if (!/^[0-9a-f]{6}$/.test(hex)) return send(400, { error: 'invalid hex' });
const data = await lookup('aircraft', hex);
return send(200, data ? { found: true, ...data } : { found: false });
}
return send(404, { error: 'unknown endpoint' });
} catch (err) {
console.error('[adsbdb-proxy] request failed');
return send(500, { error: 'adsbdb proxy error' });
}
});
},
};
}
+596
View File
@@ -0,0 +1,596 @@
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import { coalesceProxyRequest, readResponseJsonCapped } from '../common/http.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
// ---------------------------------------------------------------------------
// OpenSky OAuth2 token + response cache state
// ---------------------------------------------------------------------------
/** @type {string|null} Current OAuth2 bearer token. */
let _openskyToken = null;
/** @type {number} Epoch-ms when the current token expires. */
let _openskyTokenExpiry = 0;
/** @type {Promise<string|null>|null} In-flight token refresh promise (coalesces concurrent callers). */
let _openskyTokenPromise = null;
/** @type {string|null} Cached upstream response body (JSON text). */
let _openskyCacheBody = null;
/** @type {number} HTTP status of the cached response. */
let _openskyCacheStatus = 0;
/** @type {number} Epoch-ms when the response was cached. */
let _openskyCacheTime = 0;
/** @type {{requestedMode:string,usedMode:string,reason:string}|null} Auth metadata for the cached response. */
let _openskyCacheMeta = null;
/** @type {number|null} Source snapshot epoch from the cached OpenSky body. */
let _openskyCacheSourceEpochMs = null;
/** TTL for the OpenSky response cache (ms). */
const OPENSKY_CACHE_MS = 9000;
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
// The global /states/all this proxy fetches costs 4 CREDITS per call against
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
// the whole quota in ~8h and the layer then hard-died until the daily reset
// ("rate limited for 48h" owner report; auth itself was fine). Three levers:
// 1. Adaptive TTL: OpenSky returns X-Rate-Limit-Remaining on success; as the
// budget thins, the proxy stretches its cache TTL so a full day of
// continuous use never exhausts it.
// 2. 429 cooldown: honor X-Rate-Limit-Retry-After-Seconds — no upstream
// attempts until it passes (bounded 30 s … 30 min).
// 3. Serve-stale: while rate-limited/cooling, serve the last-good body (200 +
// X-OpenSky-Stale) so the layer keeps rendering instead of dying.
/** @type {number} Current adaptive TTL (ms) — starts at the base cache TTL. */
let _openskyTtlMs = OPENSKY_CACHE_MS;
/** @type {number} Epoch-ms before which no upstream fetch is attempted. */
let _openskyCooldownUntil = 0;
/**
* Picks the cache TTL from the remaining daily credit budget.
* Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the
* later tiers stretch the day: >2400 → ~3 h of full freshness, then 30 s
* (~2.5 h), 90 s (~5 h), 300 s (~8 h) ≈ 18+ h of continuous use per day.
* @param {number} remaining - X-Rate-Limit-Remaining header value.
* @returns {number} TTL in ms.
*/
function openskyAdaptiveTtlMs(remaining) {
if (!Number.isFinite(remaining)) return OPENSKY_CACHE_MS;
if (remaining > 2400) return OPENSKY_CACHE_MS;
if (remaining > 1200) return 30_000;
if (remaining > 400) return 90_000;
return 300_000;
}
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
let _openskyAuthWarned = false;
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
let _openskyAuthModeWarned = false;
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
/** Set of valid OPENSKY_AUTH_MODE values. */
const OPENSKY_AUTH_MODE_SET = new Set(['basic', 'oauth', 'auto', 'anon']);
/** Regional civilian fallback cache, keyed by a coarse 0.25° view anchor. */
const _adsbLolPointCache = new Map();
/** Per-anchor single-flight map for concurrent regional fallback requests. */
const _adsbLolPointInFlight = new Map();
const ADSBLOL_POINT_CACHE_MS = 12000;
const ADSBLOL_POINT_CACHE_MAX = 80;
const ADSBLOL_POINT_RADIUS_NM = 250;
const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// A 200 response can still contain an old OpenSky snapshot. Past this point
// the viewport-scoped adsb.lol source is more honest and keeps local motion
// current instead of coasting a stale worldwide frame indefinitely.
const OPENSKY_SOURCE_STALE_MS = 120_000;
/**
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
*
* Uses the client_credentials grant against the OpenSky Keycloak realm.
* Concurrent callers share a single in-flight refresh promise so only
* one token request is issued at a time.
*
* @returns {Promise<string|null>} Bearer token string, or null if unavailable.
*/
export async function getOpenSkyToken() {
const now = Date.now();
// Return cached token if still valid (with 60 s safety margin)
if (_openskyToken && now < _openskyTokenExpiry - 60000) return _openskyToken;
// Coalesce concurrent refresh requests — if a refresh is already in-flight,
// return the same promise instead of issuing a duplicate token request
if (_openskyTokenPromise) return _openskyTokenPromise;
const clientId = process.env.OPENSKY_CLIENT_ID;
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
_openskyTokenPromise = (async () => {
try {
const res = await fetch(
'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
}
);
let data = null;
try {
data = await res.json();
} catch {
data = null;
}
const accessToken = data?.access_token;
const expiresIn = Number(data?.expires_in);
if (!res.ok || !accessToken) {
if (!_openskyAuthWarned) {
const detail = data?.error_description || data?.error || `HTTP ${res.status}`;
console.warn('[OpenSky] OAuth client_credentials failed:', detail);
_openskyAuthWarned = true;
}
_openskyToken = null;
_openskyTokenExpiry = 0;
return null;
}
_openskyToken = accessToken;
// Default to 1800 s (30 min) if expires_in is missing or non-finite
_openskyTokenExpiry = Date.now() + (Number.isFinite(expiresIn) ? expiresIn : 1800) * 1000;
console.log('[OpenSky] OAuth token refreshed, expires in', Number.isFinite(expiresIn) ? expiresIn : 1800, 's');
_openskyAuthWarned = false;
return _openskyToken;
} catch (err) {
if (!_openskyAuthWarned) {
console.warn('[OpenSky] OAuth token request failed:', err?.message || String(err));
_openskyAuthWarned = true;
}
_openskyToken = null;
_openskyTokenExpiry = 0;
return null;
} finally {
// Clear the shared promise so the next caller can start a fresh refresh
_openskyTokenPromise = null;
}
})();
return _openskyTokenPromise;
}
/**
* Validate and normalize the OPENSKY_AUTH_MODE env value.
*
* @param {string} value - Raw env value (e.g. 'basic', 'oauth', 'auto', 'anon').
* @returns {string} One of the valid mode strings, or the default ('oauth').
*/
function normalizeOpenSkyAuthMode(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) return OPENSKY_AUTH_MODE_DEFAULT;
if (OPENSKY_AUTH_MODE_SET.has(raw)) return raw;
if (!_openskyAuthModeWarned) {
console.warn(
`[OpenSky] Invalid OPENSKY_AUTH_MODE="${raw}", defaulting to "${OPENSKY_AUTH_MODE_DEFAULT}"`
);
_openskyAuthModeWarned = true;
}
return OPENSKY_AUTH_MODE_DEFAULT;
}
/**
* Build standard response headers for OpenSky proxy responses.
*
* Includes diagnostic X-OpenSky-* headers so the client can inspect
* cache hit/miss status and which auth mode was actually used.
*
* @param {object} opts
* @param {string} opts.cacheStatus - 'HIT', 'MISS', or 'STALE'.
* @param {string} opts.requestedMode - The auth mode the config requested.
* @param {string} opts.usedMode - The auth mode actually used for the upstream call.
* @param {string} opts.reason - Human-readable reason string for diagnostics.
* @returns {Record<string,string>} Header object.
*/
function buildOpenSkyHeaders({ cacheStatus, requestedMode, usedMode, reason, staleSeconds, retryAfterSeconds }) {
const headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-OpenSky-Cache': cacheStatus,
'X-OpenSky-Auth': usedMode,
'X-OpenSky-Auth-Mode-Requested': requestedMode,
'X-OpenSky-Auth-Mode-Used': usedMode,
'X-OpenSky-Auth-Reason': reason,
};
// Credit-governor extras (field-test fix 2026-07-06): the client can show a
// STALE cue / countdown without parsing the body.
if (Number.isFinite(staleSeconds)) headers['X-OpenSky-Stale-Seconds'] = String(Math.round(staleSeconds));
if (Number.isFinite(retryAfterSeconds)) headers['X-OpenSky-Retry-After-Seconds'] = String(Math.round(retryAfterSeconds));
return headers;
}
export function adsbLolFallbackAnchor(req) {
const incoming = new URL(req?.url || '', 'http://localhost');
const latitude = requiredFiniteQueryNumber(incoming.searchParams, 'lat');
const longitude = requiredFiniteQueryNumber(incoming.searchParams, 'lon');
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null;
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null;
return { latitude, longitude };
}
async function fetchAdsbLolPointFallback(req) {
const anchor = adsbLolFallbackAnchor(req);
if (!anchor) return null;
const roundedLat = Math.round(anchor.latitude * 4) / 4;
const roundedLon = Math.round(anchor.longitude * 4) / 4;
const cacheKey = `${roundedLat.toFixed(2)},${roundedLon.toFixed(2)}`;
const cached = _adsbLolPointCache.get(cacheKey);
const now = Date.now();
if (cached && now - cached.cachedAt < ADSBLOL_POINT_CACHE_MS) {
return { ...cached, cacheStatus: 'HIT' };
}
const request = coalesceProxyRequest(_adsbLolPointInFlight, cacheKey, async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const upstream = await fetch(
`https://api.adsb.lol/v2/lat/${roundedLat}/lon/${roundedLon}/dist/${ADSBLOL_POINT_RADIUS_NM}`,
{
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-adsblol-regional-fallback/1.0',
},
signal: controller.signal,
},
);
if (!upstream.ok) throw new Error(`upstream HTTP ${upstream.status}`);
const payload = await readResponseJsonCapped(upstream, ADSBLOL_POINT_MAX_RESPONSE_BYTES);
const normalized = normalizeAdsbLolPointResponse(payload);
const record = {
body: JSON.stringify(normalized),
cachedAt: Date.now(),
count: normalized.states.length,
};
_adsbLolPointCache.delete(cacheKey);
_adsbLolPointCache.set(cacheKey, record);
while (_adsbLolPointCache.size > ADSBLOL_POINT_CACHE_MAX) {
_adsbLolPointCache.delete(_adsbLolPointCache.keys().next().value);
}
return record;
} finally {
clearTimeout(timeoutId);
}
});
try {
const record = await request.promise;
return { ...record, cacheStatus: request.shared ? 'INFLIGHT' : 'MISS' };
} catch (error) {
if (!request.shared && error?.name !== 'AbortError') {
console.warn('[adsb.lol Flights Fallback]', error?.message || error);
}
return cached ? { ...cached, cacheStatus: 'STALE' } : null;
}
}
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
const fallback = await fetchAdsbLolPointFallback(req);
if (!fallback) return false;
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: fallback.cacheStatus,
requestedMode,
usedMode: 'adsblol-regional',
reason,
}),
'X-Flight-Source': 'adsb.lol',
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
'X-Flight-Count': String(fallback.count),
});
res.end(fallback.body);
return true;
}
function openSkySourceEpochMs(body) {
try {
const seconds = Number(JSON.parse(body)?.time);
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null;
} catch {
return null;
}
}
function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
return Number.isFinite(sourceEpochMs)
&& now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS;
}
/**
* Vite plugin: OpenSky Network proxy with multi-mode auth and response caching.
*
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
* - 'oauth' (default) — client_credentials bearer token
* - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD
* - 'auto' — try OAuth first, fall back to Basic, then anon
* - 'anon' — no credentials
*
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
* upstream failure the proxy serves a stale cached response if available, or
* a bounded 250 nm adsb.lol point snapshot around the current view anchor.
*
* @returns {import('vite').Plugin}
*/
export function openSkyProxy() {
return {
name: 'opensky-proxy',
configureServer(server) {
server.middlewares.use('/api/opensky', async (req, res) => {
try {
const requestedMode = normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE);
const now = Date.now();
const inCooldown = now < _openskyCooldownUntil;
// Fresh-enough cache (adaptive TTL) OR any cache during a 429
// cooldown: serve it without touching upstream. Stale-during-cooldown
// is deliberate (credit governor): last-good planes beat a dead layer.
if (_openskyCacheBody && (now - _openskyCacheTime < _openskyTtlMs || inCooldown)) {
if (
openSkySourceIsStale(_openskyCacheSourceEpochMs, now)
&& await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_snapshot_stale_regional_fallback',
)
) {
return;
}
const cachedMeta = _openskyCacheMeta || {
requestedMode,
usedMode: 'unknown',
reason: 'cached',
};
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
cacheStatus: isStale ? 'STALE' : 'HIT',
requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown',
reason: isStale ? 'rate_limited_serving_stale' : (cachedMeta.reason || 'cached'),
staleSeconds: isStale ? (now - _openskyCacheTime) / 1000 : undefined,
retryAfterSeconds: inCooldown ? (_openskyCooldownUntil - now) / 1000 : undefined,
})
);
res.end(_openskyCacheBody);
return;
}
// Cooling down with nothing cached (cold start into a rate limit):
// synthesize the 429 locally — hammering upstream mid-cooldown can't
// succeed and just burns goodwill.
if (inCooldown) {
if (await serveAdsbLolPointFallback(req, res, requestedMode, 'opensky_cooldown_regional_fallback')) return;
res.writeHead(429, buildOpenSkyHeaders({
cacheStatus: 'COOLDOWN',
requestedMode,
usedMode: 'none',
reason: 'rate_limited',
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
}));
res.end(JSON.stringify({ error: 'OpenSky rate limited; proxy cooling down.' }));
return;
}
const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { 'Accept': 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
if (requestedMode === 'basic') {
if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'basic_credentials';
} else {
reason = 'missing_basic_creds';
}
} else if (requestedMode === 'oauth') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else {
reason = 'oauth_invalid_or_missing';
}
} else if (requestedMode === 'auto') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'oauth_unavailable_fallback_basic';
} else {
reason = 'missing_oauth_and_basic_creds';
}
}
let upstream = await fetch('https://opensky-network.org/api/states/all?extended=1', { headers });
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
requestedMode === 'auto' &&
usedMode === 'oauth' &&
hasBasicCreds
) {
const retryHeaders = {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
upstream = await fetch('https://opensky-network.org/api/states/all?extended=1', { headers: retryHeaders });
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
let body = await upstream.text();
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if (
upstream.ok
&& openSkySourceIsStale(sourceEpochMs, now)
&& await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_snapshot_stale_regional_fallback',
)
) {
// Keep the last global snapshot available as a fail-soft cache,
// but do not label or render it as a fresh live result.
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = { requestedMode, usedMode, reason };
return;
}
if (upstream.status === 429) {
reason = 'rate_limited';
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
// 2 min when the header is absent) — no upstream attempts until then.
const retryAfterSec = Number(upstream.headers.get('x-rate-limit-retry-after-seconds'));
const cooldownMs = Math.min(
Math.max(Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, 30_000),
30 * 60_000
);
_openskyCooldownUntil = now + cooldownMs;
// Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) {
res.writeHead(200, buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode,
usedMode,
reason: 'rate_limited_serving_stale',
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}));
res.end(_openskyCacheBody);
return;
}
}
if (!upstream.ok && !_openskyCacheBody) {
const servedFallback = await serveAdsbLolPointFallback(
req,
res,
requestedMode,
`opensky_http_${upstream.status}_regional_fallback`,
);
if (servedFallback) return;
}
if (upstream.status === 401 || upstream.status === 403) {
if (requestedMode === 'basic' && !hasBasicCreds) {
body = JSON.stringify({
error: 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
});
reason = 'missing_basic_creds';
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
body = JSON.stringify({
error: 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
});
reason = 'oauth_invalid_or_missing';
} else if (usedMode === 'basic') {
body = JSON.stringify({
error: 'OpenSky auth invalid. Username/password were rejected.',
});
reason = 'basic_invalid_credentials';
} else if (usedMode === 'oauth') {
body = JSON.stringify({
error: 'OpenSky auth invalid. OAuth client credentials were rejected.',
});
reason = 'oauth_invalid_credentials';
} else if (requestedMode === 'auto' && !hasBasicCreds) {
body = JSON.stringify({
error: 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
});
reason = 'missing_oauth_and_basic_creds';
} else {
body = JSON.stringify({
error: 'OpenSky auth required.',
});
reason = 'auth_required';
}
}
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (upstream.ok && usedMode === 'basic' && reason === 'basic_credentials') {
reason = 'basic_ok';
} else if (upstream.ok && usedMode === 'oauth' && reason === 'oauth_token') {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(upstream.headers.get('x-rate-limit-remaining'));
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
res.writeHead(
upstream.status,
buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode,
reason,
})
);
res.end(body);
} catch (e) {
console.error('[OpenSky Proxy]', e.message);
if (_openskyCacheBody) {
const cachedMeta = _openskyCacheMeta || {
requestedMode: normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE),
usedMode: 'unknown',
reason: 'cached_stale',
};
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode: cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
usedMode: cachedMeta.usedMode || 'unknown',
reason: cachedMeta.reason || 'cached_stale',
})
);
res.end(_openskyCacheBody);
return;
}
const requestedMode = normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE);
if (await serveAdsbLolPointFallback(req, res, requestedMode, 'opensky_proxy_error_regional_fallback')) return;
res.writeHead(
502,
buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode: 'error',
reason: 'proxy_error',
})
);
res.end(JSON.stringify({ error: 'OpenSky proxy error' }));
}
});
},
};
}
+114
View File
@@ -0,0 +1,114 @@
import { getOpenSkyToken } from './opensky.js';
import { readCappedResponseText } from '../common/http.js';
/**
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
*
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
* own credit bucket, 4 credits per call on the free tier). OAuth via the
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
* client can fall back to its accumulated trail silently.
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
* of real history per aircraft. Treat as best-effort; data is ODbL —
* credit "adsb.lol (ODbL)" in the UI.
*/
export function trackBackfillProxies() {
const TRACK_CACHE_MS = 60000;
const TRACK_CACHE_MAX = 200;
const RESPONSE_CAP_BYTES = 5 * 1024 * 1024;
/** @type {Map<string, {at:number,status:number,body:string}>} */
const cache = new Map();
function cachePut(key, entry) {
cache.set(key, entry);
if (cache.size > TRACK_CACHE_MAX) {
const oldest = [...cache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
if (oldest) cache.delete(oldest[0]);
}
}
async function proxyJson(res, key, upstreamUrl, headers = {}) {
const cached = cache.get(key);
if (cached && Date.now() - cached.at < TRACK_CACHE_MS) {
res.statusCode = cached.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(cached.body);
return;
}
const upstream = await fetch(upstreamUrl, { headers, signal: AbortSignal.timeout(12000) });
const { tooLarge, text } = await readCappedResponseText(upstream, RESPONSE_CAP_BYTES);
let body;
if (tooLarge) {
body = JSON.stringify({ error: 'Upstream track response too large' });
} else if (!upstream.ok) {
// Sanitize upstream error surface; status code is signal enough
body = JSON.stringify({ error: `Track source HTTP ${upstream.status}` });
} else {
body = text;
}
cachePut(key, { at: Date.now(), status: upstream.status, body });
res.statusCode = upstream.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(body);
}
function install(middlewares) {
middlewares.use('/api/opensky-track', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const icao24 = String(incoming.searchParams.get('icao24') || '').trim().toLowerCase();
if (!/^[0-9a-f]{6}$/.test(icao24)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'icao24 must be a 6-char hex string' }));
return;
}
const token = await getOpenSkyToken();
await proxyJson(
res,
`osky:${icao24}`,
`https://opensky-network.org/api/tracks/all?icao24=${icao24}&time=0`,
token ? { Authorization: `Bearer ${token}` } : {}
);
} catch (error) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'OpenSky track fetch failed' }));
}
});
middlewares.use('/api/adsblol/trace', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const hex = String(incoming.searchParams.get('hex') || '').trim().toLowerCase();
if (!/^[0-9a-f~]{6,7}$/.test(hex)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'hex must be a 6-7 char hex string' }));
return;
}
await proxyJson(
res,
`lol:${hex}`,
`https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`
);
} catch (error) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'adsb.lol trace fetch failed' }));
}
});
}
return {
name: 'track-backfill-proxies',
configureServer(server) {
install(server.middlewares);
},
configurePreviewServer(server) {
install(server.middlewares);
},
};
}