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 -1
View File
@@ -31,5 +31,18 @@
"build/vite.js", "build/vite.js",
"server/standalone/vite.config.js", "server/standalone/vite.config.js",
"vite.config.js", "vite.config.js",
"src/tooling/viteBuild.test.mjs" "src/tooling/viteBuild.test.mjs",
"server/providers/common/http.js",
"server/providers/common/query.js",
"server/providers/aircraft/opensky.js",
"server/providers/aircraft/adsb-lol.js",
"server/providers/aircraft/enrichment.js",
"server/providers/aircraft/tracks.js",
"server/providers/vessels/ais-store.js",
"server/providers/vessels/ais-live.js",
"server/providers/live.js",
"src/tooling/liveProviders.test.mjs",
"src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js"
] ]
+13 -39
View File
@@ -10,47 +10,27 @@
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js" "src/data/localGeojsonLod.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"application": { "application": {
"exports": [ "exports": ["./application"],
"./application" "modules": ["src/app/application.js"],
],
"modules": [
"src/app/application.js"
],
"external": [] "external": []
}, },
"viewer": { "viewer": {
"exports": [ "exports": ["./application/viewer"],
"./application/viewer" "modules": ["src/app/viewer.js"],
], "external": ["cesium"]
"modules": [
"src/app/viewer.js"
],
"external": [
"cesium"
]
}, },
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./build/vite"],
"./build/vite" "modules": ["build/vite.js"],
], "external": ["vite-plugin-cesium"]
"modules": [
"build/vite.js"
],
"external": [
"vite-plugin-cesium"
]
}, },
"live-providers": { "live-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/live"],
"./server/providers/live"
],
"modules": [ "modules": [
"server/providers/live.js", "server/providers/live.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -65,17 +45,11 @@
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js" "src/data/aisWatchdog.js"
], ],
"external": [ "external": ["ws"]
"ws"
]
}, },
"adsb-lol-source": { "adsb-lol-source": {
"exports": [ "exports": ["./sources/adsb-lol"],
"./sources/adsb-lol" "modules": ["src/data/adsbLolFallback.js"],
],
"modules": [
"src/data/adsbLolFallback.js"
],
"external": [] "external": []
} }
} }
+14 -3
View File
@@ -20,7 +20,11 @@ export function adsbLolProxy() {
try { try {
const now = Date.now(); const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) { 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); res.end(_cache);
return; return;
} }
@@ -32,12 +36,19 @@ export function adsbLolProxy() {
_cache = body; _cache = body;
_cacheAt = now; _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); res.end(body);
} catch (e) { } catch (e) {
console.error('[adsb.lol Proxy]', e.message); console.error('[adsb.lol Proxy]', e.message);
if (_cache) { 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); res.end(_cache);
return; return;
} }
+60 -32
View File
@@ -21,14 +21,18 @@ export function adsbdbProxy() {
try { try {
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
cache = { routes: parsed.routes ?? {}, aircraft: parsed.aircraft ?? {} }; cache = { routes: parsed.routes ?? {}, aircraft: parsed.aircraft ?? {} };
} catch { /* first run */ } } catch {
/* first run */
}
setInterval(async () => { setInterval(async () => {
if (!dirty) return; if (!dirty) return;
dirty = false; dirty = false;
try { try {
await fsp.mkdir(path.dirname(CACHE_PATH), { recursive: true }); await fsp.mkdir(path.dirname(CACHE_PATH), { recursive: true });
await fsp.writeFile(CACHE_PATH, JSON.stringify(cache), 'utf8'); await fsp.writeFile(CACHE_PATH, JSON.stringify(cache), 'utf8');
} catch { dirty = true; } // retry next tick } catch {
dirty = true;
} // retry next tick
}, 15_000).unref?.(); }, 15_000).unref?.();
} }
@@ -43,7 +47,11 @@ export function adsbdbProxy() {
lat: Number.isFinite(a.latitude) ? a.latitude : null, lat: Number.isFinite(a.latitude) ? a.latitude : null,
lon: Number.isFinite(a.longitude) ? a.longitude : 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) { function parseAircraft(json) {
@@ -51,7 +59,10 @@ export function adsbdbProxy() {
if (!a) return null; if (!a) return null;
return { return {
typeCode: a.icao_type || null, // ICAO designator, e.g. "B738" — feeds classifyAircraft 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, registration: a.registration || null,
}; };
} }
@@ -61,30 +72,37 @@ export function adsbdbProxy() {
if (fresh(store[key])) return Promise.resolve(store[key].data); if (fresh(store[key])) return Promise.resolve(store[key].data);
const ik = `${kind}:${key}`; const ik = `${kind}:${key}`;
if (!inflight.has(ik)) { if (!inflight.has(ik)) {
inflight.set(ik, (async () => { inflight.set(
try { ik,
const url = kind === 'route' (async () => {
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}` try {
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`; const url =
const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); kind === 'route'
if (res.ok) { ? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
const data = kind === 'route' ? parseRoute(await res.json()) : parseAircraft(await res.json()); : `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
store[key] = { at: Date.now(), data }; // data may be null — negative cache const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
dirty = true; if (res.ok) {
return data; 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); return inflight.get(ik);
} }
@@ -99,18 +117,28 @@ export function adsbdbProxy() {
res.end(JSON.stringify(obj)); res.end(JSON.stringify(obj));
}; };
try { try {
const [, kind, rawKey] = String(req.url || '').split('?')[0].split('/'); const [, kind, rawKey] = String(req.url || '')
.split('?')[0]
.split('/');
if (kind === 'route') { if (kind === 'route') {
const cs = String(rawKey || '').toUpperCase(); 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); 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') { if (kind === 'type') {
const hex = String(rawKey || '').toLowerCase(); 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); 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' }); return send(404, { error: 'unknown endpoint' });
} catch (err) { } catch (err) {
+198 -95
View File
@@ -1,5 +1,8 @@
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; 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'; import { requiredFiniteQueryNumber } from '../common/query.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OpenSky OAuth2 token + response cache state // OpenSky OAuth2 token + response cache state
@@ -105,7 +108,7 @@ export async function getOpenSkyToken() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`, body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
} },
); );
let data = null; let data = null;
@@ -119,7 +122,8 @@ export async function getOpenSkyToken() {
const expiresIn = Number(data?.expires_in); const expiresIn = Number(data?.expires_in);
if (!res.ok || !accessToken) { if (!res.ok || !accessToken) {
if (!_openskyAuthWarned) { 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); console.warn('[OpenSky] OAuth client_credentials failed:', detail);
_openskyAuthWarned = true; _openskyAuthWarned = true;
} }
@@ -130,13 +134,21 @@ export async function getOpenSkyToken() {
_openskyToken = accessToken; _openskyToken = accessToken;
// Default to 1800 s (30 min) if expires_in is missing or non-finite // Default to 1800 s (30 min) if expires_in is missing or non-finite
_openskyTokenExpiry = Date.now() + (Number.isFinite(expiresIn) ? expiresIn : 1800) * 1000; _openskyTokenExpiry =
console.log('[OpenSky] OAuth token refreshed, expires in', Number.isFinite(expiresIn) ? expiresIn : 1800, 's'); Date.now() + (Number.isFinite(expiresIn) ? expiresIn : 1800) * 1000;
console.log(
'[OpenSky] OAuth token refreshed, expires in',
Number.isFinite(expiresIn) ? expiresIn : 1800,
's',
);
_openskyAuthWarned = false; _openskyAuthWarned = false;
return _openskyToken; return _openskyToken;
} catch (err) { } catch (err) {
if (!_openskyAuthWarned) { 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; _openskyAuthWarned = true;
} }
_openskyToken = null; _openskyToken = null;
@@ -158,12 +170,14 @@ export async function getOpenSkyToken() {
* @returns {string} One of the valid mode strings, or the default ('oauth'). * @returns {string} One of the valid mode strings, or the default ('oauth').
*/ */
function normalizeOpenSkyAuthMode(value) { function normalizeOpenSkyAuthMode(value) {
const raw = String(value || '').trim().toLowerCase(); const raw = String(value || '')
.trim()
.toLowerCase();
if (!raw) return OPENSKY_AUTH_MODE_DEFAULT; if (!raw) return OPENSKY_AUTH_MODE_DEFAULT;
if (OPENSKY_AUTH_MODE_SET.has(raw)) return raw; if (OPENSKY_AUTH_MODE_SET.has(raw)) return raw;
if (!_openskyAuthModeWarned) { if (!_openskyAuthModeWarned) {
console.warn( 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; _openskyAuthModeWarned = true;
} }
@@ -183,7 +197,14 @@ function normalizeOpenSkyAuthMode(value) {
* @param {string} opts.reason - Human-readable reason string for diagnostics. * @param {string} opts.reason - Human-readable reason string for diagnostics.
* @returns {Record<string,string>} Header object. * @returns {Record<string,string>} Header object.
*/ */
function buildOpenSkyHeaders({ cacheStatus, requestedMode, usedMode, reason, staleSeconds, retryAfterSeconds }) { function buildOpenSkyHeaders({
cacheStatus,
requestedMode,
usedMode,
reason,
staleSeconds,
retryAfterSeconds,
}) {
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'no-store', '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 // Credit-governor extras (field-test fix 2026-07-06): the client can show a
// STALE cue / countdown without parsing the body. // STALE cue / countdown without parsing the body.
if (Number.isFinite(staleSeconds)) headers['X-OpenSky-Stale-Seconds'] = String(Math.round(staleSeconds)); if (Number.isFinite(staleSeconds))
if (Number.isFinite(retryAfterSeconds)) headers['X-OpenSky-Retry-After-Seconds'] = String(Math.round(retryAfterSeconds)); 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; return headers;
} }
@@ -204,8 +229,10 @@ export function adsbLolFallbackAnchor(req) {
const incoming = new URL(req?.url || '', 'http://localhost'); const incoming = new URL(req?.url || '', 'http://localhost');
const latitude = requiredFiniteQueryNumber(incoming.searchParams, 'lat'); const latitude = requiredFiniteQueryNumber(incoming.searchParams, 'lat');
const longitude = requiredFiniteQueryNumber(incoming.searchParams, 'lon'); const longitude = requiredFiniteQueryNumber(incoming.searchParams, 'lon');
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null; if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null; return null;
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
return null;
return { latitude, longitude }; return { latitude, longitude };
} }
@@ -221,38 +248,45 @@ async function fetchAdsbLolPointFallback(req) {
return { ...cached, cacheStatus: 'HIT' }; return { ...cached, cacheStatus: 'HIT' };
} }
const request = coalesceProxyRequest(_adsbLolPointInFlight, cacheKey, async () => { const request = coalesceProxyRequest(
const controller = new AbortController(); _adsbLolPointInFlight,
const timeoutId = setTimeout(() => controller.abort(), 10000); cacheKey,
try { async () => {
const upstream = await fetch( const controller = new AbortController();
`https://api.adsb.lol/v2/lat/${roundedLat}/lon/${roundedLon}/dist/${ADSBLOL_POINT_RADIUS_NM}`, const timeoutId = setTimeout(() => controller.abort(), 10000);
{ try {
headers: { const upstream = await fetch(
Accept: 'application/json', `https://api.adsb.lol/v2/lat/${roundedLat}/lon/${roundedLon}/dist/${ADSBLOL_POINT_RADIUS_NM}`,
'User-Agent': 'gods-eye-view-adsblol-regional-fallback/1.0', {
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(
if (!upstream.ok) throw new Error(`upstream HTTP ${upstream.status}`); upstream,
const payload = await readResponseJsonCapped(upstream, ADSBLOL_POINT_MAX_RESPONSE_BYTES); ADSBLOL_POINT_MAX_RESPONSE_BYTES,
const normalized = normalizeAdsbLolPointResponse(payload); );
const record = { const normalized = normalizeAdsbLolPointResponse(payload);
body: JSON.stringify(normalized), const record = {
cachedAt: Date.now(), body: JSON.stringify(normalized),
count: normalized.states.length, cachedAt: Date.now(),
}; count: normalized.states.length,
_adsbLolPointCache.delete(cacheKey); };
_adsbLolPointCache.set(cacheKey, record); _adsbLolPointCache.delete(cacheKey);
while (_adsbLolPointCache.size > ADSBLOL_POINT_CACHE_MAX) { _adsbLolPointCache.set(cacheKey, record);
_adsbLolPointCache.delete(_adsbLolPointCache.keys().next().value); while (_adsbLolPointCache.size > ADSBLOL_POINT_CACHE_MAX) {
_adsbLolPointCache.delete(_adsbLolPointCache.keys().next().value);
}
return record;
} finally {
clearTimeout(timeoutId);
} }
return record; },
} finally { );
clearTimeout(timeoutId);
}
});
try { try {
const record = await request.promise; const record = await request.promise;
return { ...record, cacheStatus: request.shared ? 'INFLIGHT' : 'MISS' }; return { ...record, cacheStatus: request.shared ? 'INFLIGHT' : 'MISS' };
@@ -292,8 +326,10 @@ function openSkySourceEpochMs(body) {
} }
function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
return Number.isFinite(sourceEpochMs) return (
&& now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS; Number.isFinite(sourceEpochMs) &&
now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS
);
} }
/** /**
@@ -317,21 +353,26 @@ export function openSkyProxy() {
configureServer(server) { configureServer(server) {
server.middlewares.use('/api/opensky', async (req, res) => { server.middlewares.use('/api/opensky', async (req, res) => {
try { try {
const requestedMode = normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE); const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
);
const now = Date.now(); const now = Date.now();
const inCooldown = now < _openskyCooldownUntil; const inCooldown = now < _openskyCooldownUntil;
// Fresh-enough cache (adaptive TTL) OR any cache during a 429 // Fresh-enough cache (adaptive TTL) OR any cache during a 429
// cooldown: serve it without touching upstream. Stale-during-cooldown // cooldown: serve it without touching upstream. Stale-during-cooldown
// is deliberate (credit governor): last-good planes beat a dead layer. // is deliberate (credit governor): last-good planes beat a dead layer.
if (_openskyCacheBody && (now - _openskyCacheTime < _openskyTtlMs || inCooldown)) { if (
_openskyCacheBody &&
(now - _openskyCacheTime < _openskyTtlMs || inCooldown)
) {
if ( if (
openSkySourceIsStale(_openskyCacheSourceEpochMs, now) openSkySourceIsStale(_openskyCacheSourceEpochMs, now) &&
&& await serveAdsbLolPointFallback( (await serveAdsbLolPointFallback(
req, req,
res, res,
requestedMode, requestedMode,
'opensky_snapshot_stale_regional_fallback', 'opensky_snapshot_stale_regional_fallback',
) ))
) { ) {
return; return;
} }
@@ -347,10 +388,16 @@ export function openSkyProxy() {
cacheStatus: isStale ? 'STALE' : 'HIT', cacheStatus: isStale ? 'STALE' : 'HIT',
requestedMode: cachedMeta.requestedMode || requestedMode, requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown', usedMode: cachedMeta.usedMode || 'unknown',
reason: isStale ? 'rate_limited_serving_stale' : (cachedMeta.reason || 'cached'), reason: isStale
staleSeconds: isStale ? (now - _openskyCacheTime) / 1000 : undefined, ? 'rate_limited_serving_stale'
retryAfterSeconds: inCooldown ? (_openskyCooldownUntil - now) / 1000 : undefined, : cachedMeta.reason || 'cached',
}) staleSeconds: isStale
? (now - _openskyCacheTime) / 1000
: undefined,
retryAfterSeconds: inCooldown
? (_openskyCooldownUntil - now) / 1000
: undefined,
}),
); );
res.end(_openskyCacheBody); res.end(_openskyCacheBody);
return; return;
@@ -359,22 +406,37 @@ export function openSkyProxy() {
// synthesize the 429 locally — hammering upstream mid-cooldown can't // synthesize the 429 locally — hammering upstream mid-cooldown can't
// succeed and just burns goodwill. // succeed and just burns goodwill.
if (inCooldown) { if (inCooldown) {
if (await serveAdsbLolPointFallback(req, res, requestedMode, 'opensky_cooldown_regional_fallback')) return; if (
res.writeHead(429, buildOpenSkyHeaders({ await serveAdsbLolPointFallback(
cacheStatus: 'COOLDOWN', req,
requestedMode, res,
usedMode: 'none', requestedMode,
reason: 'rate_limited', 'opensky_cooldown_regional_fallback',
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, )
})); )
res.end(JSON.stringify({ error: 'OpenSky rate limited; proxy cooling down.' })); 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; return;
} }
const basicUser = process.env.OPENSKY_USERNAME || ''; const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || ''; const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass); const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { 'Accept': 'application/json' }; const headers = { Accept: 'application/json' };
let usedMode = 'anon'; let usedMode = 'anon';
let reason = 'forced_anonymous'; 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 // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if ( if (
(upstream.status === 401 || upstream.status === 403) && (upstream.status === 401 || upstream.status === 403) &&
@@ -422,7 +487,10 @@ export function openSkyProxy() {
Accept: 'application/json', Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, 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'; usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic'; reason = 'oauth_rejected_fallback_basic';
} }
@@ -430,14 +498,14 @@ export function openSkyProxy() {
let body = await upstream.text(); let body = await upstream.text();
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if ( if (
upstream.ok upstream.ok &&
&& openSkySourceIsStale(sourceEpochMs, now) openSkySourceIsStale(sourceEpochMs, now) &&
&& await serveAdsbLolPointFallback( (await serveAdsbLolPointFallback(
req, req,
res, res,
requestedMode, requestedMode,
'opensky_snapshot_stale_regional_fallback', 'opensky_snapshot_stale_regional_fallback',
) ))
) { ) {
// Keep the last global snapshot available as a fail-soft cache, // Keep the last global snapshot available as a fail-soft cache,
// but do not label or render it as a fresh live result. // but do not label or render it as a fresh live result.
@@ -452,23 +520,31 @@ export function openSkyProxy() {
reason = 'rate_limited'; reason = 'rate_limited';
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min; // Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
// 2 min when the header is absent) — no upstream attempts until then. // 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( const cooldownMs = Math.min(
Math.max(Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, 30_000), Math.max(
30 * 60_000 Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
30_000,
),
30 * 60_000,
); );
_openskyCooldownUntil = now + cooldownMs; _openskyCooldownUntil = now + cooldownMs;
// Serve the last-good body instead of the 429 when we have one — // Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying. // the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) { if (_openskyCacheBody && _openskyCacheStatus === 200) {
res.writeHead(200, buildOpenSkyHeaders({ res.writeHead(
cacheStatus: 'STALE', 200,
requestedMode, buildOpenSkyHeaders({
usedMode, cacheStatus: 'STALE',
reason: 'rate_limited_serving_stale', requestedMode,
staleSeconds: (now - _openskyCacheTime) / 1000, usedMode,
retryAfterSeconds: cooldownMs / 1000, reason: 'rate_limited_serving_stale',
})); staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
);
res.end(_openskyCacheBody); res.end(_openskyCacheBody);
return; return;
} }
@@ -487,12 +563,14 @@ export function openSkyProxy() {
if (upstream.status === 401 || upstream.status === 403) { if (upstream.status === 401 || upstream.status === 403) {
if (requestedMode === 'basic' && !hasBasicCreds) { if (requestedMode === 'basic' && !hasBasicCreds) {
body = JSON.stringify({ 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'; reason = 'missing_basic_creds';
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') { } else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
body = JSON.stringify({ 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'; reason = 'oauth_invalid_or_missing';
} else if (usedMode === 'basic') { } else if (usedMode === 'basic') {
@@ -502,12 +580,14 @@ export function openSkyProxy() {
reason = 'basic_invalid_credentials'; reason = 'basic_invalid_credentials';
} else if (usedMode === 'oauth') { } else if (usedMode === 'oauth') {
body = JSON.stringify({ 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'; reason = 'oauth_invalid_credentials';
} else if (requestedMode === 'auto' && !hasBasicCreds) { } else if (requestedMode === 'auto' && !hasBasicCreds) {
body = JSON.stringify({ 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'; reason = 'missing_oauth_and_basic_creds';
} else { } else {
@@ -521,9 +601,17 @@ export function openSkyProxy() {
// Refine the reason string to reflect the actual outcome // Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') { if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok'; reason = 'anonymous_ok';
} else if (upstream.ok && usedMode === 'basic' && reason === 'basic_credentials') { } else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok'; reason = 'basic_ok';
} else if (upstream.ok && usedMode === 'oauth' && reason === 'oauth_token') { } else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok'; reason = 'oauth_ok';
} }
@@ -542,7 +630,9 @@ export function openSkyProxy() {
// Credit governor: adapt the cache TTL to the remaining daily // Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of // budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown. // 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); _openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0; _openskyCooldownUntil = 0;
} }
@@ -554,14 +644,16 @@ export function openSkyProxy() {
requestedMode, requestedMode,
usedMode, usedMode,
reason, reason,
}) }),
); );
res.end(body); res.end(body);
} catch (e) { } catch (e) {
console.error('[OpenSky Proxy]', e.message); console.error('[OpenSky Proxy]', e.message);
if (_openskyCacheBody) { if (_openskyCacheBody) {
const cachedMeta = _openskyCacheMeta || { const cachedMeta = _openskyCacheMeta || {
requestedMode: normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE), requestedMode: normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
),
usedMode: 'unknown', usedMode: 'unknown',
reason: 'cached_stale', reason: 'cached_stale',
}; };
@@ -569,16 +661,27 @@ export function openSkyProxy() {
_openskyCacheStatus || 200, _openskyCacheStatus || 200,
buildOpenSkyHeaders({ buildOpenSkyHeaders({
cacheStatus: 'STALE', cacheStatus: 'STALE',
requestedMode: cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT, requestedMode:
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
usedMode: cachedMeta.usedMode || 'unknown', usedMode: cachedMeta.usedMode || 'unknown',
reason: cachedMeta.reason || 'cached_stale', reason: cachedMeta.reason || 'cached_stale',
}) }),
); );
res.end(_openskyCacheBody); res.end(_openskyCacheBody);
return; return;
} }
const requestedMode = normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE); const requestedMode = normalizeOpenSkyAuthMode(
if (await serveAdsbLolPointFallback(req, res, requestedMode, 'opensky_proxy_error_regional_fallback')) return; process.env.OPENSKY_AUTH_MODE,
);
if (
await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_proxy_error_regional_fallback',
)
)
return;
res.writeHead( res.writeHead(
502, 502,
buildOpenSkyHeaders({ buildOpenSkyHeaders({
@@ -586,7 +689,7 @@ export function openSkyProxy() {
requestedMode, requestedMode,
usedMode: 'error', usedMode: 'error',
reason: 'proxy_error', reason: 'proxy_error',
}) }),
); );
res.end(JSON.stringify({ error: 'OpenSky 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); res.end(cached.body);
return; return;
} }
const upstream = await fetch(upstreamUrl, { headers, signal: AbortSignal.timeout(12000) }); const upstream = await fetch(upstreamUrl, {
const { tooLarge, text } = await readCappedResponseText(upstream, RESPONSE_CAP_BYTES); headers,
signal: AbortSignal.timeout(12000),
});
const { tooLarge, text } = await readCappedResponseText(
upstream,
RESPONSE_CAP_BYTES,
);
let body; let body;
if (tooLarge) { if (tooLarge) {
body = JSON.stringify({ error: 'Upstream track response too large' }); body = JSON.stringify({ error: 'Upstream track response too large' });
@@ -58,11 +64,15 @@ export function trackBackfillProxies() {
middlewares.use('/api/opensky-track', async (req, res) => { middlewares.use('/api/opensky-track', async (req, res) => {
try { try {
const incoming = new URL(req.url || '', 'http://localhost'); 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)) { if (!/^[0-9a-f]{6}$/.test(icao24)) {
res.statusCode = 400; res.statusCode = 400;
res.setHeader('Content-Type', 'application/json'); 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; return;
} }
const token = await getOpenSkyToken(); const token = await getOpenSkyToken();
@@ -70,7 +80,7 @@ export function trackBackfillProxies() {
res, res,
`osky:${icao24}`, `osky:${icao24}`,
`https://opensky-network.org/api/tracks/all?icao24=${icao24}&time=0`, `https://opensky-network.org/api/tracks/all?icao24=${icao24}&time=0`,
token ? { Authorization: `Bearer ${token}` } : {} token ? { Authorization: `Bearer ${token}` } : {},
); );
} catch (error) { } catch (error) {
res.statusCode = 502; res.statusCode = 502;
@@ -82,17 +92,21 @@ export function trackBackfillProxies() {
middlewares.use('/api/adsblol/trace', async (req, res) => { middlewares.use('/api/adsblol/trace', async (req, res) => {
try { try {
const incoming = new URL(req.url || '', 'http://localhost'); 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)) { if (!/^[0-9a-f~]{6,7}$/.test(hex)) {
res.statusCode = 400; res.statusCode = 400;
res.setHeader('Content-Type', 'application/json'); 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; return;
} }
await proxyJson( await proxyJson(
res, res,
`lol:${hex}`, `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) { } catch (error) {
res.statusCode = 502; res.statusCode = 502;
+22 -5
View File
@@ -28,7 +28,11 @@ export async function readResponseTextCapped(response, maxBytes) {
if (done) break; if (done) break;
total += value.byteLength; total += value.byteLength;
if (total > maxBytes) { 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'); const err = new Error('Upstream response too large');
err.code = 'RESPONSE_TOO_LARGE'; err.code = 'RESPONSE_TOO_LARGE';
throw err; throw err;
@@ -85,12 +89,21 @@ export function coalesceProxyRequest(inFlight, key, create) {
export async function readCappedResponseText(upstream, maxBytes) { export async function readCappedResponseText(upstream, maxBytes) {
const declared = Number(upstream.headers.get('content-length')); const declared = Number(upstream.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) { 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: '' }; 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(); 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(); const decoder = new TextDecoder();
let text = ''; let text = '';
@@ -98,7 +111,11 @@ export async function readCappedResponseText(upstream, maxBytes) {
for await (const chunk of upstream.body) { for await (const chunk of upstream.body) {
total += chunk.length; total += chunk.length;
if (total > maxBytes) { if (total > maxBytes) {
try { await upstream.body.cancel(); } catch { /* no-op */ } try {
await upstream.body.cancel();
} catch {
/* no-op */
}
return { tooLarge: true, text: '' }; return { tooLarge: true, text: '' };
} }
text += decoder.decode(chunk, { stream: true }); 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 { requiredFiniteQueryNumber, clampInt } from './common/query.js';
export { adsbLolFallbackAnchor, openSkyProxy } from './aircraft/opensky.js'; export { adsbLolFallbackAnchor, openSkyProxy } from './aircraft/opensky.js';
export { adsbLolProxy } from './aircraft/adsb-lol.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 { createAisStreamAdapter } from '../../../src/data/aisStreamAdapter.js';
import { parseSilenceTimeoutEnv } from '../../../src/data/aisWatchdog.js'; import { parseSilenceTimeoutEnv } from '../../../src/data/aisWatchdog.js';
import { clampInt } from '../common/query.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 // AISStream live vessel cache state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const AISSTREAM_URL = 'wss://stream.aisstream.io/v0/stream'; 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 = [ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
'PositionReport', 'PositionReport',
'StandardClassBPositionReport', 'StandardClassBPositionReport',
@@ -67,25 +79,40 @@ export function aisLiveProxy() {
// Track sub-route MUST be handled before the rows snapshot — this // Track sub-route MUST be handled before the rows snapshot — this
// mount prefix-matches every subpath, so without this branch // mount prefix-matches every subpath, so without this branch
// /api/ais-live/track would be silently answered with vessel rows. // /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(); const mmsi = String(incoming.searchParams.get('mmsi') || '').trim();
res.statusCode = /^\d{5,10}$/.test(mmsi) ? 200 : 400; res.statusCode = /^\d{5,10}$/.test(mmsi) ? 200 : 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
if (res.statusCode !== 200) { 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; return;
} }
res.end(JSON.stringify({ res.end(
mmsi, JSON.stringify({
samples: readAisTrack(mmsi), mmsi,
source: 'AISStream (accumulated since server start)', samples: readAisTrack(mmsi),
retainedSec: Math.floor(AISSTREAM_STALE_MS / 1000), source: 'AISStream (accumulated since server start)',
})); retainedSec: Math.floor(AISSTREAM_STALE_MS / 1000),
}),
);
return; 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 rows = aisStreamRows(maxRows);
const feed = aisStreamStatusSnapshot(); const feed = aisStreamStatusSnapshot();
@@ -93,27 +120,34 @@ export function aisLiveProxy() {
res.statusCode = process.env.AISSTREAM_API_KEY ? 200 : 503; res.statusCode = process.env.AISSTREAM_API_KEY ? 200 : 503;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ res.end(
rows, JSON.stringify({
source: 'AISStream', rows,
status: feed.status, source: 'AISStream',
error: feed.error, status: feed.status,
refreshing: feed.status !== 'live', error: feed.error,
newestPositionAt: newestAisPositionAt(rows), refreshing: feed.status !== 'live',
lastMessageAt: feed.lastMessageAt, newestPositionAt: newestAisPositionAt(rows),
// Honest-failure metadata: how long the feed has been quiet, which lastMessageAt: feed.lastMessageAt,
// recovery attempt we are on, and when the next one lands. // Honest-failure metadata: how long the feed has been quiet, which
silentForMs: feed.silentForMs, // recovery attempt we are on, and when the next one lands.
reconnectAttempt: feed.reconnectAttempt, silentForMs: feed.silentForMs,
nextAttemptAt: feed.nextAttemptAt, reconnectAttempt: feed.reconnectAttempt,
staleAfterMs: feed.staleAfterMs, nextAttemptAt: feed.nextAttemptAt,
watchdog: feed.watchdog, staleAfterMs: feed.staleAfterMs,
})); watchdog: feed.watchdog,
}),
);
} catch (error) { } catch (error) {
res.statusCode = 502; res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store'); 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'); _aisWebSocketImpl = createRequire(import.meta.url)('ws');
} catch (error) { } catch (error) {
_aisWebSocketImpl = null; _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; return _aisWebSocketImpl;
} }
@@ -188,9 +225,13 @@ function aisWatchdogPolicy() {
process.env.AISSTREAM_SILENCE_TIMEOUT_MS, process.env.AISSTREAM_SILENCE_TIMEOUT_MS,
(message) => console.warn(message), (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 = { _aisWatchdogPolicy = {
silenceWatch: override.kind === 'off' ? false : (override.kind === 'timeout' || !customSubscription), silenceWatch:
override.kind === 'off'
? false
: override.kind === 'timeout' || !customSubscription,
reportMs, reportMs,
recycleMs: Math.round(reportMs * AISSTREAM_RECYCLE_RATIO), recycleMs: Math.round(reportMs * AISSTREAM_RECYCLE_RATIO),
// Overridable so the watchdog can be exercised end-to-end against a local // Overridable so the watchdog can be exercised end-to-end against a local
@@ -201,7 +242,6 @@ function aisWatchdogPolicy() {
return _aisWatchdogPolicy; return _aisWatchdogPolicy;
} }
/** /**
* The transport adapter, built on first use and kept for the module lifetime. * The transport adapter, built on first use and kept for the module lifetime.
* *
@@ -273,7 +313,9 @@ function aisStreamStatusSnapshot() {
if (snapshot) return snapshot; if (snapshot) return snapshot;
return { return {
status: process.env.AISSTREAM_API_KEY ? 'idle' : 'missing-key', 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, lastMessageAt: null,
silentForMs: null, silentForMs: null,
reconnectAttempt: 0, reconnectAttempt: 0,
@@ -327,8 +369,14 @@ function disposeAisStream() {
function aisStreamSubscription() { function aisStreamSubscription() {
return { return {
APIKey: process.env.AISSTREAM_API_KEY, APIKey: process.env.AISSTREAM_API_KEY,
BoundingBoxes: parseJsonEnv('AISSTREAM_BOUNDING_BOXES', AISSTREAM_DEFAULT_BBOXES), BoundingBoxes: parseJsonEnv(
FilterMessageTypes: parseCsvOrJsonEnv('AISSTREAM_MESSAGE_TYPES', AISSTREAM_DEFAULT_MESSAGE_TYPES), '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); const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : fallback; return Array.isArray(parsed) ? parsed : fallback;
} catch { } 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 messageType = envelope?.MessageType;
const message = envelope?.Message?.[messageType] || {}; const message = envelope?.Message?.[messageType] || {};
const metadata = envelope?.MetaData || envelope?.Metadata || {}; 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 (!mmsi) return false;
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') { if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
@@ -51,8 +53,12 @@ export function ingestAisStreamEnvelope(envelope) {
mergeAisStaticIntoLiveVessel(mmsi, staticData); mergeAisStaticIntoLiveVessel(mmsi, staticData);
} }
const lat = numberValue(metadata.latitude ?? metadata.Latitude ?? message.Latitude); const lat = numberValue(
const lon = numberValue(metadata.longitude ?? metadata.Longitude ?? message.Longitude); 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 // A positionless but well-formed record (static data) is still the feed
// delivering AIS traffic, so it counts as liveness. // delivering AIS traffic, so it counts as liveness.
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
@@ -69,14 +75,21 @@ export function ingestAisStreamEnvelope(envelope) {
speed: numberValue(message.Sog ?? message.SOG), speed: numberValue(message.Sog ?? message.SOG),
course: numberValue(message.Cog ?? message.COG), course: numberValue(message.Cog ?? message.COG),
heading: normalizedHeading(message.TrueHeading ?? message.Heading), 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 — // Use the AIS message's own report time, not server ingest wall-clock —
// trail spacing and dead reckoning depend on true fix epochs. // trail spacing and dead reckoning depend on true fix epochs.
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc), last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
_updatedAt: Date.now(), _updatedAt: Date.now(),
}); });
appendAisTrackSample(mmsi, lat, lon, aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc)); appendAisTrackSample(
mmsi,
lat,
lon,
aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
);
pruneAisStreamCache(); pruneAisStreamCache();
return true; return true;
@@ -87,7 +100,9 @@ export function ingestAisStreamEnvelope(envelope) {
*/ */
function aisEpochSeconds(value) { function aisEpochSeconds(value) {
const ms = Date.parse(normalizeAisTimestamp(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; return;
} }
if (epochSec - pending.epochSec < AIS_TRACK_MIN_GAP_SEC) 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 = { track = {
lats: new Float32Array(AIS_TRACK_SAMPLES), lats: new Float32Array(AIS_TRACK_SAMPLES),
lons: 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 lastIdx = (track.head - 1 + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
const lastEpoch = track.times[lastIdx]; const lastEpoch = track.times[lastIdx];
if (epochSec - lastEpoch < AIS_TRACK_MIN_GAP_SEC) return; 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); writeAisTrackSample(track, lat, lon, epochSec);
} }
@@ -144,10 +167,15 @@ export function readAisTrack(mmsi) {
const track = _aisStreamTracks.get(mmsi); const track = _aisStreamTracks.get(mmsi);
if (!track || !track.len) return []; if (!track || !track.len) return [];
const samples = []; 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++) { for (let i = 0; i < track.len; i++) {
const idx = (start + i) % AIS_TRACK_SAMPLES; 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; return samples;
} }
@@ -155,35 +183,38 @@ export function readAisTrack(mmsi) {
/** Equirectangular distance approximation — plenty for 25m thinning. */ /** Equirectangular distance approximation — plenty for 25m thinning. */
function approxMetersBetween(lat1, lon1, lat2, lon2) { function approxMetersBetween(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111320; 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); return Math.hypot(dLat, dLon);
} }
function mergeAisStaticIntoLiveVessel(mmsi, staticData) { function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
const existing = _aisStreamVessels.get(mmsi); const existing = _aisStreamVessels.get(mmsi);
if (!existing) return; 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.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; if (staticData.imo && !existing.imo) existing.imo = staticData.imo;
} }
function vesselNameFromAis(metadata, message, staticData = {}) { function vesselNameFromAis(metadata, message, staticData = {}) {
return stringValue( return stringValue(
metadata.ShipName metadata.ShipName ??
?? message.Name message.Name ??
?? message.ShipName message.ShipName ??
?? message.ReportA?.Name message.ReportA?.Name ??
?? staticData.name staticData.name,
); );
} }
function vesselTypeFromAis(message, staticData = {}) { function vesselTypeFromAis(message, staticData = {}) {
return stringValue( return stringValue(
message.Type message.Type ??
?? message.ShipType message.ShipType ??
?? message.ReportB?.ShipType message.ReportB?.ShipType ??
?? staticData.type staticData.type,
); );
} }
@@ -209,11 +240,17 @@ function pruneAisStreamCache() {
// Pending single-fix entries for vessels never seen again must not leak // Pending single-fix entries for vessels never seen again must not leak
const pendingCutoffSec = Math.floor(cutoff / 1000); const pendingCutoffSec = Math.floor(cutoff / 1000);
for (const [mmsi, pending] of _aisStreamTrackPending) { 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; if (_aisStreamVessels.size <= AISSTREAM_CACHE_MAX) return;
const ordered = [..._aisStreamVessels.entries()].sort((a, b) => a[1]._updatedAt - b[1]._updatedAt); const ordered = [..._aisStreamVessels.entries()].sort(
for (const [mmsi] of ordered.slice(0, _aisStreamVessels.size - AISSTREAM_CACHE_MAX)) { (a, b) => a[1]._updatedAt - b[1]._updatedAt,
);
for (const [mmsi] of ordered.slice(
0,
_aisStreamVessels.size - AISSTREAM_CACHE_MAX,
)) {
_aisStreamVessels.delete(mmsi); _aisStreamVessels.delete(mmsi);
_aisStreamTracks.delete(mmsi); _aisStreamTracks.delete(mmsi);
_aisStreamTrackPending.delete(mmsi); _aisStreamTrackPending.delete(mmsi);
@@ -245,5 +282,7 @@ function normalizeAisTimestamp(value) {
if (!text) return new Date().toISOString(); if (!text) return new Date().toISOString();
const normalized = text.replace(' +0000 UTC', 'Z').replace(' UTC', 'Z'); const normalized = text.replace(' +0000 UTC', 'Z').replace(' UTC', 'Z');
const date = new Date(normalized); 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();
} }
+18 -7
View File
@@ -9,7 +9,9 @@ function finiteNumber(value) {
} }
function emitterCategory(value) { function emitterCategory(value) {
const category = String(value || '').trim().toUpperCase(); const category = String(value || '')
.trim()
.toUpperCase();
const categories = { const categories = {
A1: 2, A1: 2,
A2: 3, A2: 3,
@@ -36,18 +38,24 @@ function emitterCategory(value) {
* @returns {Array|null} OpenSky-compatible state vector, or null when invalid. * @returns {Array|null} OpenSky-compatible state vector, or null when invalid.
*/ */
export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const hex = String(aircraft?.hex || '').trim().toLowerCase(); const hex = String(aircraft?.hex || '')
.trim()
.toLowerCase();
const latitude = finiteNumber(aircraft?.lat); const latitude = finiteNumber(aircraft?.lat);
const longitude = finiteNumber(aircraft?.lon); const longitude = finiteNumber(aircraft?.lon);
if (!hex || latitude === null || longitude === null) return null; if (!hex || latitude === null || longitude === null) return null;
const seenPosition = Math.max(0, finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0); const seenPosition = Math.max(
0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
);
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition); const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
const onGround = aircraft?.alt_baro === 'ground'; const onGround = aircraft?.alt_baro === 'ground';
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro); const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
const geometricFeet = finiteNumber(aircraft?.alt_geom); const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs); const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm = finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate); const verticalRateFpm =
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
const track = finiteNumber(aircraft?.track); const track = finiteNumber(aircraft?.track);
return [ return [
@@ -80,9 +88,12 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
*/ */
export function normalizeAdsbLolPointResponse(payload) { export function normalizeAdsbLolPointResponse(payload) {
const responseNow = finiteNumber(payload?.now); const responseNow = finiteNumber(payload?.now);
const nowSeconds = responseNow === null const nowSeconds =
? Math.floor(Date.now() / 1000) responseNow === null
: Math.floor(responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow); ? Math.floor(Date.now() / 1000)
: Math.floor(
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
);
const states = (Array.isArray(payload?.ac) ? payload.ac : []) const states = (Array.isArray(payload?.ac) ? payload.ac : [])
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds)) .map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
.filter(Boolean); .filter(Boolean);
+103 -53
View File
@@ -26,10 +26,12 @@ const DEFAULT_CLOCK = Object.freeze({
}); });
/** Upstream text that identifies a credential rejection. */ /** Upstream text that identifies a credential rejection. */
const AUTH_TEXT = /(unauthoriz|unauthoris|forbidden|invalid\s*api[\s_-]*key|invalid\s*key|bad\s*api[\s_-]*key|authentic|api\s*key\s*(is\s*)?(invalid|required|missing|not\s*valid))/i; const AUTH_TEXT =
/(unauthoriz|unauthoris|forbidden|invalid\s*api[\s_-]*key|invalid\s*key|bad\s*api[\s_-]*key|authentic|api\s*key\s*(is\s*)?(invalid|required|missing|not\s*valid))/i;
/** Upstream text that identifies a rate limit / connection cap. */ /** Upstream text that identifies a rate limit / connection cap. */
const RATE_TEXT = /(rate[\s_-]*limit|too\s*many\s*(requests|connections)|quota\s*exceeded|429)/i; const RATE_TEXT =
/(rate[\s_-]*limit|too\s*many\s*(requests|connections)|quota\s*exceeded|429)/i;
/** /**
* Parse an HTTP `Retry-After` header into milliseconds. * Parse an HTTP `Retry-After` header into milliseconds.
@@ -61,7 +63,8 @@ export function parseRetryAfterMs(raw, nowMs = Date.now()) {
*/ */
export function classifyAisFailure(input = {}) { export function classifyAisFailure(input = {}) {
const text = String(input.message || '').trim(); const text = String(input.message || '').trim();
const fromHeader = () => parseRetryAfterMs(input.retryAfterHeader, input.nowMs); const fromHeader = () =>
parseRetryAfterMs(input.retryAfterHeader, input.nowMs);
let status = Number(input.httpStatus); let status = Number(input.httpStatus);
if (!Number.isFinite(status)) { if (!Number.isFinite(status)) {
@@ -72,7 +75,10 @@ export function classifyAisFailure(input = {}) {
} }
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
return { kind: 'auth', message: `AISStream rejected the API key (HTTP ${status})` }; return {
kind: 'auth',
message: `AISStream rejected the API key (HTTP ${status})`,
};
} }
if (status === 429) { if (status === 429) {
return { return {
@@ -82,10 +88,14 @@ export function classifyAisFailure(input = {}) {
}; };
} }
if (Number.isFinite(status) && status >= 400) { if (Number.isFinite(status) && status >= 400) {
return { kind: 'transport', message: `AISStream upgrade failed (HTTP ${status})` }; return {
kind: 'transport',
message: `AISStream upgrade failed (HTTP ${status})`,
};
} }
if (AUTH_TEXT.test(text)) return { kind: 'auth', message: text }; if (AUTH_TEXT.test(text)) return { kind: 'auth', message: text };
if (RATE_TEXT.test(text)) return { kind: 'rate-limit', message: text, retryAfterMs: fromHeader() }; if (RATE_TEXT.test(text))
return { kind: 'rate-limit', message: text, retryAfterMs: fromHeader() };
return { kind: 'transport', message: text || 'AISStream websocket error' }; return { kind: 'transport', message: text || 'AISStream websocket error' };
} }
@@ -151,7 +161,8 @@ export function aisFrameByteLength(data) {
if (typeof data === 'string') return data.length; if (typeof data === 'string') return data.length;
if (data instanceof ArrayBuffer) return data.byteLength; if (data instanceof ArrayBuffer) return data.byteLength;
if (ArrayBuffer.isView(data)) return data.byteLength; if (ArrayBuffer.isView(data)) return data.byteLength;
if (Array.isArray(data)) return data.reduce((sum, part) => sum + aisFrameByteLength(part), 0); if (Array.isArray(data))
return data.reduce((sum, part) => sum + aisFrameByteLength(part), 0);
return 0; return 0;
} }
@@ -168,33 +179,35 @@ export function aisFrameByteLength(data) {
* *
* If AISStream adds a type, add it here — do not infer it. * If AISStream adds a type, add it here — do not infer it.
*/ */
export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(new Set([ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
'PositionReport', new Set([
'UnknownMessage', 'PositionReport',
'AddressedSafetyMessage', 'UnknownMessage',
'AddressedBinaryMessage', 'AddressedSafetyMessage',
'AidsToNavigationReport', 'AddressedBinaryMessage',
'AssignedModeCommand', 'AidsToNavigationReport',
'BaseStationReport', 'AssignedModeCommand',
'BinaryAcknowledge', 'BaseStationReport',
'BinaryBroadcastMessage', 'BinaryAcknowledge',
'ChannelManagement', 'BinaryBroadcastMessage',
'CoordinatedUTCInquiry', 'ChannelManagement',
'DataLinkManagementMessage', 'CoordinatedUTCInquiry',
'DataLinkManagementMessageData', 'DataLinkManagementMessage',
'ExtendedClassBPositionReport', 'DataLinkManagementMessageData',
'GroupAssignmentCommand', 'ExtendedClassBPositionReport',
'GnssBroadcastBinaryMessage', 'GroupAssignmentCommand',
'Interrogation', 'GnssBroadcastBinaryMessage',
'LongRangeAisBroadcastMessage', 'Interrogation',
'MultiSlotBinaryMessage', 'LongRangeAisBroadcastMessage',
'SafetyBroadcastMessage', 'MultiSlotBinaryMessage',
'ShipStaticData', 'SafetyBroadcastMessage',
'SingleSlotBinaryMessage', 'ShipStaticData',
'StandardClassBPositionReport', 'SingleSlotBinaryMessage',
'StandardSearchAndRescueAircraftReport', 'StandardClassBPositionReport',
'StaticDataReport', 'StandardSearchAndRescueAircraftReport',
])); 'StaticDataReport',
]),
);
/** /**
* Resolve an envelope's MMSI, or null. * Resolve an envelope's MMSI, or null.
@@ -221,9 +234,14 @@ export function aisEnvelopeMmsi(envelope, body = {}) {
* @returns {boolean} * @returns {boolean}
*/ */
export function isRecognizedAisEnvelope(envelope) { export function isRecognizedAisEnvelope(envelope) {
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return false; if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope))
return false;
const messageType = envelope.MessageType; const messageType = envelope.MessageType;
if (typeof messageType !== 'string' || !AIS_RECOGNIZED_MESSAGE_TYPES.has(messageType)) return false; if (
typeof messageType !== 'string' ||
!AIS_RECOGNIZED_MESSAGE_TYPES.has(messageType)
)
return false;
const body = envelope.Message?.[messageType]; const body = envelope.Message?.[messageType];
if (!body || typeof body !== 'object') return false; if (!body || typeof body !== 'object') return false;
return aisEnvelopeMmsi(envelope, body) !== null; return aisEnvelopeMmsi(envelope, body) !== null;
@@ -281,7 +299,10 @@ export function createAisStreamAdapter(options) {
/** (Re)build the state machine, preserving the generation namespace. */ /** (Re)build the state machine, preserving the generation namespace. */
function setWatchdogOptions(watchdogOptions = {}) { function setWatchdogOptions(watchdogOptions = {}) {
if (watchdog) { if (watchdog) {
generationHighWater = Math.max(generationHighWater, watchdog.highWaterGeneration()); generationHighWater = Math.max(
generationHighWater,
watchdog.highWaterGeneration(),
);
} }
watchdog = createAisWatchdog({ watchdog = createAisWatchdog({
...watchdogOptions, ...watchdogOptions,
@@ -306,7 +327,9 @@ export function createAisStreamAdapter(options) {
try { try {
socket.terminate(); socket.terminate();
} catch (error) { } catch (error) {
warn(`[AISStream] terminate(${reason || 'unknown'}) failed: ${error?.message || error}`); warn(
`[AISStream] terminate(${reason || 'unknown'}) failed: ${error?.message || error}`,
);
} }
} }
@@ -325,7 +348,8 @@ export function createAisStreamAdapter(options) {
*/ */
function runActions(owner, actions) { function runActions(owner, actions) {
for (const action of actions || []) { for (const action of actions || []) {
if (action.type === 'terminate') terminateGeneration(action.generation, action.reason); if (action.type === 'terminate')
terminateGeneration(action.generation, action.reason);
else if (action.type === 'connect') openSocket(owner, action.generation); else if (action.type === 'connect') openSocket(owner, action.generation);
} }
} }
@@ -349,11 +373,18 @@ export function createAisStreamAdapter(options) {
try { try {
socket = createSocket(resolveUrl()); socket = createSocket(resolveUrl());
} catch (error) { } catch (error) {
failGeneration(owner, generation, classifyAisFailure({ message: error?.message })); failGeneration(
owner,
generation,
classifyAisFailure({ message: error?.message }),
);
return; return;
} }
if (!socket) { if (!socket) {
failGeneration(owner, generation, { kind: 'transport', message: 'socket factory returned nothing' }); failGeneration(owner, generation, {
kind: 'transport',
message: 'socket factory returned nothing',
});
return; return;
} }
sockets.set(generation, socket); sockets.set(generation, socket);
@@ -386,7 +417,11 @@ export function createAisStreamAdapter(options) {
try { try {
socket.send(JSON.stringify(buildSubscription())); socket.send(JSON.stringify(buildSubscription()));
} catch (error) { } catch (error) {
failGeneration(owner, generation, classifyAisFailure({ message: error?.message })); failGeneration(
owner,
generation,
classifyAisFailure({ message: error?.message }),
);
} }
}); });
@@ -408,7 +443,9 @@ export function createAisStreamAdapter(options) {
return; return;
} }
handleMessage(owner, generation, socket, data).catch((error) => { handleMessage(owner, generation, socket, data).catch((error) => {
warn(`[AISStream] message handling failed: ${error?.message || error}`); warn(
`[AISStream] message handling failed: ${error?.message || error}`,
);
}); });
} catch (error) { } catch (error) {
// A frame that cannot even be decoded is a malformed frame: dropped, // A frame that cannot even be decoded is a malformed frame: dropped,
@@ -427,7 +464,9 @@ export function createAisStreamAdapter(options) {
}); });
try { try {
response?.destroy?.(); response?.destroy?.();
} catch { /* already gone */ } } catch {
/* already gone */
}
releaseSocketEntry(generation, socket); releaseSocketEntry(generation, socket);
abort(socket, 'unexpected-response'); abort(socket, 'unexpected-response');
failGeneration(owner, generation, detail); failGeneration(owner, generation, detail);
@@ -440,10 +479,14 @@ export function createAisStreamAdapter(options) {
abort(socket, 'orphan-error'); abort(socket, 'orphan-error');
return; return;
} }
failGeneration(owner, generation, classifyAisFailure({ failGeneration(
message: error?.message || String(error || ''), owner,
nowMs: clock.wall(), generation,
})); classifyAisFailure({
message: error?.message || String(error || ''),
nowMs: clock.wall(),
}),
);
}); });
on('close', () => { on('close', () => {
@@ -482,10 +525,14 @@ export function createAisStreamAdapter(options) {
if (parsed.kind === 'error') { if (parsed.kind === 'error') {
// An error envelope is the OPPOSITE of liveness. Classifying it keeps a // An error envelope is the OPPOSITE of liveness. Classifying it keeps a
// rejected key out of the fast ladder. // rejected key out of the fast ladder.
failGeneration(owner, generation, classifyAisFailure({ failGeneration(
message: parsed.message, owner,
nowMs: clock.wall(), generation,
})); classifyAisFailure({
message: parsed.message,
nowMs: clock.wall(),
}),
);
return; return;
} }
@@ -504,7 +551,10 @@ export function createAisStreamAdapter(options) {
/** Tear down sockets and machine state, preserving the generation namespace. */ /** Tear down sockets and machine state, preserving the generation namespace. */
function dispose() { function dispose() {
if (watchdog) { if (watchdog) {
generationHighWater = Math.max(generationHighWater, watchdog.highWaterGeneration()); generationHighWater = Math.max(
generationHighWater,
watchdog.highWaterGeneration(),
);
runActions(watchdog, watchdog.dispose()); runActions(watchdog, watchdog.dispose());
} }
for (const [generation, socket] of [...sockets]) { for (const [generation, socket] of [...sockets]) {
+51 -20
View File
@@ -63,7 +63,11 @@ export const AIS_WATCHDOG_STATUSES = Object.freeze([
]); ]);
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */ /** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
export const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']); export const AIS_FAILURE_KINDS = Object.freeze([
'transport',
'auth',
'rate-limit',
]);
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */ /** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
const QUIET_TERMINAL = new Set(['down', 'auth-failed']); const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
@@ -92,8 +96,8 @@ export function parseSilenceTimeoutEnv(raw, warn) {
if (!text) return { kind: 'default' }; if (!text) return { kind: 'default' };
if (!/^\d+(\.\d+)?$/.test(text)) { if (!/^\d+(\.\d+)?$/.test(text)) {
warn?.( warn?.(
`[AISStream] Ignoring AISSTREAM_SILENCE_TIMEOUT_MS="${raw}" (not a non-negative number); ` `[AISStream] Ignoring AISSTREAM_SILENCE_TIMEOUT_MS="${raw}" (not a non-negative number); ` +
+ `using the ${AIS_WATCHDOG_DEFAULTS.staleMs}ms default.`, `using the ${AIS_WATCHDOG_DEFAULTS.staleMs}ms default.`,
); );
return { kind: 'default' }; return { kind: 'default' };
} }
@@ -132,11 +136,18 @@ export function createAisWatchdog(options = {}) {
staleMs, staleMs,
positiveOr(options.recycleAfterMs, AIS_WATCHDOG_DEFAULTS.recycleAfterMs), positiveOr(options.recycleAfterMs, AIS_WATCHDOG_DEFAULTS.recycleAfterMs),
); );
const backoffMs = Array.isArray(options.backoffMs) && options.backoffMs.length const backoffMs =
? options.backoffMs.map((ms) => positiveOr(ms, 5_000)) Array.isArray(options.backoffMs) && options.backoffMs.length
: [...AIS_WATCHDOG_DEFAULTS.backoffMs]; ? options.backoffMs.map((ms) => positiveOr(ms, 5_000))
const downRetryMs = positiveOr(options.downRetryMs, AIS_WATCHDOG_DEFAULTS.downRetryMs); : [...AIS_WATCHDOG_DEFAULTS.backoffMs];
const authProbeMs = positiveOr(options.authProbeMs, AIS_WATCHDOG_DEFAULTS.authProbeMs); const downRetryMs = positiveOr(
options.downRetryMs,
AIS_WATCHDOG_DEFAULTS.downRetryMs,
);
const authProbeMs = positiveOr(
options.authProbeMs,
AIS_WATCHDOG_DEFAULTS.authProbeMs,
);
const clock = options.clock || DEFAULT_CLOCK; const clock = options.clock || DEFAULT_CLOCK;
let status = 'idle'; let status = 'idle';
@@ -184,7 +195,8 @@ export function createAisWatchdog(options = {}) {
// unproven, and hammering on an unproven key is the behaviour this whole // unproven, and hammering on an unproven key is the behaviour this whole
// state exists to prevent. Only valid data or a key rotation leaves // state exists to prevent. Only valid data or a key rotation leaves
// auth-failed. // auth-failed.
const effective = status === 'auth-failed' && kind !== 'auth' ? 'auth' : kind; const effective =
status === 'auth-failed' && kind !== 'auth' ? 'auth' : kind;
reconnectAttempt += 1; reconnectAttempt += 1;
if (effective === 'auth') { if (effective === 'auth') {
@@ -201,7 +213,8 @@ export function createAisWatchdog(options = {}) {
reconnectAttempt = Math.max(reconnectAttempt, backoffMs.length); reconnectAttempt = Math.max(reconnectAttempt, backoffMs.length);
const wait = positiveOr(retryAfterMs, backoffMs[backoffMs.length - 1]); const wait = positiveOr(retryAfterMs, backoffMs[backoffMs.length - 1]);
status = isExhausted() ? 'down' : 'reconnecting'; status = isExhausted() ? 'down' : 'reconnecting';
nextAttemptMono = monoNow + (isExhausted() ? Math.max(wait, downRetryMs) : wait); nextAttemptMono =
monoNow + (isExhausted() ? Math.max(wait, downRetryMs) : wait);
return; return;
} }
@@ -251,7 +264,8 @@ export function createAisWatchdog(options = {}) {
// A changed credential is the one event that can plausibly fix an auth // A changed credential is the one event that can plausibly fix an auth
// rejection, so it clears the terminal state immediately. // rejection, so it clears the terminal state immediately.
const nextFingerprint = env.keyFingerprint ?? null; const nextFingerprint = env.keyFingerprint ?? null;
const credentialChanged = keyFingerprint !== null && nextFingerprint !== keyFingerprint; const credentialChanged =
keyFingerprint !== null && nextFingerprint !== keyFingerprint;
keyFingerprint = nextFingerprint; keyFingerprint = nextFingerprint;
if (credentialChanged) { if (credentialChanged) {
// Any socket in flight belongs to the OLD key — it subscribed with that // Any socket in flight belongs to the OLD key — it subscribed with that
@@ -353,9 +367,12 @@ export function createAisWatchdog(options = {}) {
*/ */
function onOpen(eventGeneration) { function onOpen(eventGeneration) {
if (!ownsGeneration(eventGeneration)) { if (!ownsGeneration(eventGeneration)) {
return [{ type: 'terminate', generation: eventGeneration, reason: 'orphan' }]; return [
{ type: 'terminate', generation: eventGeneration, reason: 'orphan' },
];
} }
if (!QUIET_TERMINAL.has(status) && status !== 'stale') status = 'connecting'; if (!QUIET_TERMINAL.has(status) && status !== 'stale')
status = 'connecting';
return []; return [];
} }
@@ -367,7 +384,9 @@ export function createAisWatchdog(options = {}) {
*/ */
function onMessage(eventGeneration) { function onMessage(eventGeneration) {
if (!ownsGeneration(eventGeneration)) { if (!ownsGeneration(eventGeneration)) {
return [{ type: 'terminate', generation: eventGeneration, reason: 'orphan' }]; return [
{ type: 'terminate', generation: eventGeneration, reason: 'orphan' },
];
} }
const monoNow = clock.mono(); const monoNow = clock.mono();
silenceSinceMono = monoNow; silenceSinceMono = monoNow;
@@ -397,7 +416,9 @@ export function createAisWatchdog(options = {}) {
*/ */
function onFailure(eventGeneration, detail = {}) { function onFailure(eventGeneration, detail = {}) {
if (!ownsGeneration(eventGeneration)) return []; if (!ownsGeneration(eventGeneration)) return [];
const kind = AIS_FAILURE_KINDS.includes(detail.kind) ? detail.kind : 'transport'; const kind = AIS_FAILURE_KINDS.includes(detail.kind)
? detail.kind
: 'transport';
// A probe dying of some unrelated fault while the key is refused keeps the // A probe dying of some unrelated fault while the key is refused keeps the
// credential message — surfacing "ECONNRESET" would send the operator // credential message — surfacing "ECONNRESET" would send the operator
// chasing the network instead of the key. // chasing the network instead of the key.
@@ -433,7 +454,8 @@ export function createAisWatchdog(options = {}) {
function snapshot() { function snapshot() {
const monoNow = clock.mono(); const monoNow = clock.mono();
const wallNow = clock.wall(); const wallNow = clock.wall();
const silentForMs = owned !== null ? Math.max(0, monoNow - silenceSinceMono) : null; const silentForMs =
owned !== null ? Math.max(0, monoNow - silenceSinceMono) : null;
const waitingMs = Math.max(0, nextAttemptMono - monoNow); const waitingMs = Math.max(0, nextAttemptMono - monoNow);
return { return {
status, status,
@@ -442,9 +464,12 @@ export function createAisWatchdog(options = {}) {
silentForMs, silentForMs,
reconnectAttempt, reconnectAttempt,
// Projected onto the wall clock purely for display. // Projected onto the wall clock purely for display.
nextAttemptAt: (status === 'reconnecting' || status === 'down' || status === 'auth-failed') nextAttemptAt:
? wallNow + waitingMs status === 'reconnecting' ||
: null, status === 'down' ||
status === 'auth-failed'
? wallNow + waitingMs
: null,
watchdog: silenceWatchArmed ? 'armed' : 'custom-subscription-off', watchdog: silenceWatchArmed ? 'armed' : 'custom-subscription-off',
staleAfterMs: staleMs, staleAfterMs: staleMs,
}; };
@@ -452,7 +477,13 @@ export function createAisWatchdog(options = {}) {
/** Test/diagnostic view of internal ownership. */ /** Test/diagnostic view of internal ownership. */
function debugState() { function debugState() {
return { status, owned, generation, reconnectAttempt, lastMessageAt: lastMessageWall }; return {
status,
owned,
generation,
reconnectAttempt,
lastMessageAt: lastMessageWall,
};
} }
return { return {
+105 -36
View File
@@ -9,18 +9,28 @@ import * as portable from '../../src/data/adsbLolFallback.js';
function install(plugin, preview = false) { function install(plugin, preview = false) {
const routes = new Map(); const routes = new Map();
plugin[preview ? 'configurePreviewServer' : 'configureServer']({ plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: { use(route, handler) { routes.set(route, handler); } }, middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
}); });
return async (route, url = '/', method = 'GET') => { return async (route, url = '/', method = 'GET') => {
assert.ok(routes.has(route), `registered route: ${route}`); assert.ok(routes.has(route), `registered route: ${route}`);
const response = { const response = {
statusCode: 200, headers: {}, statusCode: 200,
setHeader(key, value) { this.headers[key.toLowerCase()] = value; }, headers: {},
setHeader(key, value) {
this.headers[key.toLowerCase()] = value;
},
writeHead(status, headers = {}) { writeHead(status, headers = {}) {
this.statusCode = status; this.statusCode = status;
for (const [key, value] of Object.entries(headers)) this.setHeader(key, value); for (const [key, value] of Object.entries(headers))
this.setHeader(key, value);
},
end(body) {
this.body = body;
}, },
end(body) { this.body = body; },
}; };
await routes.get(route)({ url, method }, response); await routes.get(route)({ url, method }, response);
return response; return response;
@@ -30,8 +40,12 @@ function install(plugin, preview = false) {
function environment(t, values) { function environment(t, values) {
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
const original = process.env[key]; const original = process.env[key];
if (value === undefined) delete process.env[key]; else process.env[key] = value; if (value === undefined) delete process.env[key];
t.after(() => { if (original === undefined) delete process.env[key]; else process.env[key] = original; }); else process.env[key] = value;
t.after(() => {
if (original === undefined) delete process.env[key];
else process.env[key] = original;
});
} }
} }
@@ -40,13 +54,19 @@ test('live entry resolves in Node and aircraft normalization stays independently
assert.equal(entry.openSkyProxy, providers.openSkyProxy); assert.equal(entry.openSkyProxy, providers.openSkyProxy);
assert.equal(entry.aisLiveProxy, providers.aisLiveProxy); assert.equal(entry.aisLiveProxy, providers.aisLiveProxy);
const normalizer = await import('gods-eye-view/sources/adsb-lol'); const normalizer = await import('gods-eye-view/sources/adsb-lol');
assert.equal(normalizer.normalizeAdsbLolAircraftState, portable.normalizeAdsbLolAircraftState); assert.equal(
normalizer.normalizeAdsbLolAircraftState,
portable.normalizeAdsbLolAircraftState,
);
}); });
test('OpenSky state and track routes share tokens, retain cache and use regional fallback', async t => { test('OpenSky state and track routes share tokens, retain cache and use regional fallback', async (t) => {
environment(t, { environment(t, {
OPENSKY_CLIENT_ID: 'fixture-client', OPENSKY_CLIENT_SECRET: 'fixture-secret', OPENSKY_CLIENT_ID: 'fixture-client',
OPENSKY_AUTH_MODE: 'oauth', OPENSKY_USERNAME: undefined, OPENSKY_PASSWORD: undefined, OPENSKY_CLIENT_SECRET: 'fixture-secret',
OPENSKY_AUTH_MODE: 'oauth',
OPENSKY_USERNAME: undefined,
OPENSKY_PASSWORD: undefined,
}); });
let now = Date.now(); let now = Date.now();
t.mock.method(Date, 'now', () => now); t.mock.method(Date, 'now', () => now);
@@ -54,7 +74,8 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
const calls = []; const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => { t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, options }); calls.push({ url, options });
if (url.includes('/token')) return Response.json({ access_token: 'fixture-token', expires_in: 1800 }); if (url.includes('/token'))
return Response.json({ access_token: 'fixture-token', expires_in: 1800 });
if (url.includes('/states/')) { if (url.includes('/states/')) {
assert.equal(options.headers.Authorization, 'Bearer fixture-token'); assert.equal(options.headers.Authorization, 'Bearer fixture-token');
return Response.json({ time: Math.floor(now / 1000), states: [] }); return Response.json({ time: Math.floor(now / 1000), states: [] });
@@ -63,35 +84,62 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(options.headers.Authorization, 'Bearer fixture-token'); assert.equal(options.headers.Authorization, 'Bearer fixture-token');
return Response.json({ path: [] }); return Response.json({ path: [] });
} }
if (url.includes('/lat/')) return Response.json({ now: now / 1000, ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }] }); if (url.includes('/lat/'))
return Response.json({
now: now / 1000,
ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }],
});
throw Error(`Unexpected URL: ${url}`); throw Error(`Unexpected URL: ${url}`);
}); });
const states = install(providers.openSkyProxy()); const states = install(providers.openSkyProxy());
assert.equal((await states('/api/opensky', '?lat=30&lon=-97')).statusCode, 200); assert.equal(
assert.equal((await states('/api/opensky', '?lat=30&lon=-97')).headers['x-opensky-cache'], 'HIT'); (await states('/api/opensky', '?lat=30&lon=-97')).statusCode,
200,
);
assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).headers[
'x-opensky-cache'
],
'HIT',
);
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
const tracks = install(providers.trackBackfillProxies(), true); const tracks = install(providers.trackBackfillProxies(), true);
assert.equal((await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode, 200); assert.equal(
(await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode,
200,
);
await tracks('/api/opensky-track', '?icao24=abc123'); await tracks('/api/opensky-track', '?icao24=abc123');
assert.equal(calls.filter(call => call.url.includes('/token')).length, 1); assert.equal(calls.filter((call) => call.url.includes('/token')).length, 1);
assert.equal(calls.filter(call => call.url.includes('/tracks/')).length, 1); assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1);
assert.equal((await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode, 400); assert.equal(
(await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode,
400,
);
now += 130_000; now += 130_000;
t.mock.method(globalThis, 'fetch', async url => { t.mock.method(globalThis, 'fetch', async (url) => {
if (url.includes('/states/')) return new Response('', { status: 503 }); if (url.includes('/states/')) return new Response('', { status: 503 });
if (url.includes('/lat/')) return Response.json({ now: now / 1000, ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }] }); if (url.includes('/lat/'))
return Response.json({
now: now / 1000,
ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }],
});
throw Error(`Unexpected URL: ${url}`); throw Error(`Unexpected URL: ${url}`);
}); });
// A fresh request without a usable cached worldwide frame should use the regional feed. // A fresh request without a usable cached worldwide frame should use the regional feed.
const fresh = await import(`../../server/providers/aircraft/opensky.js?fallback=${now}`); const fresh = await import(
`../../server/providers/aircraft/opensky.js?fallback=${now}`
);
environment(t, { OPENSKY_AUTH_MODE: 'anon' }); environment(t, { OPENSKY_AUTH_MODE: 'anon' });
const fallback = await install(fresh.openSkyProxy())('/api/opensky', '?lat=30&lon=-97'); const fallback = await install(fresh.openSkyProxy())(
'/api/opensky',
'?lat=30&lon=-97',
);
assert.equal(fallback.statusCode, 200); assert.equal(fallback.statusCode, 200);
assert.equal(fallback.headers['x-flight-source'], 'adsb.lol'); assert.equal(fallback.headers['x-flight-source'], 'adsb.lol');
assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123'); assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123');
}); });
test('military aircraft route preserves fresh cache and stale response after upstream failure', async t => { test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => {
let now = Date.now(); let now = Date.now();
let calls = 0; let calls = 0;
t.mock.method(Date, 'now', () => now); t.mock.method(Date, 'now', () => now);
@@ -109,30 +157,48 @@ test('military aircraft route preserves fresh cache and stale response after ups
assert.equal(calls, 2); assert.equal(calls, 2);
}); });
test('AIS preview route ingests through the socket, returns tracks and disposes before restart', async t => { test('AIS preview route ingests through the socket, returns tracks and disposes before restart', async (t) => {
const upstream = new WebSocketServer({ host: '127.0.0.1', port: 0 }); const upstream = new WebSocketServer({ host: '127.0.0.1', port: 0 });
await once(upstream, 'listening'); await once(upstream, 'listening');
const sockets = []; const sockets = [];
t.after(async () => { t.after(async () => {
for (const socket of sockets) socket.terminate(); for (const socket of sockets) socket.terminate();
await new Promise(resolve => upstream.close(resolve)); await new Promise((resolve) => upstream.close(resolve));
}); });
environment(t, { environment(t, {
AISSTREAM_API_KEY: 'fixture-key', AISSTREAM_API_KEY: 'fixture-key',
AISSTREAM_URL: `ws://127.0.0.1:${upstream.address().port}`, AISSTREAM_URL: `ws://127.0.0.1:${upstream.address().port}`,
AISSTREAM_BOUNDING_BOXES: undefined, AISSTREAM_MESSAGE_TYPES: undefined, AISSTREAM_BOUNDING_BOXES: undefined,
AISSTREAM_MESSAGE_TYPES: undefined,
AISSTREAM_SILENCE_TIMEOUT_MS: '0', AISSTREAM_SILENCE_TIMEOUT_MS: '0',
}); });
upstream.on('connection', socket => { upstream.on('connection', (socket) => {
sockets.push(socket); sockets.push(socket);
socket.on('message', raw => { socket.on('message', (raw) => {
assert.equal(JSON.parse(raw).APIKey, 'fixture-key'); assert.equal(JSON.parse(raw).APIKey, 'fixture-key');
for (const [lat, epoch] of [[30, 1_800_000_000], [30.01, 1_800_000_060]]) { for (const [lat, epoch] of [
socket.send(JSON.stringify({ [30, 1_800_000_000],
MessageType: 'PositionReport', [30.01, 1_800_000_060],
MetaData: { MMSI: 123456789, latitude: lat, longitude: -97, time_utc: new Date(epoch * 1000).toISOString() }, ]) {
Message: { PositionReport: { UserID: 123456789, Sog: 10, Cog: 90, TrueHeading: 511 } }, socket.send(
})); JSON.stringify({
MessageType: 'PositionReport',
MetaData: {
MMSI: 123456789,
latitude: lat,
longitude: -97,
time_utc: new Date(epoch * 1000).toISOString(),
},
Message: {
PositionReport: {
UserID: 123456789,
Sog: 10,
Cog: 90,
TrueHeading: 511,
},
},
}),
);
} }
}); });
}); });
@@ -151,7 +217,10 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
assert.equal(data.rows[0].heading, null); assert.equal(data.rows[0].heading, null);
const history = await request('/api/ais-live', '/track?mmsi=123456789'); const history = await request('/api/ais-live', '/track?mmsi=123456789');
assert.equal(JSON.parse(history.body).samples.length, 2); assert.equal(JSON.parse(history.body).samples.length, 2);
assert.equal((await request('/api/ais-live', '/track?mmsi=bad')).statusCode, 400); assert.equal(
(await request('/api/ais-live', '/track?mmsi=bad')).statusCode,
400,
);
assert.equal(sockets.length, 1); assert.equal(sockets.length, 1);
plugin.closeBundle(); plugin.closeBundle();
for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10); for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);