mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
style: format extracted live provider modules
This commit is contained in:
1 parent
0e221441b4
commit
f9a85528fe
14 files changed
+778
-362
No files matched your search
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user