diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5384e0a23..c22e24133 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -31,5 +31,18 @@ "build/vite.js", "server/standalone/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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7727021dc..3653188c5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,17 +45,11 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] } } diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index e8ce60b0c..418d9da0c 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -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; } diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js index c07c4204f..67e61e382 100644 --- a/server/providers/aircraft/enrichment.js +++ b/server/providers/aircraft/enrichment.js @@ -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) { diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index c3ec896f1..4a6715faf 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -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} 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' })); } diff --git a/server/providers/aircraft/tracks.js b/server/providers/aircraft/tracks.js index 2c11b278f..7d9501628 100644 --- a/server/providers/aircraft/tracks.js +++ b/server/providers/aircraft/tracks.js @@ -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; diff --git a/server/providers/common/http.js b/server/providers/common/http.js index 361ce928c..5ec7cf785 100644 --- a/server/providers/common/http.js +++ b/server/providers/common/http.js @@ -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 }); diff --git a/server/providers/live.js b/server/providers/live.js index c67109041..3afb5b8c9 100644 --- a/server/providers/live.js +++ b/server/providers/live.js @@ -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'; diff --git a/server/providers/vessels/ais-live.js b/server/providers/vessels/ais-live.js index d40c04539..2f8f8a25f 100644 --- a/server/providers/vessels/ais-live.js +++ b/server/providers/vessels/ais-live.js @@ -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); } } diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 0ba5235f0..4a53464f6 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -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(); } diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index d18d5e34d..7cd887dc6 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -9,7 +9,9 @@ function finiteNumber(value) { } function emitterCategory(value) { - const category = String(value || '').trim().toUpperCase(); + const category = String(value || '') + .trim() + .toUpperCase(); const categories = { A1: 2, A2: 3, @@ -36,18 +38,24 @@ function emitterCategory(value) { * @returns {Array|null} OpenSky-compatible state vector, or null when invalid. */ 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 longitude = finiteNumber(aircraft?.lon); 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 onGround = aircraft?.alt_baro === 'ground'; const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro); const geometricFeet = finiteNumber(aircraft?.alt_geom); 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); return [ @@ -80,9 +88,12 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { */ export function normalizeAdsbLolPointResponse(payload) { const responseNow = finiteNumber(payload?.now); - const nowSeconds = responseNow === null - ? Math.floor(Date.now() / 1000) - : Math.floor(responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow); + const nowSeconds = + responseNow === null + ? Math.floor(Date.now() / 1000) + : Math.floor( + responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow, + ); const states = (Array.isArray(payload?.ac) ? payload.ac : []) .map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds)) .filter(Boolean); diff --git a/src/data/aisStreamAdapter.js b/src/data/aisStreamAdapter.js index 3235b2f6f..1f7b0ef9b 100644 --- a/src/data/aisStreamAdapter.js +++ b/src/data/aisStreamAdapter.js @@ -26,10 +26,12 @@ const DEFAULT_CLOCK = Object.freeze({ }); /** 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. */ -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. @@ -61,7 +63,8 @@ export function parseRetryAfterMs(raw, nowMs = Date.now()) { */ export function classifyAisFailure(input = {}) { 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); if (!Number.isFinite(status)) { @@ -72,7 +75,10 @@ export function classifyAisFailure(input = {}) { } 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) { return { @@ -82,10 +88,14 @@ export function classifyAisFailure(input = {}) { }; } 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 (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' }; } @@ -151,7 +161,8 @@ export function aisFrameByteLength(data) { if (typeof data === 'string') return data.length; if (data instanceof ArrayBuffer) 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; } @@ -168,33 +179,35 @@ export function aisFrameByteLength(data) { * * If AISStream adds a type, add it here — do not infer it. */ -export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(new Set([ - 'PositionReport', - 'UnknownMessage', - 'AddressedSafetyMessage', - 'AddressedBinaryMessage', - 'AidsToNavigationReport', - 'AssignedModeCommand', - 'BaseStationReport', - 'BinaryAcknowledge', - 'BinaryBroadcastMessage', - 'ChannelManagement', - 'CoordinatedUTCInquiry', - 'DataLinkManagementMessage', - 'DataLinkManagementMessageData', - 'ExtendedClassBPositionReport', - 'GroupAssignmentCommand', - 'GnssBroadcastBinaryMessage', - 'Interrogation', - 'LongRangeAisBroadcastMessage', - 'MultiSlotBinaryMessage', - 'SafetyBroadcastMessage', - 'ShipStaticData', - 'SingleSlotBinaryMessage', - 'StandardClassBPositionReport', - 'StandardSearchAndRescueAircraftReport', - 'StaticDataReport', -])); +export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze( + new Set([ + 'PositionReport', + 'UnknownMessage', + 'AddressedSafetyMessage', + 'AddressedBinaryMessage', + 'AidsToNavigationReport', + 'AssignedModeCommand', + 'BaseStationReport', + 'BinaryAcknowledge', + 'BinaryBroadcastMessage', + 'ChannelManagement', + 'CoordinatedUTCInquiry', + 'DataLinkManagementMessage', + 'DataLinkManagementMessageData', + 'ExtendedClassBPositionReport', + 'GroupAssignmentCommand', + 'GnssBroadcastBinaryMessage', + 'Interrogation', + 'LongRangeAisBroadcastMessage', + 'MultiSlotBinaryMessage', + 'SafetyBroadcastMessage', + 'ShipStaticData', + 'SingleSlotBinaryMessage', + 'StandardClassBPositionReport', + 'StandardSearchAndRescueAircraftReport', + 'StaticDataReport', + ]), +); /** * Resolve an envelope's MMSI, or null. @@ -221,9 +234,14 @@ export function aisEnvelopeMmsi(envelope, body = {}) { * @returns {boolean} */ 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; - 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]; if (!body || typeof body !== 'object') return false; return aisEnvelopeMmsi(envelope, body) !== null; @@ -281,7 +299,10 @@ export function createAisStreamAdapter(options) { /** (Re)build the state machine, preserving the generation namespace. */ function setWatchdogOptions(watchdogOptions = {}) { if (watchdog) { - generationHighWater = Math.max(generationHighWater, watchdog.highWaterGeneration()); + generationHighWater = Math.max( + generationHighWater, + watchdog.highWaterGeneration(), + ); } watchdog = createAisWatchdog({ ...watchdogOptions, @@ -306,7 +327,9 @@ export function createAisStreamAdapter(options) { try { socket.terminate(); } 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) { 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); } } @@ -349,11 +373,18 @@ export function createAisStreamAdapter(options) { try { socket = createSocket(resolveUrl()); } catch (error) { - failGeneration(owner, generation, classifyAisFailure({ message: error?.message })); + failGeneration( + owner, + generation, + classifyAisFailure({ message: error?.message }), + ); return; } if (!socket) { - failGeneration(owner, generation, { kind: 'transport', message: 'socket factory returned nothing' }); + failGeneration(owner, generation, { + kind: 'transport', + message: 'socket factory returned nothing', + }); return; } sockets.set(generation, socket); @@ -386,7 +417,11 @@ export function createAisStreamAdapter(options) { try { socket.send(JSON.stringify(buildSubscription())); } 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; } 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) { // A frame that cannot even be decoded is a malformed frame: dropped, @@ -427,7 +464,9 @@ export function createAisStreamAdapter(options) { }); try { response?.destroy?.(); - } catch { /* already gone */ } + } catch { + /* already gone */ + } releaseSocketEntry(generation, socket); abort(socket, 'unexpected-response'); failGeneration(owner, generation, detail); @@ -440,10 +479,14 @@ export function createAisStreamAdapter(options) { abort(socket, 'orphan-error'); return; } - failGeneration(owner, generation, classifyAisFailure({ - message: error?.message || String(error || ''), - nowMs: clock.wall(), - })); + failGeneration( + owner, + generation, + classifyAisFailure({ + message: error?.message || String(error || ''), + nowMs: clock.wall(), + }), + ); }); on('close', () => { @@ -482,10 +525,14 @@ export function createAisStreamAdapter(options) { if (parsed.kind === 'error') { // An error envelope is the OPPOSITE of liveness. Classifying it keeps a // rejected key out of the fast ladder. - failGeneration(owner, generation, classifyAisFailure({ - message: parsed.message, - nowMs: clock.wall(), - })); + failGeneration( + owner, + generation, + classifyAisFailure({ + message: parsed.message, + nowMs: clock.wall(), + }), + ); return; } @@ -504,7 +551,10 @@ export function createAisStreamAdapter(options) { /** Tear down sockets and machine state, preserving the generation namespace. */ function dispose() { if (watchdog) { - generationHighWater = Math.max(generationHighWater, watchdog.highWaterGeneration()); + generationHighWater = Math.max( + generationHighWater, + watchdog.highWaterGeneration(), + ); runActions(watchdog, watchdog.dispose()); } for (const [generation, socket] of [...sockets]) { diff --git a/src/data/aisWatchdog.js b/src/data/aisWatchdog.js index eaf5ac614..511644482 100644 --- a/src/data/aisWatchdog.js +++ b/src/data/aisWatchdog.js @@ -63,7 +63,11 @@ export const AIS_WATCHDOG_STATUSES = Object.freeze([ ]); /** 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. */ const QUIET_TERMINAL = new Set(['down', 'auth-failed']); @@ -92,8 +96,8 @@ export function parseSilenceTimeoutEnv(raw, warn) { if (!text) return { kind: 'default' }; if (!/^\d+(\.\d+)?$/.test(text)) { warn?.( - `[AISStream] Ignoring AISSTREAM_SILENCE_TIMEOUT_MS="${raw}" (not a non-negative number); ` - + `using the ${AIS_WATCHDOG_DEFAULTS.staleMs}ms default.`, + `[AISStream] Ignoring AISSTREAM_SILENCE_TIMEOUT_MS="${raw}" (not a non-negative number); ` + + `using the ${AIS_WATCHDOG_DEFAULTS.staleMs}ms default.`, ); return { kind: 'default' }; } @@ -132,11 +136,18 @@ export function createAisWatchdog(options = {}) { staleMs, positiveOr(options.recycleAfterMs, AIS_WATCHDOG_DEFAULTS.recycleAfterMs), ); - const backoffMs = Array.isArray(options.backoffMs) && options.backoffMs.length - ? options.backoffMs.map((ms) => positiveOr(ms, 5_000)) - : [...AIS_WATCHDOG_DEFAULTS.backoffMs]; - const downRetryMs = positiveOr(options.downRetryMs, AIS_WATCHDOG_DEFAULTS.downRetryMs); - const authProbeMs = positiveOr(options.authProbeMs, AIS_WATCHDOG_DEFAULTS.authProbeMs); + const backoffMs = + Array.isArray(options.backoffMs) && options.backoffMs.length + ? options.backoffMs.map((ms) => positiveOr(ms, 5_000)) + : [...AIS_WATCHDOG_DEFAULTS.backoffMs]; + const downRetryMs = positiveOr( + options.downRetryMs, + AIS_WATCHDOG_DEFAULTS.downRetryMs, + ); + const authProbeMs = positiveOr( + options.authProbeMs, + AIS_WATCHDOG_DEFAULTS.authProbeMs, + ); const clock = options.clock || DEFAULT_CLOCK; let status = 'idle'; @@ -184,7 +195,8 @@ export function createAisWatchdog(options = {}) { // unproven, and hammering on an unproven key is the behaviour this whole // state exists to prevent. Only valid data or a key rotation leaves // auth-failed. - const effective = status === 'auth-failed' && kind !== 'auth' ? 'auth' : kind; + const effective = + status === 'auth-failed' && kind !== 'auth' ? 'auth' : kind; reconnectAttempt += 1; if (effective === 'auth') { @@ -201,7 +213,8 @@ export function createAisWatchdog(options = {}) { reconnectAttempt = Math.max(reconnectAttempt, backoffMs.length); const wait = positiveOr(retryAfterMs, backoffMs[backoffMs.length - 1]); status = isExhausted() ? 'down' : 'reconnecting'; - nextAttemptMono = monoNow + (isExhausted() ? Math.max(wait, downRetryMs) : wait); + nextAttemptMono = + monoNow + (isExhausted() ? Math.max(wait, downRetryMs) : wait); return; } @@ -251,7 +264,8 @@ export function createAisWatchdog(options = {}) { // A changed credential is the one event that can plausibly fix an auth // rejection, so it clears the terminal state immediately. const nextFingerprint = env.keyFingerprint ?? null; - const credentialChanged = keyFingerprint !== null && nextFingerprint !== keyFingerprint; + const credentialChanged = + keyFingerprint !== null && nextFingerprint !== keyFingerprint; keyFingerprint = nextFingerprint; if (credentialChanged) { // Any socket in flight belongs to the OLD key — it subscribed with that @@ -353,9 +367,12 @@ export function createAisWatchdog(options = {}) { */ function onOpen(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 []; } @@ -367,7 +384,9 @@ export function createAisWatchdog(options = {}) { */ function onMessage(eventGeneration) { if (!ownsGeneration(eventGeneration)) { - return [{ type: 'terminate', generation: eventGeneration, reason: 'orphan' }]; + return [ + { type: 'terminate', generation: eventGeneration, reason: 'orphan' }, + ]; } const monoNow = clock.mono(); silenceSinceMono = monoNow; @@ -397,7 +416,9 @@ export function createAisWatchdog(options = {}) { */ function onFailure(eventGeneration, detail = {}) { 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 // credential message — surfacing "ECONNRESET" would send the operator // chasing the network instead of the key. @@ -433,7 +454,8 @@ export function createAisWatchdog(options = {}) { function snapshot() { const monoNow = clock.mono(); 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); return { status, @@ -442,9 +464,12 @@ export function createAisWatchdog(options = {}) { silentForMs, reconnectAttempt, // Projected onto the wall clock purely for display. - nextAttemptAt: (status === 'reconnecting' || status === 'down' || status === 'auth-failed') - ? wallNow + waitingMs - : null, + nextAttemptAt: + status === 'reconnecting' || + status === 'down' || + status === 'auth-failed' + ? wallNow + waitingMs + : null, watchdog: silenceWatchArmed ? 'armed' : 'custom-subscription-off', staleAfterMs: staleMs, }; @@ -452,7 +477,13 @@ export function createAisWatchdog(options = {}) { /** Test/diagnostic view of internal ownership. */ function debugState() { - return { status, owned, generation, reconnectAttempt, lastMessageAt: lastMessageWall }; + return { + status, + owned, + generation, + reconnectAttempt, + lastMessageAt: lastMessageWall, + }; } return { diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index d1e53e43c..b61de4eeb 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -9,18 +9,28 @@ import * as portable from '../../src/data/adsbLolFallback.js'; function install(plugin, preview = false) { const routes = new Map(); 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') => { assert.ok(routes.has(route), `registered route: ${route}`); const response = { - statusCode: 200, headers: {}, - setHeader(key, value) { this.headers[key.toLowerCase()] = value; }, + statusCode: 200, + headers: {}, + setHeader(key, value) { + this.headers[key.toLowerCase()] = value; + }, writeHead(status, headers = {}) { 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); return response; @@ -30,8 +40,12 @@ function install(plugin, preview = false) { function environment(t, values) { for (const [key, value] of Object.entries(values)) { const original = process.env[key]; - if (value === undefined) delete process.env[key]; else process.env[key] = value; - t.after(() => { if (original === undefined) delete process.env[key]; else process.env[key] = original; }); + if (value === undefined) delete process.env[key]; + 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.aisLiveProxy, providers.aisLiveProxy); 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, { - OPENSKY_CLIENT_ID: 'fixture-client', OPENSKY_CLIENT_SECRET: 'fixture-secret', - OPENSKY_AUTH_MODE: 'oauth', OPENSKY_USERNAME: undefined, OPENSKY_PASSWORD: undefined, + OPENSKY_CLIENT_ID: 'fixture-client', + OPENSKY_CLIENT_SECRET: 'fixture-secret', + OPENSKY_AUTH_MODE: 'oauth', + OPENSKY_USERNAME: undefined, + OPENSKY_PASSWORD: undefined, }); let now = Date.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 = []; t.mock.method(globalThis, 'fetch', async (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/')) { assert.equal(options.headers.Authorization, 'Bearer fixture-token'); 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'); 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}`); }); const states = install(providers.openSkyProxy()); - assert.equal((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( + (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); 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'); - assert.equal(calls.filter(call => call.url.includes('/token')).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(calls.filter((call) => call.url.includes('/token')).length, 1); + assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1); + assert.equal( + (await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode, + 400, + ); 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('/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}`); }); // 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' }); - 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.headers['x-flight-source'], 'adsb.lol'); 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 calls = 0; 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); }); -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 }); await once(upstream, 'listening'); const sockets = []; t.after(async () => { for (const socket of sockets) socket.terminate(); - await new Promise(resolve => upstream.close(resolve)); + await new Promise((resolve) => upstream.close(resolve)); }); environment(t, { AISSTREAM_API_KEY: 'fixture-key', 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', }); - upstream.on('connection', socket => { + upstream.on('connection', (socket) => { sockets.push(socket); - socket.on('message', raw => { + socket.on('message', (raw) => { assert.equal(JSON.parse(raw).APIKey, 'fixture-key'); - for (const [lat, epoch] of [[30, 1_800_000_000], [30.01, 1_800_000_060]]) { - 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 } }, - })); + for (const [lat, epoch] of [ + [30, 1_800_000_000], + [30.01, 1_800_000_060], + ]) { + 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); const history = await request('/api/ais-live', '/track?mmsi=123456789'); 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); plugin.closeBundle(); for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);