style: format extracted live provider modules

This commit is contained in:
Sameh Khamis committed 2026-09-11 19:06:10 -07:00
1 parent 0e221441b4
commit f9a85528fe
14 files changed
+778 -362

No files matched your search

+14 -3
View File
@@ -20,7 +20,11 @@ export function adsbLolProxy() {
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.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-ADS-B-Cache': 'HIT',
});
res.end(_cache);
return;
}
@@ -32,12 +36,19 @@ export function adsbLolProxy() {
_cache = body;
_cacheAt = now;
}
res.writeHead(upstream.status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-ADS-B-Cache': 'MISS' });
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.writeHead(200, {
'Content-Type': 'application/json',
'X-ADS-B-Cache': 'STALE',
});
res.end(_cache);
return;
}
+60 -32
View File
@@ -21,14 +21,18 @@ export function adsbdbProxy() {
try {
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
cache = { routes: parsed.routes ?? {}, aircraft: parsed.aircraft ?? {} };
} catch { /* first run */ }
} 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
} catch {
dirty = true;
} // retry next tick
}, 15_000).unref?.();
}
@@ -43,7 +47,11 @@ export function adsbdbProxy() {
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) };
return {
airline: fr.airline?.name || null,
origin: airport(fr.origin),
destination: airport(fr.destination),
};
}
function parseAircraft(json) {
@@ -51,7 +59,10 @@ export function adsbdbProxy() {
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),
typeName:
a.manufacturer && a.type
? `${a.manufacturer} ${a.type}`
: a.type || null,
registration: a.registration || null,
};
}
@@ -61,30 +72,37 @@ export function adsbdbProxy() {
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;
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);
}
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);
}
@@ -99,18 +117,28 @@ export function adsbdbProxy() {
res.end(JSON.stringify(obj));
};
try {
const [, kind, rawKey] = String(req.url || '').split('?')[0].split('/');
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' });
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 });
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' });
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(
200,
data ? { found: true, ...data } : { found: false },
);
}
return send(404, { error: 'unknown endpoint' });
} catch (err) {
+198 -95
View File
@@ -1,5 +1,8 @@
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import { coalesceProxyRequest, readResponseJsonCapped } from '../common/http.js';
import {
coalesceProxyRequest,
readResponseJsonCapped,
} from '../common/http.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
// ---------------------------------------------------------------------------
// OpenSky OAuth2 token + response cache state
@@ -105,7 +108,7 @@ export async function getOpenSkyToken() {
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;
@@ -119,7 +122,8 @@ export async function getOpenSkyToken() {
const expiresIn = Number(data?.expires_in);
if (!res.ok || !accessToken) {
if (!_openskyAuthWarned) {
const detail = data?.error_description || data?.error || `HTTP ${res.status}`;
const detail =
data?.error_description || data?.error || `HTTP ${res.status}`;
console.warn('[OpenSky] OAuth client_credentials failed:', detail);
_openskyAuthWarned = true;
}
@@ -130,13 +134,21 @@ export async function getOpenSkyToken() {
_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');
_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));
console.warn(
'[OpenSky] OAuth token request failed:',
err?.message || String(err),
);
_openskyAuthWarned = true;
}
_openskyToken = null;
@@ -158,12 +170,14 @@ export async function getOpenSkyToken() {
* @returns {string} One of the valid mode strings, or the default ('oauth').
*/
function normalizeOpenSkyAuthMode(value) {
const raw = String(value || '').trim().toLowerCase();
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}"`
`[OpenSky] Invalid OPENSKY_AUTH_MODE="${raw}", defaulting to "${OPENSKY_AUTH_MODE_DEFAULT}"`,
);
_openskyAuthModeWarned = true;
}
@@ -183,7 +197,14 @@ function normalizeOpenSkyAuthMode(value) {
* @param {string} opts.reason - Human-readable reason string for diagnostics.
* @returns {Record<string,string>} Header object.
*/
function buildOpenSkyHeaders({ cacheStatus, requestedMode, usedMode, reason, staleSeconds, retryAfterSeconds }) {
function buildOpenSkyHeaders({
cacheStatus,
requestedMode,
usedMode,
reason,
staleSeconds,
retryAfterSeconds,
}) {
const headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
@@ -195,8 +216,12 @@ function buildOpenSkyHeaders({ cacheStatus, requestedMode, usedMode, reason, sta
};
// 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));
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;
}
@@ -204,8 +229,10 @@ 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;
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
return null;
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
return null;
return { latitude, longitude };
}
@@ -221,38 +248,45 @@ async function fetchAdsbLolPointFallback(req) {
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',
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,
},
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);
);
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);
}
return record;
} finally {
clearTimeout(timeoutId);
}
});
},
);
try {
const record = await request.promise;
return { ...record, cacheStatus: request.shared ? 'INFLIGHT' : 'MISS' };
@@ -292,8 +326,10 @@ function openSkySourceEpochMs(body) {
}
function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
return Number.isFinite(sourceEpochMs)
&& now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS;
return (
Number.isFinite(sourceEpochMs) &&
now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS
);
}
/**
@@ -317,21 +353,26 @@ export function openSkyProxy() {
configureServer(server) {
server.middlewares.use('/api/opensky', async (req, res) => {
try {
const requestedMode = normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE);
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 (
_openskyCacheBody &&
(now - _openskyCacheTime < _openskyTtlMs || inCooldown)
) {
if (
openSkySourceIsStale(_openskyCacheSourceEpochMs, now)
&& await serveAdsbLolPointFallback(
openSkySourceIsStale(_openskyCacheSourceEpochMs, now) &&
(await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_snapshot_stale_regional_fallback',
)
))
) {
return;
}
@@ -347,10 +388,16 @@ export function openSkyProxy() {
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,
})
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;
@@ -359,22 +406,37 @@ export function openSkyProxy() {
// 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.' }));
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' };
const headers = { Accept: 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
@@ -410,7 +472,10 @@ export function openSkyProxy() {
}
}
let upstream = await fetch('https://opensky-network.org/api/states/all?extended=1', { headers });
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) &&
@@ -422,7 +487,10 @@ export function openSkyProxy() {
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 });
upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers: retryHeaders },
);
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
@@ -430,14 +498,14 @@ export function openSkyProxy() {
let body = await upstream.text();
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if (
upstream.ok
&& openSkySourceIsStale(sourceEpochMs, now)
&& await serveAdsbLolPointFallback(
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.
@@ -452,23 +520,31 @@ export function openSkyProxy() {
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 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
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.writeHead(
200,
buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode,
usedMode,
reason: 'rate_limited_serving_stale',
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
);
res.end(_openskyCacheBody);
return;
}
@@ -487,12 +563,14 @@ export function openSkyProxy() {
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.',
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.',
error:
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
});
reason = 'oauth_invalid_or_missing';
} else if (usedMode === 'basic') {
@@ -502,12 +580,14 @@ export function openSkyProxy() {
reason = 'basic_invalid_credentials';
} else if (usedMode === 'oauth') {
body = JSON.stringify({
error: 'OpenSky auth invalid. OAuth client credentials were rejected.',
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.',
error:
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
});
reason = 'missing_oauth_and_basic_creds';
} else {
@@ -521,9 +601,17 @@ export function openSkyProxy() {
// 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') {
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (upstream.ok && usedMode === 'oauth' && reason === 'oauth_token') {
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
@@ -542,7 +630,9 @@ export function openSkyProxy() {
// 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'));
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
@@ -554,14 +644,16 @@ export function openSkyProxy() {
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),
requestedMode: normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
),
usedMode: 'unknown',
reason: 'cached_stale',
};
@@ -569,16 +661,27 @@ export function openSkyProxy() {
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode: cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
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;
const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
);
if (
await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_proxy_error_regional_fallback',
)
)
return;
res.writeHead(
502,
buildOpenSkyHeaders({
@@ -586,7 +689,7 @@ export function openSkyProxy() {
requestedMode,
usedMode: 'error',
reason: 'proxy_error',
})
}),
);
res.end(JSON.stringify({ error: 'OpenSky proxy error' }));
}
+22 -8
View File
@@ -36,8 +36,14 @@ export function trackBackfillProxies() {
res.end(cached.body);
return;
}
const upstream = await fetch(upstreamUrl, { headers, signal: AbortSignal.timeout(12000) });
const { tooLarge, text } = await readCappedResponseText(upstream, RESPONSE_CAP_BYTES);
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' });
@@ -58,11 +64,15 @@ export function trackBackfillProxies() {
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();
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' }));
res.end(
JSON.stringify({ error: 'icao24 must be a 6-char hex string' }),
);
return;
}
const token = await getOpenSkyToken();
@@ -70,7 +80,7 @@ export function trackBackfillProxies() {
res,
`osky:${icao24}`,
`https://opensky-network.org/api/tracks/all?icao24=${icao24}&time=0`,
token ? { Authorization: `Bearer ${token}` } : {}
token ? { Authorization: `Bearer ${token}` } : {},
);
} catch (error) {
res.statusCode = 502;
@@ -82,17 +92,21 @@ export function trackBackfillProxies() {
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();
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' }));
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`
`https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`,
);
} catch (error) {
res.statusCode = 502;
+22 -5
View File
@@ -28,7 +28,11 @@ export async function readResponseTextCapped(response, maxBytes) {
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
try { await reader.cancel(); } catch { /* no-op */ }
try {
await reader.cancel();
} catch {
/* no-op */
}
const err = new Error('Upstream response too large');
err.code = 'RESPONSE_TOO_LARGE';
throw err;
@@ -85,12 +89,21 @@ export function coalesceProxyRequest(inFlight, key, create) {
export async function readCappedResponseText(upstream, maxBytes) {
const declared = Number(upstream.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { await upstream.body?.cancel(); } catch { /* no-op */ }
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
return { tooLarge: true, text: '' };
}
if (!upstream.body || typeof upstream.body[Symbol.asyncIterator] !== 'function') {
if (
!upstream.body ||
typeof upstream.body[Symbol.asyncIterator] !== 'function'
) {
const text = await upstream.text();
return text.length > maxBytes ? { tooLarge: true, text: '' } : { tooLarge: false, text };
return text.length > maxBytes
? { tooLarge: true, text: '' }
: { tooLarge: false, text };
}
const decoder = new TextDecoder();
let text = '';
@@ -98,7 +111,11 @@ export async function readCappedResponseText(upstream, maxBytes) {
for await (const chunk of upstream.body) {
total += chunk.length;
if (total > maxBytes) {
try { await upstream.body.cancel(); } catch { /* no-op */ }
try {
await upstream.body.cancel();
} catch {
/* no-op */
}
return { tooLarge: true, text: '' };
}
text += decoder.decode(chunk, { stream: true });
+6 -1
View File
@@ -1,4 +1,9 @@
export { readResponseTextCapped, readResponseJsonCapped, coalesceProxyRequest, readCappedResponseText } from './common/http.js';
export {
readResponseTextCapped,
readResponseJsonCapped,
coalesceProxyRequest,
readCappedResponseText,
} from './common/http.js';
export { requiredFiniteQueryNumber, clampInt } from './common/query.js';
export { adsbLolFallbackAnchor, openSkyProxy } from './aircraft/opensky.js';
export { adsbLolProxy } from './aircraft/adsb-lol.js';
+87 -36
View File
@@ -3,12 +3,24 @@ import { createRequire } from 'node:module';
import { createAisStreamAdapter } from '../../../src/data/aisStreamAdapter.js';
import { parseSilenceTimeoutEnv } from '../../../src/data/aisWatchdog.js';
import { clampInt } from '../common/query.js';
import { AISSTREAM_CACHE_MAX, AISSTREAM_STALE_MS, ingestAisStreamEnvelope, readAisTrack, aisStreamRows, newestAisPositionAt } from './ais-store.js';
import {
AISSTREAM_CACHE_MAX,
AISSTREAM_STALE_MS,
ingestAisStreamEnvelope,
readAisTrack,
aisStreamRows,
newestAisPositionAt,
} from './ais-store.js';
// ---------------------------------------------------------------------------
// AISStream live vessel cache state
// ---------------------------------------------------------------------------
const AISSTREAM_URL = 'wss://stream.aisstream.io/v0/stream';
const AISSTREAM_DEFAULT_BBOXES = [[[-90, -180], [90, 180]]];
const AISSTREAM_DEFAULT_BBOXES = [
[
[-90, -180],
[90, 180],
],
];
const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
'PositionReport',
'StandardClassBPositionReport',
@@ -67,25 +79,40 @@ export function aisLiveProxy() {
// Track sub-route MUST be handled before the rows snapshot — this
// mount prefix-matches every subpath, so without this branch
// /api/ais-live/track would be silently answered with vessel rows.
if (incoming.pathname === '/track' || incoming.pathname.startsWith('/track/')) {
if (
incoming.pathname === '/track' ||
incoming.pathname.startsWith('/track/')
) {
const mmsi = String(incoming.searchParams.get('mmsi') || '').trim();
res.statusCode = /^\d{5,10}$/.test(mmsi) ? 200 : 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
if (res.statusCode !== 200) {
res.end(JSON.stringify({ error: 'mmsi query param required', samples: [] }));
res.end(
JSON.stringify({
error: 'mmsi query param required',
samples: [],
}),
);
return;
}
res.end(JSON.stringify({
mmsi,
samples: readAisTrack(mmsi),
source: 'AISStream (accumulated since server start)',
retainedSec: Math.floor(AISSTREAM_STALE_MS / 1000),
}));
res.end(
JSON.stringify({
mmsi,
samples: readAisTrack(mmsi),
source: 'AISStream (accumulated since server start)',
retainedSec: Math.floor(AISSTREAM_STALE_MS / 1000),
}),
);
return;
}
const maxRows = clampInt(incoming.searchParams.get('maxRows'), 1, AISSTREAM_CACHE_MAX, AISSTREAM_CACHE_MAX);
const maxRows = clampInt(
incoming.searchParams.get('maxRows'),
1,
AISSTREAM_CACHE_MAX,
AISSTREAM_CACHE_MAX,
);
const rows = aisStreamRows(maxRows);
const feed = aisStreamStatusSnapshot();
@@ -93,27 +120,34 @@ export function aisLiveProxy() {
res.statusCode = process.env.AISSTREAM_API_KEY ? 200 : 503;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({
rows,
source: 'AISStream',
status: feed.status,
error: feed.error,
refreshing: feed.status !== 'live',
newestPositionAt: newestAisPositionAt(rows),
lastMessageAt: feed.lastMessageAt,
// Honest-failure metadata: how long the feed has been quiet, which
// recovery attempt we are on, and when the next one lands.
silentForMs: feed.silentForMs,
reconnectAttempt: feed.reconnectAttempt,
nextAttemptAt: feed.nextAttemptAt,
staleAfterMs: feed.staleAfterMs,
watchdog: feed.watchdog,
}));
res.end(
JSON.stringify({
rows,
source: 'AISStream',
status: feed.status,
error: feed.error,
refreshing: feed.status !== 'live',
newestPositionAt: newestAisPositionAt(rows),
lastMessageAt: feed.lastMessageAt,
// Honest-failure metadata: how long the feed has been quiet, which
// recovery attempt we are on, and when the next one lands.
silentForMs: feed.silentForMs,
reconnectAttempt: feed.reconnectAttempt,
nextAttemptAt: feed.nextAttemptAt,
staleAfterMs: feed.staleAfterMs,
watchdog: feed.watchdog,
}),
);
} catch (error) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ error: error?.message || 'AIS live stream error', rows: [] }));
res.end(
JSON.stringify({
error: error?.message || 'AIS live stream error',
rows: [],
}),
);
}
});
}
@@ -161,7 +195,10 @@ function aisWebSocketImpl() {
_aisWebSocketImpl = createRequire(import.meta.url)('ws');
} catch (error) {
_aisWebSocketImpl = null;
console.warn('[AISStream] `ws` is unavailable; the live vessel feed is off.', error?.message || '');
console.warn(
'[AISStream] `ws` is unavailable; the live vessel feed is off.',
error?.message || '',
);
}
return _aisWebSocketImpl;
}
@@ -188,9 +225,13 @@ function aisWatchdogPolicy() {
process.env.AISSTREAM_SILENCE_TIMEOUT_MS,
(message) => console.warn(message),
);
const reportMs = override.kind === 'timeout' ? override.value : AISSTREAM_SILENCE_REPORT_MS;
const reportMs =
override.kind === 'timeout' ? override.value : AISSTREAM_SILENCE_REPORT_MS;
_aisWatchdogPolicy = {
silenceWatch: override.kind === 'off' ? false : (override.kind === 'timeout' || !customSubscription),
silenceWatch:
override.kind === 'off'
? false
: override.kind === 'timeout' || !customSubscription,
reportMs,
recycleMs: Math.round(reportMs * AISSTREAM_RECYCLE_RATIO),
// Overridable so the watchdog can be exercised end-to-end against a local
@@ -201,7 +242,6 @@ function aisWatchdogPolicy() {
return _aisWatchdogPolicy;
}
/**
* The transport adapter, built on first use and kept for the module lifetime.
*
@@ -273,7 +313,9 @@ function aisStreamStatusSnapshot() {
if (snapshot) return snapshot;
return {
status: process.env.AISSTREAM_API_KEY ? 'idle' : 'missing-key',
error: process.env.AISSTREAM_API_KEY ? null : 'AISSTREAM_API_KEY is not set',
error: process.env.AISSTREAM_API_KEY
? null
: 'AISSTREAM_API_KEY is not set',
lastMessageAt: null,
silentForMs: null,
reconnectAttempt: 0,
@@ -327,8 +369,14 @@ function disposeAisStream() {
function aisStreamSubscription() {
return {
APIKey: process.env.AISSTREAM_API_KEY,
BoundingBoxes: parseJsonEnv('AISSTREAM_BOUNDING_BOXES', AISSTREAM_DEFAULT_BBOXES),
FilterMessageTypes: parseCsvOrJsonEnv('AISSTREAM_MESSAGE_TYPES', AISSTREAM_DEFAULT_MESSAGE_TYPES),
BoundingBoxes: parseJsonEnv(
'AISSTREAM_BOUNDING_BOXES',
AISSTREAM_DEFAULT_BBOXES,
),
FilterMessageTypes: parseCsvOrJsonEnv(
'AISSTREAM_MESSAGE_TYPES',
AISSTREAM_DEFAULT_MESSAGE_TYPES,
),
};
}
@@ -350,6 +398,9 @@ function parseCsvOrJsonEnv(key, fallback) {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : fallback;
} catch {
return value.split(',').map((entry) => entry.trim()).filter(Boolean);
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
}
+65 -26
View File
@@ -37,7 +37,9 @@ export function ingestAisStreamEnvelope(envelope) {
const messageType = envelope?.MessageType;
const message = envelope?.Message?.[messageType] || {};
const metadata = envelope?.MetaData || envelope?.Metadata || {};
const mmsi = stringValue(metadata.MMSI ?? message.UserID ?? message.UserId ?? message.Mmsi);
const mmsi = stringValue(
metadata.MMSI ?? message.UserID ?? message.UserId ?? message.Mmsi,
);
if (!mmsi) return false;
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
@@ -51,8 +53,12 @@ export function ingestAisStreamEnvelope(envelope) {
mergeAisStaticIntoLiveVessel(mmsi, staticData);
}
const lat = numberValue(metadata.latitude ?? metadata.Latitude ?? message.Latitude);
const lon = numberValue(metadata.longitude ?? metadata.Longitude ?? message.Longitude);
const lat = numberValue(
metadata.latitude ?? metadata.Latitude ?? message.Latitude,
);
const lon = numberValue(
metadata.longitude ?? metadata.Longitude ?? message.Longitude,
);
// A positionless but well-formed record (static data) is still the feed
// delivering AIS traffic, so it counts as liveness.
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
@@ -69,14 +75,21 @@ export function ingestAisStreamEnvelope(envelope) {
speed: numberValue(message.Sog ?? message.SOG),
course: numberValue(message.Cog ?? message.COG),
heading: normalizedHeading(message.TrueHeading ?? message.Heading),
last_position_UTC: normalizeAisTimestamp(metadata.time_utc ?? metadata.TimeUtc),
last_position_UTC: normalizeAisTimestamp(
metadata.time_utc ?? metadata.TimeUtc,
),
// Use the AIS message's own report time, not server ingest wall-clock —
// trail spacing and dead reckoning depend on true fix epochs.
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
_updatedAt: Date.now(),
});
appendAisTrackSample(mmsi, lat, lon, aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc));
appendAisTrackSample(
mmsi,
lat,
lon,
aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
);
pruneAisStreamCache();
return true;
@@ -87,7 +100,9 @@ export function ingestAisStreamEnvelope(envelope) {
*/
function aisEpochSeconds(value) {
const ms = Date.parse(normalizeAisTimestamp(value));
return Number.isFinite(ms) ? Math.floor(ms / 1000) : Math.floor(Date.now() / 1000);
return Number.isFinite(ms)
? Math.floor(ms / 1000)
: Math.floor(Date.now() / 1000);
}
/**
@@ -106,7 +121,11 @@ function appendAisTrackSample(mmsi, lat, lon, epochSec) {
return;
}
if (epochSec - pending.epochSec < AIS_TRACK_MIN_GAP_SEC) return;
if (approxMetersBetween(pending.lat, pending.lon, lat, lon) < AIS_TRACK_MIN_MOVE_M) return;
if (
approxMetersBetween(pending.lat, pending.lon, lat, lon) <
AIS_TRACK_MIN_MOVE_M
)
return;
track = {
lats: new Float32Array(AIS_TRACK_SAMPLES),
lons: new Float32Array(AIS_TRACK_SAMPLES),
@@ -124,7 +143,11 @@ function appendAisTrackSample(mmsi, lat, lon, epochSec) {
const lastIdx = (track.head - 1 + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
const lastEpoch = track.times[lastIdx];
if (epochSec - lastEpoch < AIS_TRACK_MIN_GAP_SEC) return;
if (approxMetersBetween(track.lats[lastIdx], track.lons[lastIdx], lat, lon) < AIS_TRACK_MIN_MOVE_M) return;
if (
approxMetersBetween(track.lats[lastIdx], track.lons[lastIdx], lat, lon) <
AIS_TRACK_MIN_MOVE_M
)
return;
writeAisTrackSample(track, lat, lon, epochSec);
}
@@ -144,10 +167,15 @@ export function readAisTrack(mmsi) {
const track = _aisStreamTracks.get(mmsi);
if (!track || !track.len) return [];
const samples = [];
const start = (track.head - track.len + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
const start =
(track.head - track.len + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
for (let i = 0; i < track.len; i++) {
const idx = (start + i) % AIS_TRACK_SAMPLES;
samples.push({ lat: track.lats[idx], lon: track.lons[idx], t: track.times[idx] });
samples.push({
lat: track.lats[idx],
lon: track.lons[idx],
t: track.times[idx],
});
}
return samples;
}
@@ -155,35 +183,38 @@ export function readAisTrack(mmsi) {
/** Equirectangular distance approximation — plenty for 25m thinning. */
function approxMetersBetween(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111320;
const dLon = (lon2 - lon1) * 111320 * Math.cos(((lat1 + lat2) / 2) * (Math.PI / 180));
const dLon =
(lon2 - lon1) * 111320 * Math.cos(((lat1 + lat2) / 2) * (Math.PI / 180));
return Math.hypot(dLat, dLon);
}
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
const existing = _aisStreamVessels.get(mmsi);
if (!existing) return;
if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`)) existing.name = staticData.name;
if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`))
existing.name = staticData.name;
if (staticData.type && !existing.type) existing.type = staticData.type;
if (staticData.destination && !existing.destination) existing.destination = staticData.destination;
if (staticData.destination && !existing.destination)
existing.destination = staticData.destination;
if (staticData.imo && !existing.imo) existing.imo = staticData.imo;
}
function vesselNameFromAis(metadata, message, staticData = {}) {
return stringValue(
metadata.ShipName
?? message.Name
?? message.ShipName
?? message.ReportA?.Name
?? staticData.name
metadata.ShipName ??
message.Name ??
message.ShipName ??
message.ReportA?.Name ??
staticData.name,
);
}
function vesselTypeFromAis(message, staticData = {}) {
return stringValue(
message.Type
?? message.ShipType
?? message.ReportB?.ShipType
?? staticData.type
message.Type ??
message.ShipType ??
message.ReportB?.ShipType ??
staticData.type,
);
}
@@ -209,11 +240,17 @@ function pruneAisStreamCache() {
// Pending single-fix entries for vessels never seen again must not leak
const pendingCutoffSec = Math.floor(cutoff / 1000);
for (const [mmsi, pending] of _aisStreamTrackPending) {
if (pending.epochSec < pendingCutoffSec) _aisStreamTrackPending.delete(mmsi);
if (pending.epochSec < pendingCutoffSec)
_aisStreamTrackPending.delete(mmsi);
}
if (_aisStreamVessels.size <= AISSTREAM_CACHE_MAX) return;
const ordered = [..._aisStreamVessels.entries()].sort((a, b) => a[1]._updatedAt - b[1]._updatedAt);
for (const [mmsi] of ordered.slice(0, _aisStreamVessels.size - AISSTREAM_CACHE_MAX)) {
const ordered = [..._aisStreamVessels.entries()].sort(
(a, b) => a[1]._updatedAt - b[1]._updatedAt,
);
for (const [mmsi] of ordered.slice(
0,
_aisStreamVessels.size - AISSTREAM_CACHE_MAX,
)) {
_aisStreamVessels.delete(mmsi);
_aisStreamTracks.delete(mmsi);
_aisStreamTrackPending.delete(mmsi);
@@ -245,5 +282,7 @@ function normalizeAisTimestamp(value) {
if (!text) return new Date().toISOString();
const normalized = text.replace(' +0000 UTC', 'Z').replace(' UTC', 'Z');
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
return Number.isNaN(date.getTime())
? new Date().toISOString()
: date.toISOString();
}