mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate aircraft and vessel server providers
This commit is contained in:
1 parent
149517da81
commit
0e221441b4
17 files changed
+1892
-1615
No files matched your search
@@ -13,6 +13,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Split aircraft and vessel server providers into focused modules for source
|
||||||
|
fetching, AIS records/tracks and shared request helpers; preserve existing
|
||||||
|
routes, local setup, fallback behavior and rendering.
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Separate explicit browser build settings from standalone environment loading
|
- Separate explicit browser build settings from standalone environment loading
|
||||||
and local provider middleware. Preserve provider behavior and root named exports.
|
and local provider middleware. Preserve provider behavior and root named exports.
|
||||||
|
|||||||
@@ -84,3 +84,19 @@ credential-store paths. Provider Settings writes to the same root `.env` or
|
|||||||
Pinokio store as before. `vite.config.js` preserves the default configuration and
|
Pinokio store as before. `vite.config.js` preserves the default configuration and
|
||||||
existing named provider exports for tools/tests. The provider module remains
|
existing named provider exports for tools/tests. The provider module remains
|
||||||
large; later extractions should split complete provider families and their tests.
|
large; later extractions should split complete provider families and their tests.
|
||||||
|
|
||||||
|
## Aircraft and vessel providers
|
||||||
|
|
||||||
|
`server/providers/live.js` exports the existing Node middleware factories and
|
||||||
|
request helpers. `aircraft/` owns OpenSky state/fallback, military positions,
|
||||||
|
enrichment and track endpoints in separate files. `vessels/ais-live.js` owns
|
||||||
|
websocket setup and route responses; `vessels/ais-store.js` owns record ingestion,
|
||||||
|
static metadata and recent-track storage. Neither area imports globe rendering.
|
||||||
|
`common/http.js` owns capped reads/coalescing; `common/query.js` owns query values.
|
||||||
|
|
||||||
|
These plugins retain their existing process-scoped caches and server lifetime.
|
||||||
|
Importing the entry does not start acquisition. The AIS plugin disposes its
|
||||||
|
socket/watchdog on server close and re-reads configuration after restart.
|
||||||
|
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
|
||||||
|
records without importing Node middleware or a renderer. Browser layer/controller
|
||||||
|
separation is outside this server extraction.
|
||||||
@@ -174,6 +174,23 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n
|
|||||||
|
|
||||||
Updated: August 24, 2026
|
Updated: August 24, 2026
|
||||||
|
|
||||||
|
## Aircraft and vessel server modules
|
||||||
|
|
||||||
|
Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky
|
||||||
|
state vectors and regional fallback, adsb.lol military positions, ADSBDB
|
||||||
|
enrichment and track backfill. Vessel routes and websocket/watchdog setup live
|
||||||
|
in `server/providers/vessels/ais-live.js`; AIS records and recent tracks live
|
||||||
|
in `ais-store.js`. Common response caps, request coalescing and query parsing
|
||||||
|
have their own modules. `server/providers/local.js` composes these with the
|
||||||
|
remaining providers and retains existing named compatibility exports.
|
||||||
|
|
||||||
|
`gods-eye-view/server/providers/live` is a Node-only entry for the existing
|
||||||
|
plugins and shared request helpers. Importing it starts no sockets or timers.
|
||||||
|
The existing aircraft normalizer is separately available through the portable
|
||||||
|
`gods-eye-view/sources/adsb-lol` export. Provider URLs, local credentials, cache
|
||||||
|
policy, fallback behavior, response shapes and rendering remain unchanged.
|
||||||
|
|
||||||
|
|
||||||
## Control names for assistive technology
|
## Control names for assistive technology
|
||||||
|
|
||||||
Scope, Bloom, Sharpen and location search have explicit accessible names.
|
Scope, Bloom, Sharpen and location search have explicit accessible names.
|
||||||
|
|||||||
+5
-1
@@ -69,6 +69,10 @@
|
|||||||
"./application/viewer": "./src/app/viewer.js",
|
"./application/viewer": "./src/app/viewer.js",
|
||||||
"./build/vite": {
|
"./build/vite": {
|
||||||
"node": "./build/vite.js"
|
"node": "./build/vite.js"
|
||||||
}
|
},
|
||||||
|
"./server/providers/live": {
|
||||||
|
"node": "./server/providers/live.js"
|
||||||
|
},
|
||||||
|
"./sources/adsb-lol": "./src/data/adsbLolFallback.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,22 +10,72 @@
|
|||||||
"src/data/localGeojsonCore.js",
|
"src/data/localGeojsonCore.js",
|
||||||
"src/data/localGeojsonLod.js"
|
"src/data/localGeojsonLod.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"application": {
|
"application": {
|
||||||
"exports": ["./application"],
|
"exports": [
|
||||||
"modules": ["src/app/application.js"],
|
"./application"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/app/application.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"exports": ["./application/viewer"],
|
"exports": [
|
||||||
"modules": ["src/app/viewer.js"],
|
"./application/viewer"
|
||||||
"external": ["cesium"]
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/app/viewer.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vite-build": {
|
"vite-build": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./build/vite"],
|
"exports": [
|
||||||
"modules": ["build/vite.js"],
|
"./build/vite"
|
||||||
"external": ["vite-plugin-cesium"]
|
],
|
||||||
|
"modules": [
|
||||||
|
"build/vite.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"vite-plugin-cesium"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"live-providers": {
|
||||||
|
"runtime": "node",
|
||||||
|
"exports": [
|
||||||
|
"./server/providers/live"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"server/providers/live.js",
|
||||||
|
"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",
|
||||||
|
"src/data/adsbLolFallback.js",
|
||||||
|
"src/data/aisStreamAdapter.js",
|
||||||
|
"src/data/aisWatchdog.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"ws"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"adsb-lol-source": {
|
||||||
|
"exports": [
|
||||||
|
"./sources/adsb-lol"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/adsbLolFallback.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
||||||
|
*
|
||||||
|
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. On upstream
|
||||||
|
* failure, serves a stale cached response if one exists.
|
||||||
|
*
|
||||||
|
* @returns {import('vite').Plugin}
|
||||||
|
*/
|
||||||
|
export function adsbLolProxy() {
|
||||||
|
/** @type {string|null} Cached upstream JSON body. */
|
||||||
|
let _cache = null;
|
||||||
|
/** @type {number} Epoch-ms when the cache was populated. */
|
||||||
|
let _cacheAt = 0;
|
||||||
|
/** Response cache TTL (ms). */
|
||||||
|
const CACHE_MS = 12000;
|
||||||
|
return {
|
||||||
|
name: 'adsblol-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-ADS-B-Cache': 'HIT' });
|
||||||
|
res.end(_cache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
||||||
|
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
||||||
|
});
|
||||||
|
const body = await upstream.text();
|
||||||
|
if (upstream.ok) {
|
||||||
|
_cache = body;
|
||||||
|
_cacheAt = now;
|
||||||
|
}
|
||||||
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-ADS-B-Cache': 'MISS' });
|
||||||
|
res.end(body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[adsb.lol Proxy]', e.message);
|
||||||
|
if (_cache) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-ADS-B-Cache': 'STALE' });
|
||||||
|
res.end(_cache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'ADS-B proxy error' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { promises as fsp } from 'node:fs';
|
||||||
|
/**
|
||||||
|
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
|
||||||
|
* airports) and hex → aircraft type/registration. Free community API — cached
|
||||||
|
* aggressively: ONE upstream request per new key ever (404s negative-cached),
|
||||||
|
* persisted to disk so restarts don't re-hammer it. Adapted from skylight
|
||||||
|
* (MIT) server/src/enrich/routes.ts.
|
||||||
|
*/
|
||||||
|
export function adsbdbProxy() {
|
||||||
|
const TTL_MS = 24 * 3600_000;
|
||||||
|
const CACHE_PATH = path.join(process.cwd(), '.gev-cache', 'adsbdb.json');
|
||||||
|
let cache = { routes: {}, aircraft: {} };
|
||||||
|
let dirty = false;
|
||||||
|
let loaded = false;
|
||||||
|
const inflight = new Map();
|
||||||
|
|
||||||
|
async function loadOnce() {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
|
||||||
|
cache = { routes: parsed.routes ?? {}, aircraft: parsed.aircraft ?? {} };
|
||||||
|
} catch { /* first run */ }
|
||||||
|
setInterval(async () => {
|
||||||
|
if (!dirty) return;
|
||||||
|
dirty = false;
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(path.dirname(CACHE_PATH), { recursive: true });
|
||||||
|
await fsp.writeFile(CACHE_PATH, JSON.stringify(cache), 'utf8');
|
||||||
|
} catch { dirty = true; } // retry next tick
|
||||||
|
}, 15_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = (e) => e && Date.now() - e.at < TTL_MS;
|
||||||
|
|
||||||
|
function parseRoute(json) {
|
||||||
|
const fr = json?.response?.flightroute;
|
||||||
|
if (!fr?.origin || !fr?.destination) return null;
|
||||||
|
const airport = (a) => ({
|
||||||
|
code: a.iata_code || a.icao_code || '',
|
||||||
|
name: a.municipality || a.name || '',
|
||||||
|
lat: Number.isFinite(a.latitude) ? a.latitude : null,
|
||||||
|
lon: Number.isFinite(a.longitude) ? a.longitude : null,
|
||||||
|
});
|
||||||
|
return { airline: fr.airline?.name || null, origin: airport(fr.origin), destination: airport(fr.destination) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAircraft(json) {
|
||||||
|
const a = json?.response?.aircraft;
|
||||||
|
if (!a) return null;
|
||||||
|
return {
|
||||||
|
typeCode: a.icao_type || null, // ICAO designator, e.g. "B738" — feeds classifyAircraft
|
||||||
|
typeName: a.manufacturer && a.type ? `${a.manufacturer} ${a.type}` : (a.type || null),
|
||||||
|
registration: a.registration || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(kind, key) {
|
||||||
|
const store = kind === 'route' ? cache.routes : cache.aircraft;
|
||||||
|
if (fresh(store[key])) return Promise.resolve(store[key].data);
|
||||||
|
const ik = `${kind}:${key}`;
|
||||||
|
if (!inflight.has(ik)) {
|
||||||
|
inflight.set(ik, (async () => {
|
||||||
|
try {
|
||||||
|
const url = kind === 'route'
|
||||||
|
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
|
||||||
|
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = kind === 'route' ? parseRoute(await res.json()) : parseAircraft(await res.json());
|
||||||
|
store[key] = { at: Date.now(), data }; // data may be null — negative cache
|
||||||
|
dirty = true;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
store[key] = { at: Date.now(), data: null }; // known-missing — cache the miss
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
// other statuses: leave uncached so we retry later
|
||||||
|
return fresh(store[key]) ? store[key].data : null;
|
||||||
|
} catch {
|
||||||
|
return fresh(store[key]) ? store[key].data : null; // network error → stale if any
|
||||||
|
} finally {
|
||||||
|
inflight.delete(ik);
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
return inflight.get(ik);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'adsbdb-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/adsbdb', async (req, res) => {
|
||||||
|
await loadOnce();
|
||||||
|
const send = (status, obj) => {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const [, kind, rawKey] = String(req.url || '').split('?')[0].split('/');
|
||||||
|
if (kind === 'route') {
|
||||||
|
const cs = String(rawKey || '').toUpperCase();
|
||||||
|
if (!/^[A-Z0-9]{2,8}$/.test(cs)) return send(400, { error: 'invalid callsign' });
|
||||||
|
const data = await lookup('route', cs);
|
||||||
|
return send(200, data ? { found: true, ...data } : { found: false });
|
||||||
|
}
|
||||||
|
if (kind === 'type') {
|
||||||
|
const hex = String(rawKey || '').toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{6}$/.test(hex)) return send(400, { error: 'invalid hex' });
|
||||||
|
const data = await lookup('aircraft', hex);
|
||||||
|
return send(200, data ? { found: true, ...data } : { found: false });
|
||||||
|
}
|
||||||
|
return send(404, { error: 'unknown endpoint' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[adsbdb-proxy] request failed');
|
||||||
|
return send(500, { error: 'adsbdb proxy error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
|
||||||
|
import { coalesceProxyRequest, readResponseJsonCapped } from '../common/http.js';
|
||||||
|
import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OpenSky OAuth2 token + response cache state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** @type {string|null} Current OAuth2 bearer token. */
|
||||||
|
let _openskyToken = null;
|
||||||
|
/** @type {number} Epoch-ms when the current token expires. */
|
||||||
|
let _openskyTokenExpiry = 0;
|
||||||
|
/** @type {Promise<string|null>|null} In-flight token refresh promise (coalesces concurrent callers). */
|
||||||
|
let _openskyTokenPromise = null;
|
||||||
|
/** @type {string|null} Cached upstream response body (JSON text). */
|
||||||
|
let _openskyCacheBody = null;
|
||||||
|
/** @type {number} HTTP status of the cached response. */
|
||||||
|
let _openskyCacheStatus = 0;
|
||||||
|
/** @type {number} Epoch-ms when the response was cached. */
|
||||||
|
let _openskyCacheTime = 0;
|
||||||
|
/** @type {{requestedMode:string,usedMode:string,reason:string}|null} Auth metadata for the cached response. */
|
||||||
|
let _openskyCacheMeta = null;
|
||||||
|
/** @type {number|null} Source snapshot epoch from the cached OpenSky body. */
|
||||||
|
let _openskyCacheSourceEpochMs = null;
|
||||||
|
/** TTL for the OpenSky response cache (ms). */
|
||||||
|
const OPENSKY_CACHE_MS = 9000;
|
||||||
|
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
|
||||||
|
// The global /states/all this proxy fetches costs 4 CREDITS per call against
|
||||||
|
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
|
||||||
|
// the whole quota in ~8h and the layer then hard-died until the daily reset
|
||||||
|
// ("rate limited for 48h" owner report; auth itself was fine). Three levers:
|
||||||
|
// 1. Adaptive TTL: OpenSky returns X-Rate-Limit-Remaining on success; as the
|
||||||
|
// budget thins, the proxy stretches its cache TTL so a full day of
|
||||||
|
// continuous use never exhausts it.
|
||||||
|
// 2. 429 cooldown: honor X-Rate-Limit-Retry-After-Seconds — no upstream
|
||||||
|
// attempts until it passes (bounded 30 s … 30 min).
|
||||||
|
// 3. Serve-stale: while rate-limited/cooling, serve the last-good body (200 +
|
||||||
|
// X-OpenSky-Stale) so the layer keeps rendering instead of dying.
|
||||||
|
/** @type {number} Current adaptive TTL (ms) — starts at the base cache TTL. */
|
||||||
|
let _openskyTtlMs = OPENSKY_CACHE_MS;
|
||||||
|
/** @type {number} Epoch-ms before which no upstream fetch is attempted. */
|
||||||
|
let _openskyCooldownUntil = 0;
|
||||||
|
/**
|
||||||
|
* Picks the cache TTL from the remaining daily credit budget.
|
||||||
|
* Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the
|
||||||
|
* later tiers stretch the day: >2400 → ~3 h of full freshness, then 30 s
|
||||||
|
* (~2.5 h), 90 s (~5 h), 300 s (~8 h) ≈ 18+ h of continuous use per day.
|
||||||
|
* @param {number} remaining - X-Rate-Limit-Remaining header value.
|
||||||
|
* @returns {number} TTL in ms.
|
||||||
|
*/
|
||||||
|
function openskyAdaptiveTtlMs(remaining) {
|
||||||
|
if (!Number.isFinite(remaining)) return OPENSKY_CACHE_MS;
|
||||||
|
if (remaining > 2400) return OPENSKY_CACHE_MS;
|
||||||
|
if (remaining > 1200) return 30_000;
|
||||||
|
if (remaining > 400) return 90_000;
|
||||||
|
return 300_000;
|
||||||
|
}
|
||||||
|
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
|
||||||
|
let _openskyAuthWarned = false;
|
||||||
|
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
|
||||||
|
let _openskyAuthModeWarned = false;
|
||||||
|
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
|
||||||
|
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
|
||||||
|
/** Set of valid OPENSKY_AUTH_MODE values. */
|
||||||
|
const OPENSKY_AUTH_MODE_SET = new Set(['basic', 'oauth', 'auto', 'anon']);
|
||||||
|
/** Regional civilian fallback cache, keyed by a coarse 0.25° view anchor. */
|
||||||
|
const _adsbLolPointCache = new Map();
|
||||||
|
/** Per-anchor single-flight map for concurrent regional fallback requests. */
|
||||||
|
const _adsbLolPointInFlight = new Map();
|
||||||
|
const ADSBLOL_POINT_CACHE_MS = 12000;
|
||||||
|
const ADSBLOL_POINT_CACHE_MAX = 80;
|
||||||
|
const ADSBLOL_POINT_RADIUS_NM = 250;
|
||||||
|
const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
|
// A 200 response can still contain an old OpenSky snapshot. Past this point
|
||||||
|
// the viewport-scoped adsb.lol source is more honest and keeps local motion
|
||||||
|
// current instead of coasting a stale worldwide frame indefinitely.
|
||||||
|
const OPENSKY_SOURCE_STALE_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
|
||||||
|
*
|
||||||
|
* Uses the client_credentials grant against the OpenSky Keycloak realm.
|
||||||
|
* Concurrent callers share a single in-flight refresh promise so only
|
||||||
|
* one token request is issued at a time.
|
||||||
|
*
|
||||||
|
* @returns {Promise<string|null>} Bearer token string, or null if unavailable.
|
||||||
|
*/
|
||||||
|
export async function getOpenSkyToken() {
|
||||||
|
const now = Date.now();
|
||||||
|
// Return cached token if still valid (with 60 s safety margin)
|
||||||
|
if (_openskyToken && now < _openskyTokenExpiry - 60000) return _openskyToken;
|
||||||
|
|
||||||
|
// Coalesce concurrent refresh requests — if a refresh is already in-flight,
|
||||||
|
// return the same promise instead of issuing a duplicate token request
|
||||||
|
if (_openskyTokenPromise) return _openskyTokenPromise;
|
||||||
|
|
||||||
|
const clientId = process.env.OPENSKY_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
|
||||||
|
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
|
||||||
|
_openskyTokenPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = data?.access_token;
|
||||||
|
const expiresIn = Number(data?.expires_in);
|
||||||
|
if (!res.ok || !accessToken) {
|
||||||
|
if (!_openskyAuthWarned) {
|
||||||
|
const detail = data?.error_description || data?.error || `HTTP ${res.status}`;
|
||||||
|
console.warn('[OpenSky] OAuth client_credentials failed:', detail);
|
||||||
|
_openskyAuthWarned = true;
|
||||||
|
}
|
||||||
|
_openskyToken = null;
|
||||||
|
_openskyTokenExpiry = 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_openskyToken = accessToken;
|
||||||
|
// Default to 1800 s (30 min) if expires_in is missing or non-finite
|
||||||
|
_openskyTokenExpiry = Date.now() + (Number.isFinite(expiresIn) ? expiresIn : 1800) * 1000;
|
||||||
|
console.log('[OpenSky] OAuth token refreshed, expires in', Number.isFinite(expiresIn) ? expiresIn : 1800, 's');
|
||||||
|
_openskyAuthWarned = false;
|
||||||
|
return _openskyToken;
|
||||||
|
} catch (err) {
|
||||||
|
if (!_openskyAuthWarned) {
|
||||||
|
console.warn('[OpenSky] OAuth token request failed:', err?.message || String(err));
|
||||||
|
_openskyAuthWarned = true;
|
||||||
|
}
|
||||||
|
_openskyToken = null;
|
||||||
|
_openskyTokenExpiry = 0;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// Clear the shared promise so the next caller can start a fresh refresh
|
||||||
|
_openskyTokenPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return _openskyTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalize the OPENSKY_AUTH_MODE env value.
|
||||||
|
*
|
||||||
|
* @param {string} value - Raw env value (e.g. 'basic', 'oauth', 'auto', 'anon').
|
||||||
|
* @returns {string} One of the valid mode strings, or the default ('oauth').
|
||||||
|
*/
|
||||||
|
function normalizeOpenSkyAuthMode(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) return OPENSKY_AUTH_MODE_DEFAULT;
|
||||||
|
if (OPENSKY_AUTH_MODE_SET.has(raw)) return raw;
|
||||||
|
if (!_openskyAuthModeWarned) {
|
||||||
|
console.warn(
|
||||||
|
`[OpenSky] Invalid OPENSKY_AUTH_MODE="${raw}", defaulting to "${OPENSKY_AUTH_MODE_DEFAULT}"`
|
||||||
|
);
|
||||||
|
_openskyAuthModeWarned = true;
|
||||||
|
}
|
||||||
|
return OPENSKY_AUTH_MODE_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build standard response headers for OpenSky proxy responses.
|
||||||
|
*
|
||||||
|
* Includes diagnostic X-OpenSky-* headers so the client can inspect
|
||||||
|
* cache hit/miss status and which auth mode was actually used.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.cacheStatus - 'HIT', 'MISS', or 'STALE'.
|
||||||
|
* @param {string} opts.requestedMode - The auth mode the config requested.
|
||||||
|
* @param {string} opts.usedMode - The auth mode actually used for the upstream call.
|
||||||
|
* @param {string} opts.reason - Human-readable reason string for diagnostics.
|
||||||
|
* @returns {Record<string,string>} Header object.
|
||||||
|
*/
|
||||||
|
function buildOpenSkyHeaders({ cacheStatus, requestedMode, usedMode, reason, staleSeconds, retryAfterSeconds }) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-OpenSky-Cache': cacheStatus,
|
||||||
|
'X-OpenSky-Auth': usedMode,
|
||||||
|
'X-OpenSky-Auth-Mode-Requested': requestedMode,
|
||||||
|
'X-OpenSky-Auth-Mode-Used': usedMode,
|
||||||
|
'X-OpenSky-Auth-Reason': reason,
|
||||||
|
};
|
||||||
|
// Credit-governor extras (field-test fix 2026-07-06): the client can show a
|
||||||
|
// STALE cue / countdown without parsing the body.
|
||||||
|
if (Number.isFinite(staleSeconds)) headers['X-OpenSky-Stale-Seconds'] = String(Math.round(staleSeconds));
|
||||||
|
if (Number.isFinite(retryAfterSeconds)) headers['X-OpenSky-Retry-After-Seconds'] = String(Math.round(retryAfterSeconds));
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adsbLolFallbackAnchor(req) {
|
||||||
|
const incoming = new URL(req?.url || '', 'http://localhost');
|
||||||
|
const latitude = requiredFiniteQueryNumber(incoming.searchParams, 'lat');
|
||||||
|
const longitude = requiredFiniteQueryNumber(incoming.searchParams, 'lon');
|
||||||
|
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null;
|
||||||
|
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null;
|
||||||
|
return { latitude, longitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdsbLolPointFallback(req) {
|
||||||
|
const anchor = adsbLolFallbackAnchor(req);
|
||||||
|
if (!anchor) return null;
|
||||||
|
const roundedLat = Math.round(anchor.latitude * 4) / 4;
|
||||||
|
const roundedLon = Math.round(anchor.longitude * 4) / 4;
|
||||||
|
const cacheKey = `${roundedLat.toFixed(2)},${roundedLon.toFixed(2)}`;
|
||||||
|
const cached = _adsbLolPointCache.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now - cached.cachedAt < ADSBLOL_POINT_CACHE_MS) {
|
||||||
|
return { ...cached, cacheStatus: 'HIT' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = coalesceProxyRequest(_adsbLolPointInFlight, cacheKey, async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(
|
||||||
|
`https://api.adsb.lol/v2/lat/${roundedLat}/lon/${roundedLon}/dist/${ADSBLOL_POINT_RADIUS_NM}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'gods-eye-view-adsblol-regional-fallback/1.0',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!upstream.ok) throw new Error(`upstream HTTP ${upstream.status}`);
|
||||||
|
const payload = await readResponseJsonCapped(upstream, ADSBLOL_POINT_MAX_RESPONSE_BYTES);
|
||||||
|
const normalized = normalizeAdsbLolPointResponse(payload);
|
||||||
|
const record = {
|
||||||
|
body: JSON.stringify(normalized),
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
count: normalized.states.length,
|
||||||
|
};
|
||||||
|
_adsbLolPointCache.delete(cacheKey);
|
||||||
|
_adsbLolPointCache.set(cacheKey, record);
|
||||||
|
while (_adsbLolPointCache.size > ADSBLOL_POINT_CACHE_MAX) {
|
||||||
|
_adsbLolPointCache.delete(_adsbLolPointCache.keys().next().value);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const record = await request.promise;
|
||||||
|
return { ...record, cacheStatus: request.shared ? 'INFLIGHT' : 'MISS' };
|
||||||
|
} catch (error) {
|
||||||
|
if (!request.shared && error?.name !== 'AbortError') {
|
||||||
|
console.warn('[adsb.lol Flights Fallback]', error?.message || error);
|
||||||
|
}
|
||||||
|
return cached ? { ...cached, cacheStatus: 'STALE' } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
|
||||||
|
const fallback = await fetchAdsbLolPointFallback(req);
|
||||||
|
if (!fallback) return false;
|
||||||
|
res.writeHead(200, {
|
||||||
|
...buildOpenSkyHeaders({
|
||||||
|
cacheStatus: fallback.cacheStatus,
|
||||||
|
requestedMode,
|
||||||
|
usedMode: 'adsblol-regional',
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
'X-Flight-Source': 'adsb.lol',
|
||||||
|
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
|
||||||
|
'X-Flight-Count': String(fallback.count),
|
||||||
|
});
|
||||||
|
res.end(fallback.body);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSkySourceEpochMs(body) {
|
||||||
|
try {
|
||||||
|
const seconds = Number(JSON.parse(body)?.time);
|
||||||
|
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
||||||
|
return Number.isFinite(sourceEpochMs)
|
||||||
|
&& now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin: OpenSky Network proxy with multi-mode auth and response caching.
|
||||||
|
*
|
||||||
|
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
|
||||||
|
* - 'oauth' (default) — client_credentials bearer token
|
||||||
|
* - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD
|
||||||
|
* - 'auto' — try OAuth first, fall back to Basic, then anon
|
||||||
|
* - 'anon' — no credentials
|
||||||
|
*
|
||||||
|
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
|
||||||
|
* upstream failure the proxy serves a stale cached response if available, or
|
||||||
|
* a bounded 250 nm adsb.lol point snapshot around the current view anchor.
|
||||||
|
*
|
||||||
|
* @returns {import('vite').Plugin}
|
||||||
|
*/
|
||||||
|
export function openSkyProxy() {
|
||||||
|
return {
|
||||||
|
name: 'opensky-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/opensky', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedMode = normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE);
|
||||||
|
const now = Date.now();
|
||||||
|
const inCooldown = now < _openskyCooldownUntil;
|
||||||
|
// Fresh-enough cache (adaptive TTL) OR any cache during a 429
|
||||||
|
// cooldown: serve it without touching upstream. Stale-during-cooldown
|
||||||
|
// is deliberate (credit governor): last-good planes beat a dead layer.
|
||||||
|
if (_openskyCacheBody && (now - _openskyCacheTime < _openskyTtlMs || inCooldown)) {
|
||||||
|
if (
|
||||||
|
openSkySourceIsStale(_openskyCacheSourceEpochMs, now)
|
||||||
|
&& await serveAdsbLolPointFallback(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
requestedMode,
|
||||||
|
'opensky_snapshot_stale_regional_fallback',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cachedMeta = _openskyCacheMeta || {
|
||||||
|
requestedMode,
|
||||||
|
usedMode: 'unknown',
|
||||||
|
reason: 'cached',
|
||||||
|
};
|
||||||
|
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
|
||||||
|
res.writeHead(
|
||||||
|
_openskyCacheStatus || 200,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: isStale ? 'STALE' : 'HIT',
|
||||||
|
requestedMode: cachedMeta.requestedMode || requestedMode,
|
||||||
|
usedMode: cachedMeta.usedMode || 'unknown',
|
||||||
|
reason: isStale ? 'rate_limited_serving_stale' : (cachedMeta.reason || 'cached'),
|
||||||
|
staleSeconds: isStale ? (now - _openskyCacheTime) / 1000 : undefined,
|
||||||
|
retryAfterSeconds: inCooldown ? (_openskyCooldownUntil - now) / 1000 : undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.end(_openskyCacheBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cooling down with nothing cached (cold start into a rate limit):
|
||||||
|
// synthesize the 429 locally — hammering upstream mid-cooldown can't
|
||||||
|
// succeed and just burns goodwill.
|
||||||
|
if (inCooldown) {
|
||||||
|
if (await serveAdsbLolPointFallback(req, res, requestedMode, 'opensky_cooldown_regional_fallback')) return;
|
||||||
|
res.writeHead(429, buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'COOLDOWN',
|
||||||
|
requestedMode,
|
||||||
|
usedMode: 'none',
|
||||||
|
reason: 'rate_limited',
|
||||||
|
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
|
||||||
|
}));
|
||||||
|
res.end(JSON.stringify({ error: 'OpenSky rate limited; proxy cooling down.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicUser = process.env.OPENSKY_USERNAME || '';
|
||||||
|
const basicPass = process.env.OPENSKY_PASSWORD || '';
|
||||||
|
const hasBasicCreds = Boolean(basicUser && basicPass);
|
||||||
|
const headers = { 'Accept': 'application/json' };
|
||||||
|
let usedMode = 'anon';
|
||||||
|
let reason = 'forced_anonymous';
|
||||||
|
|
||||||
|
if (requestedMode === 'basic') {
|
||||||
|
if (hasBasicCreds) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||||
|
usedMode = 'basic';
|
||||||
|
reason = 'basic_credentials';
|
||||||
|
} else {
|
||||||
|
reason = 'missing_basic_creds';
|
||||||
|
}
|
||||||
|
} else if (requestedMode === 'oauth') {
|
||||||
|
const token = await getOpenSkyToken();
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
usedMode = 'oauth';
|
||||||
|
reason = 'oauth_token';
|
||||||
|
} else {
|
||||||
|
reason = 'oauth_invalid_or_missing';
|
||||||
|
}
|
||||||
|
} else if (requestedMode === 'auto') {
|
||||||
|
const token = await getOpenSkyToken();
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
usedMode = 'oauth';
|
||||||
|
reason = 'oauth_token';
|
||||||
|
} else if (hasBasicCreds) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||||
|
usedMode = 'basic';
|
||||||
|
reason = 'oauth_unavailable_fallback_basic';
|
||||||
|
} else {
|
||||||
|
reason = 'missing_oauth_and_basic_creds';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream = await fetch('https://opensky-network.org/api/states/all?extended=1', { headers });
|
||||||
|
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||||
|
if (
|
||||||
|
(upstream.status === 401 || upstream.status === 403) &&
|
||||||
|
requestedMode === 'auto' &&
|
||||||
|
usedMode === 'oauth' &&
|
||||||
|
hasBasicCreds
|
||||||
|
) {
|
||||||
|
const retryHeaders = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||||
|
};
|
||||||
|
upstream = await fetch('https://opensky-network.org/api/states/all?extended=1', { headers: retryHeaders });
|
||||||
|
usedMode = 'basic';
|
||||||
|
reason = 'oauth_rejected_fallback_basic';
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = await upstream.text();
|
||||||
|
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
||||||
|
if (
|
||||||
|
upstream.ok
|
||||||
|
&& openSkySourceIsStale(sourceEpochMs, now)
|
||||||
|
&& await serveAdsbLolPointFallback(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
requestedMode,
|
||||||
|
'opensky_snapshot_stale_regional_fallback',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Keep the last global snapshot available as a fail-soft cache,
|
||||||
|
// but do not label or render it as a fresh live result.
|
||||||
|
_openskyCacheBody = body;
|
||||||
|
_openskyCacheStatus = upstream.status;
|
||||||
|
_openskyCacheTime = now;
|
||||||
|
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||||
|
_openskyCacheMeta = { requestedMode, usedMode, reason };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (upstream.status === 429) {
|
||||||
|
reason = 'rate_limited';
|
||||||
|
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
|
||||||
|
// 2 min when the header is absent) — no upstream attempts until then.
|
||||||
|
const retryAfterSec = Number(upstream.headers.get('x-rate-limit-retry-after-seconds'));
|
||||||
|
const cooldownMs = Math.min(
|
||||||
|
Math.max(Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, 30_000),
|
||||||
|
30 * 60_000
|
||||||
|
);
|
||||||
|
_openskyCooldownUntil = now + cooldownMs;
|
||||||
|
// Serve the last-good body instead of the 429 when we have one —
|
||||||
|
// the layer keeps rendering (STALE-cued) instead of dying.
|
||||||
|
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
||||||
|
res.writeHead(200, buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'STALE',
|
||||||
|
requestedMode,
|
||||||
|
usedMode,
|
||||||
|
reason: 'rate_limited_serving_stale',
|
||||||
|
staleSeconds: (now - _openskyCacheTime) / 1000,
|
||||||
|
retryAfterSeconds: cooldownMs / 1000,
|
||||||
|
}));
|
||||||
|
res.end(_openskyCacheBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.ok && !_openskyCacheBody) {
|
||||||
|
const servedFallback = await serveAdsbLolPointFallback(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
requestedMode,
|
||||||
|
`opensky_http_${upstream.status}_regional_fallback`,
|
||||||
|
);
|
||||||
|
if (servedFallback) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upstream.status === 401 || upstream.status === 403) {
|
||||||
|
if (requestedMode === 'basic' && !hasBasicCreds) {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
|
||||||
|
});
|
||||||
|
reason = 'missing_basic_creds';
|
||||||
|
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
|
||||||
|
});
|
||||||
|
reason = 'oauth_invalid_or_missing';
|
||||||
|
} else if (usedMode === 'basic') {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth invalid. Username/password were rejected.',
|
||||||
|
});
|
||||||
|
reason = 'basic_invalid_credentials';
|
||||||
|
} else if (usedMode === 'oauth') {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth invalid. OAuth client credentials were rejected.',
|
||||||
|
});
|
||||||
|
reason = 'oauth_invalid_credentials';
|
||||||
|
} else if (requestedMode === 'auto' && !hasBasicCreds) {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
|
||||||
|
});
|
||||||
|
reason = 'missing_oauth_and_basic_creds';
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth required.',
|
||||||
|
});
|
||||||
|
reason = 'auth_required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refine the reason string to reflect the actual outcome
|
||||||
|
if (upstream.ok && reason === 'forced_anonymous') {
|
||||||
|
reason = 'anonymous_ok';
|
||||||
|
} else if (upstream.ok && usedMode === 'basic' && reason === 'basic_credentials') {
|
||||||
|
reason = 'basic_ok';
|
||||||
|
} else if (upstream.ok && usedMode === 'oauth' && reason === 'oauth_token') {
|
||||||
|
reason = 'oauth_ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only cache successful responses — error responses (401/403/429/5xx)
|
||||||
|
// should not be served from cache on subsequent requests
|
||||||
|
if (upstream.ok) {
|
||||||
|
_openskyCacheBody = body;
|
||||||
|
_openskyCacheStatus = upstream.status;
|
||||||
|
_openskyCacheTime = now;
|
||||||
|
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||||
|
_openskyCacheMeta = {
|
||||||
|
requestedMode,
|
||||||
|
usedMode,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
// Credit governor: adapt the cache TTL to the remaining daily
|
||||||
|
// budget so a continuously-open app stretches its polls instead of
|
||||||
|
// exhausting the quota mid-day. Success also clears any cooldown.
|
||||||
|
const remaining = Number(upstream.headers.get('x-rate-limit-remaining'));
|
||||||
|
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
|
||||||
|
_openskyCooldownUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(
|
||||||
|
upstream.status,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'MISS',
|
||||||
|
requestedMode,
|
||||||
|
usedMode,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.end(body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[OpenSky Proxy]', e.message);
|
||||||
|
if (_openskyCacheBody) {
|
||||||
|
const cachedMeta = _openskyCacheMeta || {
|
||||||
|
requestedMode: normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE),
|
||||||
|
usedMode: 'unknown',
|
||||||
|
reason: 'cached_stale',
|
||||||
|
};
|
||||||
|
res.writeHead(
|
||||||
|
_openskyCacheStatus || 200,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'STALE',
|
||||||
|
requestedMode: cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
|
||||||
|
usedMode: cachedMeta.usedMode || 'unknown',
|
||||||
|
reason: cachedMeta.reason || 'cached_stale',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.end(_openskyCacheBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestedMode = normalizeOpenSkyAuthMode(process.env.OPENSKY_AUTH_MODE);
|
||||||
|
if (await serveAdsbLolPointFallback(req, res, requestedMode, 'opensky_proxy_error_regional_fallback')) return;
|
||||||
|
res.writeHead(
|
||||||
|
502,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'MISS',
|
||||||
|
requestedMode,
|
||||||
|
usedMode: 'error',
|
||||||
|
reason: 'proxy_error',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.end(JSON.stringify({ error: 'OpenSky proxy error' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { getOpenSkyToken } from './opensky.js';
|
||||||
|
import { readCappedResponseText } from '../common/http.js';
|
||||||
|
/**
|
||||||
|
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
|
||||||
|
*
|
||||||
|
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
|
||||||
|
* own credit bucket, 4 credits per call on the free tier). OAuth via the
|
||||||
|
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
|
||||||
|
* client can fall back to its accumulated trail silently.
|
||||||
|
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||||
|
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
|
||||||
|
* of real history per aircraft. Treat as best-effort; data is ODbL —
|
||||||
|
* credit "adsb.lol (ODbL)" in the UI.
|
||||||
|
*/
|
||||||
|
export function trackBackfillProxies() {
|
||||||
|
const TRACK_CACHE_MS = 60000;
|
||||||
|
const TRACK_CACHE_MAX = 200;
|
||||||
|
const RESPONSE_CAP_BYTES = 5 * 1024 * 1024;
|
||||||
|
/** @type {Map<string, {at:number,status:number,body:string}>} */
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
function cachePut(key, entry) {
|
||||||
|
cache.set(key, entry);
|
||||||
|
if (cache.size > TRACK_CACHE_MAX) {
|
||||||
|
const oldest = [...cache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
||||||
|
if (oldest) cache.delete(oldest[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyJson(res, key, upstreamUrl, headers = {}) {
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached && Date.now() - cached.at < TRACK_CACHE_MS) {
|
||||||
|
res.statusCode = cached.status;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(cached.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const upstream = await fetch(upstreamUrl, { headers, signal: AbortSignal.timeout(12000) });
|
||||||
|
const { tooLarge, text } = await readCappedResponseText(upstream, RESPONSE_CAP_BYTES);
|
||||||
|
let body;
|
||||||
|
if (tooLarge) {
|
||||||
|
body = JSON.stringify({ error: 'Upstream track response too large' });
|
||||||
|
} else if (!upstream.ok) {
|
||||||
|
// Sanitize upstream error surface; status code is signal enough
|
||||||
|
body = JSON.stringify({ error: `Track source HTTP ${upstream.status}` });
|
||||||
|
} else {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
cachePut(key, { at: Date.now(), status: upstream.status, body });
|
||||||
|
res.statusCode = upstream.status;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function install(middlewares) {
|
||||||
|
middlewares.use('/api/opensky-track', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const incoming = new URL(req.url || '', 'http://localhost');
|
||||||
|
const icao24 = String(incoming.searchParams.get('icao24') || '').trim().toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{6}$/.test(icao24)) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'icao24 must be a 6-char hex string' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await getOpenSkyToken();
|
||||||
|
await proxyJson(
|
||||||
|
res,
|
||||||
|
`osky:${icao24}`,
|
||||||
|
`https://opensky-network.org/api/tracks/all?icao24=${icao24}&time=0`,
|
||||||
|
token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'OpenSky track fetch failed' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
middlewares.use('/api/adsblol/trace', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const incoming = new URL(req.url || '', 'http://localhost');
|
||||||
|
const hex = String(incoming.searchParams.get('hex') || '').trim().toLowerCase();
|
||||||
|
if (!/^[0-9a-f~]{6,7}$/.test(hex)) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'hex must be a 6-7 char hex string' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await proxyJson(
|
||||||
|
res,
|
||||||
|
`lol:${hex}`,
|
||||||
|
`https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'adsb.lol trace fetch failed' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'track-backfill-proxies',
|
||||||
|
configureServer(server) {
|
||||||
|
install(server.middlewares);
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
install(server.middlewares);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Read a fetch() Response body as text with a hard byte cap. Rejects early on an
|
||||||
|
* oversized Content-Length, then streams with a running cap so a chunked or
|
||||||
|
* length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||||
|
*/
|
||||||
|
export async function readResponseTextCapped(response, maxBytes) {
|
||||||
|
const declared = Number(response.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||||
|
const err = new Error('Upstream response too large');
|
||||||
|
err.code = 'RESPONSE_TOO_LARGE';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const reader = response.body?.getReader?.();
|
||||||
|
if (!reader) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (Buffer.byteLength(text) > maxBytes) {
|
||||||
|
const err = new Error('Upstream response too large');
|
||||||
|
err.code = 'RESPONSE_TOO_LARGE';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let out = '';
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
try { await reader.cancel(); } catch { /* no-op */ }
|
||||||
|
const err = new Error('Upstream response too large');
|
||||||
|
err.code = 'RESPONSE_TOO_LARGE';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
out += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
out += decoder.decode();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a fetch() JSON response only after enforcing a hard byte cap. */
|
||||||
|
export async function readResponseJsonCapped(response, maxBytes) {
|
||||||
|
return JSON.parse(await readResponseTextCapped(response, maxBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the existing promise for a cache key, or create one and remove it
|
||||||
|
* only when that exact promise settles.
|
||||||
|
*/
|
||||||
|
export function coalesceProxyRequest(inFlight, key, create) {
|
||||||
|
const existing = inFlight.get(key);
|
||||||
|
if (existing) return { promise: existing, shared: true };
|
||||||
|
let promise;
|
||||||
|
promise = Promise.resolve()
|
||||||
|
.then(create)
|
||||||
|
.finally(() => {
|
||||||
|
if (inFlight.get(key) === promise) inFlight.delete(key);
|
||||||
|
});
|
||||||
|
inFlight.set(key, promise);
|
||||||
|
return { promise, shared: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe an upstream fetch Response (image or video) to the client HTTP response.
|
||||||
|
*
|
||||||
|
* Forwards Content-Type, Content-Length, Content-Range, Accept-Ranges, and
|
||||||
|
* Cache-Control headers from the upstream. Falls back to buffered arrayBuffer
|
||||||
|
* if the body is not streamable.
|
||||||
|
*
|
||||||
|
* @param {import('http').ServerResponse} res
|
||||||
|
* @param {Response} upstream - fetch() Response object.
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Read a fetch Response body as text while enforcing a hard byte cap during
|
||||||
|
* the read — so a malicious or buggy upstream that streams an unbounded body
|
||||||
|
* (no/oversized Content-Length, chunked) can't OOM the proxy. Returns
|
||||||
|
* { tooLarge, text }. Cancels the stream as soon as the cap is crossed.
|
||||||
|
* @param {Response} upstream - fetch() response.
|
||||||
|
* @param {number} maxBytes - hard ceiling on decoded bytes.
|
||||||
|
* @returns {Promise<{tooLarge: boolean, text: string}>}
|
||||||
|
*/
|
||||||
|
export async function readCappedResponseText(upstream, maxBytes) {
|
||||||
|
const declared = Number(upstream.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||||
|
try { await upstream.body?.cancel(); } catch { /* no-op */ }
|
||||||
|
return { tooLarge: true, text: '' };
|
||||||
|
}
|
||||||
|
if (!upstream.body || typeof upstream.body[Symbol.asyncIterator] !== 'function') {
|
||||||
|
const text = await upstream.text();
|
||||||
|
return text.length > maxBytes ? { tooLarge: true, text: '' } : { tooLarge: false, text };
|
||||||
|
}
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let text = '';
|
||||||
|
let total = 0;
|
||||||
|
for await (const chunk of upstream.body) {
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
try { await upstream.body.cancel(); } catch { /* no-op */ }
|
||||||
|
return { tooLarge: true, text: '' };
|
||||||
|
}
|
||||||
|
text += decoder.decode(chunk, { stream: true });
|
||||||
|
}
|
||||||
|
text += decoder.decode();
|
||||||
|
return { tooLarge: false, text };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function requiredFiniteQueryNumber(params, key) {
|
||||||
|
const value = params.get(key);
|
||||||
|
if (value === null || value.trim() === '') return null;
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampInt(value, min, max, fallback) {
|
||||||
|
const number = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(number)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, number));
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { readResponseTextCapped, readResponseJsonCapped, coalesceProxyRequest, readCappedResponseText } from './common/http.js';
|
||||||
|
export { requiredFiniteQueryNumber, clampInt } from './common/query.js';
|
||||||
|
export { adsbLolFallbackAnchor, openSkyProxy } from './aircraft/opensky.js';
|
||||||
|
export { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||||
|
export { adsbdbProxy } from './aircraft/enrichment.js';
|
||||||
|
export { trackBackfillProxies } from './aircraft/tracks.js';
|
||||||
|
export { aisLiveProxy } from './vessels/ais-live.js';
|
||||||
+10
-1604
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,355 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { createAisStreamAdapter } from '../../../src/data/aisStreamAdapter.js';
|
||||||
|
import { parseSilenceTimeoutEnv } from '../../../src/data/aisWatchdog.js';
|
||||||
|
import { clampInt } from '../common/query.js';
|
||||||
|
import { AISSTREAM_CACHE_MAX, AISSTREAM_STALE_MS, ingestAisStreamEnvelope, readAisTrack, aisStreamRows, newestAisPositionAt } from './ais-store.js';
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AISStream live vessel cache state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const AISSTREAM_URL = 'wss://stream.aisstream.io/v0/stream';
|
||||||
|
const AISSTREAM_DEFAULT_BBOXES = [[[-90, -180], [90, 180]]];
|
||||||
|
const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
|
||||||
|
'PositionReport',
|
||||||
|
'StandardClassBPositionReport',
|
||||||
|
'ExtendedClassBPositionReport',
|
||||||
|
'ShipStaticData',
|
||||||
|
'StaticDataReport',
|
||||||
|
];
|
||||||
|
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
||||||
|
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
||||||
|
// ~2 min, but recycling the socket is throttled so recovery can never become a
|
||||||
|
// reconnect cycle against AISStream's one-connection-per-key limit.
|
||||||
|
const AISSTREAM_SILENCE_REPORT_MS = 120_000;
|
||||||
|
/** Recycle threshold as a multiple of the report threshold. */
|
||||||
|
const AISSTREAM_RECYCLE_RATIO = 2.5;
|
||||||
|
const AISSTREAM_BACKOFF_MS = Object.freeze([5_000, 15_000, 60_000, 300_000]);
|
||||||
|
/** Slow retry cadence once the ladder is spent and the feed reads DOWN. */
|
||||||
|
const AISSTREAM_DOWN_RETRY_MS = 900_000;
|
||||||
|
/**
|
||||||
|
* Probe cadence while AISStream is rejecting the key. Retrying cannot fix a
|
||||||
|
* bad credential, so this exists only to recover from an upstream-side
|
||||||
|
* mistake — it must never approach the ladder's pace.
|
||||||
|
*/
|
||||||
|
const AISSTREAM_AUTH_PROBE_MS = 3_600_000;
|
||||||
|
/** How often the watchdog re-evaluates without request traffic. */
|
||||||
|
const AISSTREAM_TICK_MS = 15_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {ReturnType<typeof createAisStreamAdapter>|null}
|
||||||
|
* Module-lifetime: it owns the socket-generation namespace, which must never
|
||||||
|
* restart across a dev-server reload (see aisStreamAdapter.js ownership rules).
|
||||||
|
*/
|
||||||
|
let _aisAdapter = null;
|
||||||
|
/** @type {{silenceWatch:boolean,reportMs:number,recycleMs:number,url:string}|null} */
|
||||||
|
let _aisWatchdogPolicy = null;
|
||||||
|
/** @type {number|null} */
|
||||||
|
let _aisStreamTickTimer = null;
|
||||||
|
/** Set by dispose so the next ensure() re-derives budgets from a reloaded .env. */
|
||||||
|
let _aisNeedsRearm = false;
|
||||||
|
/** @type {Function|null|undefined} `ws` constructor; null = unavailable, undefined = not yet probed. */
|
||||||
|
let _aisWebSocketImpl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin: AISStream live vessel cache.
|
||||||
|
*
|
||||||
|
* AISStream does not support browser CORS and requires a private API key, so
|
||||||
|
* the Vite server keeps one backend websocket open and exposes a same-origin
|
||||||
|
* JSON snapshot to the Cesium layer.
|
||||||
|
*/
|
||||||
|
export function aisLiveProxy() {
|
||||||
|
function install(middlewares) {
|
||||||
|
middlewares.use('/api/ais-live', async (req, res) => {
|
||||||
|
try {
|
||||||
|
ensureAisStreamConnection();
|
||||||
|
const incoming = new URL(req.url || '', 'http://localhost');
|
||||||
|
|
||||||
|
// Track sub-route MUST be handled before the rows snapshot — this
|
||||||
|
// mount prefix-matches every subpath, so without this branch
|
||||||
|
// /api/ais-live/track would be silently answered with vessel rows.
|
||||||
|
if (incoming.pathname === '/track' || incoming.pathname.startsWith('/track/')) {
|
||||||
|
const mmsi = String(incoming.searchParams.get('mmsi') || '').trim();
|
||||||
|
res.statusCode = /^\d{5,10}$/.test(mmsi) ? 200 : 400;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.end(JSON.stringify({ error: 'mmsi query param required', samples: [] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
mmsi,
|
||||||
|
samples: readAisTrack(mmsi),
|
||||||
|
source: 'AISStream (accumulated since server start)',
|
||||||
|
retainedSec: Math.floor(AISSTREAM_STALE_MS / 1000),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRows = clampInt(incoming.searchParams.get('maxRows'), 1, AISSTREAM_CACHE_MAX, AISSTREAM_CACHE_MAX);
|
||||||
|
const rows = aisStreamRows(maxRows);
|
||||||
|
|
||||||
|
const feed = aisStreamStatusSnapshot();
|
||||||
|
|
||||||
|
res.statusCode = process.env.AISSTREAM_API_KEY ? 200 : 503;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
rows,
|
||||||
|
source: 'AISStream',
|
||||||
|
status: feed.status,
|
||||||
|
error: feed.error,
|
||||||
|
refreshing: feed.status !== 'live',
|
||||||
|
newestPositionAt: newestAisPositionAt(rows),
|
||||||
|
lastMessageAt: feed.lastMessageAt,
|
||||||
|
// Honest-failure metadata: how long the feed has been quiet, which
|
||||||
|
// recovery attempt we are on, and when the next one lands.
|
||||||
|
silentForMs: feed.silentForMs,
|
||||||
|
reconnectAttempt: feed.reconnectAttempt,
|
||||||
|
nextAttemptAt: feed.nextAttemptAt,
|
||||||
|
staleAfterMs: feed.staleAfterMs,
|
||||||
|
watchdog: feed.watchdog,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(JSON.stringify({ error: error?.message || 'AIS live stream error', rows: [] }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'ais-live-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
install(server.middlewares);
|
||||||
|
startAisStreamWatchdogTick();
|
||||||
|
// Vite restarts the server in-process on a config change while this
|
||||||
|
// module's state survives; without teardown each reload stacks another
|
||||||
|
// interval and another socket.
|
||||||
|
server.httpServer?.on('close', disposeAisStream);
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
install(server.middlewares);
|
||||||
|
startAisStreamWatchdogTick();
|
||||||
|
server.httpServer?.on('close', disposeAisStream);
|
||||||
|
},
|
||||||
|
// Middleware-mode backstop: there is no httpServer to hang 'close' on.
|
||||||
|
closeBundle() {
|
||||||
|
disposeAisStream();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the `ws` constructor once.
|
||||||
|
*
|
||||||
|
* Node's built-in WebSocket cannot be used here: it has no terminate(), and
|
||||||
|
* its close() waits forever for a close frame a black-holed peer never sends
|
||||||
|
* (verified in src/data/aisWatchdogTransport.test.mjs). A socket parked in
|
||||||
|
* CLOSING keeps holding AISStream's single per-key connection, which is how
|
||||||
|
* the reverted watchdog wedged.
|
||||||
|
*
|
||||||
|
* Loaded lazily rather than imported at the top of this file so a missing
|
||||||
|
* optional dependency degrades the vessel feed honestly instead of breaking
|
||||||
|
* the whole dev server and build.
|
||||||
|
*
|
||||||
|
* @returns {Function|null}
|
||||||
|
*/
|
||||||
|
function aisWebSocketImpl() {
|
||||||
|
if (_aisWebSocketImpl !== undefined) return _aisWebSocketImpl;
|
||||||
|
try {
|
||||||
|
_aisWebSocketImpl = createRequire(import.meta.url)('ws');
|
||||||
|
} catch (error) {
|
||||||
|
_aisWebSocketImpl = null;
|
||||||
|
console.warn('[AISStream] `ws` is unavailable; the live vessel feed is off.', error?.message || '');
|
||||||
|
}
|
||||||
|
return _aisWebSocketImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the watchdog policy from the environment, once.
|
||||||
|
*
|
||||||
|
* Read lazily because module evaluation happens before Vite's loadEnv() copies
|
||||||
|
* .env into process.env — the reverted watchdog read these at import time and
|
||||||
|
* silently ignored every .env value, including its own kill switch.
|
||||||
|
*
|
||||||
|
* A custom subscription (one harbor, one message type) can be legitimately
|
||||||
|
* silent for minutes, so the silence watch only self-arms for the default
|
||||||
|
* worldwide subscription. An operator with a narrow filter opts back in by
|
||||||
|
* setting AISSTREAM_SILENCE_TIMEOUT_MS to a value sized for that filter; 0 is
|
||||||
|
* an explicit kill switch.
|
||||||
|
*/
|
||||||
|
function aisWatchdogPolicy() {
|
||||||
|
if (_aisWatchdogPolicy) return _aisWatchdogPolicy;
|
||||||
|
const customSubscription = Boolean(
|
||||||
|
process.env.AISSTREAM_BOUNDING_BOXES || process.env.AISSTREAM_MESSAGE_TYPES,
|
||||||
|
);
|
||||||
|
const override = parseSilenceTimeoutEnv(
|
||||||
|
process.env.AISSTREAM_SILENCE_TIMEOUT_MS,
|
||||||
|
(message) => console.warn(message),
|
||||||
|
);
|
||||||
|
const reportMs = override.kind === 'timeout' ? override.value : AISSTREAM_SILENCE_REPORT_MS;
|
||||||
|
_aisWatchdogPolicy = {
|
||||||
|
silenceWatch: override.kind === 'off' ? false : (override.kind === 'timeout' || !customSubscription),
|
||||||
|
reportMs,
|
||||||
|
recycleMs: Math.round(reportMs * AISSTREAM_RECYCLE_RATIO),
|
||||||
|
// Overridable so the watchdog can be exercised end-to-end against a local
|
||||||
|
// stand-in upstream without opening a connection to AISStream (which
|
||||||
|
// allows only one per key).
|
||||||
|
url: process.env.AISSTREAM_URL || AISSTREAM_URL,
|
||||||
|
};
|
||||||
|
return _aisWatchdogPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transport adapter, built on first use and kept for the module lifetime.
|
||||||
|
*
|
||||||
|
* Never rebuilt: it owns the socket-generation namespace, and a restarted
|
||||||
|
* namespace would let a pre-disposal handler act on its successor's socket.
|
||||||
|
*/
|
||||||
|
function aisAdapter() {
|
||||||
|
if (_aisAdapter) return _aisAdapter;
|
||||||
|
_aisAdapter = createAisStreamAdapter({
|
||||||
|
createSocket: (url) => {
|
||||||
|
const WebSocketCtor = aisWebSocketImpl();
|
||||||
|
if (!WebSocketCtor) throw new Error('ws transport unavailable');
|
||||||
|
return new WebSocketCtor(url);
|
||||||
|
},
|
||||||
|
resolveUrl: () => aisWatchdogPolicy().url,
|
||||||
|
buildSubscription: aisStreamSubscription,
|
||||||
|
ingestEnvelope: ingestAisStreamEnvelope,
|
||||||
|
warn: (message) => console.warn(message),
|
||||||
|
});
|
||||||
|
_aisAdapter.setWatchdogOptions(aisWatchdogBudgets());
|
||||||
|
return _aisAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Watchdog budgets derived from the resolved environment policy. */
|
||||||
|
function aisWatchdogBudgets() {
|
||||||
|
const policy = aisWatchdogPolicy();
|
||||||
|
return {
|
||||||
|
staleMs: policy.reportMs,
|
||||||
|
recycleAfterMs: policy.recycleMs,
|
||||||
|
backoffMs: [...AISSTREAM_BACKOFF_MS],
|
||||||
|
downRetryMs: AISSTREAM_DOWN_RETRY_MS,
|
||||||
|
authProbeMs: AISSTREAM_AUTH_PROBE_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint the credential so a key change can clear the terminal
|
||||||
|
* auth-failed state. Only a truncated digest is kept — never the key.
|
||||||
|
*/
|
||||||
|
function aisKeyFingerprint() {
|
||||||
|
const key = process.env.AISSTREAM_API_KEY;
|
||||||
|
if (!key) return null;
|
||||||
|
return createHash('sha256').update(String(key)).digest('hex').slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the watchdog once. Called on every /api/ais-live request and on the
|
||||||
|
* background interval, so recovery does not depend on browser traffic.
|
||||||
|
*/
|
||||||
|
function ensureAisStreamConnection() {
|
||||||
|
const adapter = aisAdapter();
|
||||||
|
if (_aisNeedsRearm) {
|
||||||
|
// Post-dispose re-arm, now that the restarted server's .env is loaded. The
|
||||||
|
// adapter keeps its generation namespace across this.
|
||||||
|
_aisNeedsRearm = false;
|
||||||
|
adapter.setWatchdogOptions(aisWatchdogBudgets());
|
||||||
|
}
|
||||||
|
const policy = aisWatchdogPolicy();
|
||||||
|
adapter.ensure({
|
||||||
|
hasKey: Boolean(process.env.AISSTREAM_API_KEY),
|
||||||
|
hasTransport: Boolean(aisWebSocketImpl()),
|
||||||
|
silenceWatch: policy.silenceWatch,
|
||||||
|
keyFingerprint: aisKeyFingerprint(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** Status metadata for /api/ais-live, safe to call before the first connect. */
|
||||||
|
function aisStreamStatusSnapshot() {
|
||||||
|
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
|
||||||
|
if (snapshot) return snapshot;
|
||||||
|
return {
|
||||||
|
status: process.env.AISSTREAM_API_KEY ? 'idle' : 'missing-key',
|
||||||
|
error: process.env.AISSTREAM_API_KEY ? null : 'AISSTREAM_API_KEY is not set',
|
||||||
|
lastMessageAt: null,
|
||||||
|
silentForMs: null,
|
||||||
|
reconnectAttempt: 0,
|
||||||
|
nextAttemptAt: null,
|
||||||
|
watchdog: 'armed',
|
||||||
|
staleAfterMs: AISSTREAM_SILENCE_REPORT_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the background watchdog tick. Unref'd so it never holds the dev server
|
||||||
|
* open, and idempotent so a Vite in-process restart cannot stack intervals.
|
||||||
|
*/
|
||||||
|
function startAisStreamWatchdogTick() {
|
||||||
|
if (_aisStreamTickTimer) return;
|
||||||
|
_aisStreamTickTimer = setInterval(() => {
|
||||||
|
try {
|
||||||
|
ensureAisStreamConnection();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AISStream] watchdog tick failed', error?.message || '');
|
||||||
|
}
|
||||||
|
}, AISSTREAM_TICK_MS);
|
||||||
|
_aisStreamTickTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down every timer and socket this module owns.
|
||||||
|
*
|
||||||
|
* Vite restarts the dev server in-process on a config change while module
|
||||||
|
* state survives, so without this each reload stacked another interval and
|
||||||
|
* another reconnect chain. The cached policy is dropped too, so a restart
|
||||||
|
* re-reads .env.
|
||||||
|
*
|
||||||
|
* The adapter instance itself is deliberately KEPT: it owns the socket
|
||||||
|
* generation namespace, which must stay monotonic across restarts so a
|
||||||
|
* pre-disposal handler can never collide with a post-disposal socket.
|
||||||
|
*/
|
||||||
|
function disposeAisStream() {
|
||||||
|
if (_aisStreamTickTimer) {
|
||||||
|
clearInterval(_aisStreamTickTimer);
|
||||||
|
_aisStreamTickTimer = null;
|
||||||
|
}
|
||||||
|
if (_aisAdapter) _aisAdapter.dispose();
|
||||||
|
// Drop the cached policy and re-arm LAZILY. Re-deriving budgets here would
|
||||||
|
// read process.env before the restarted server's loadEnv() has repopulated
|
||||||
|
// it, caching the outgoing configuration; the next ensure() runs after that.
|
||||||
|
_aisWatchdogPolicy = null;
|
||||||
|
_aisNeedsRearm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisStreamSubscription() {
|
||||||
|
return {
|
||||||
|
APIKey: process.env.AISSTREAM_API_KEY,
|
||||||
|
BoundingBoxes: parseJsonEnv('AISSTREAM_BOUNDING_BOXES', AISSTREAM_DEFAULT_BBOXES),
|
||||||
|
FilterMessageTypes: parseCsvOrJsonEnv('AISSTREAM_MESSAGE_TYPES', AISSTREAM_DEFAULT_MESSAGE_TYPES),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonEnv(key, fallback) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
console.warn(`[AISStream] Invalid ${key}; using default.`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvOrJsonEnv(key, fallback) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed : fallback;
|
||||||
|
} catch {
|
||||||
|
return value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
||||||
|
export const AISSTREAM_CACHE_MAX = 50000;
|
||||||
|
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
||||||
|
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
||||||
|
// precision, fine for 25m thinning) + Uint32 epoch seconds ≈ 12B/sample;
|
||||||
|
// 64 samples × 50k MMSIs worst case ≈ 38MB. Tracks exist only while the dev
|
||||||
|
// server runs — this is "recent path", not voyage history.
|
||||||
|
const AIS_TRACK_SAMPLES = 64;
|
||||||
|
const AIS_TRACK_MIN_GAP_SEC = 30;
|
||||||
|
const AIS_TRACK_MIN_MOVE_M = 25;
|
||||||
|
/** @type {Map<string,object>} */
|
||||||
|
const _aisStreamVessels = new Map();
|
||||||
|
/** @type {Map<string,object>} */
|
||||||
|
const _aisStreamStatic = new Map();
|
||||||
|
/** @type {Map<string,{lats:Float32Array,lons:Float32Array,times:Uint32Array,head:number,len:number}>} mmsi -> track ring buffer */
|
||||||
|
const _aisStreamTracks = new Map();
|
||||||
|
/** @type {Map<string,{lat:number,lon:number,epochSec:number}>} mmsi -> first fix awaiting second (lazy buffer allocation) */
|
||||||
|
const _aisStreamTrackPending = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store one parsed AIS envelope.
|
||||||
|
*
|
||||||
|
* The return value is the feed's ONLY liveness proof, so it is true strictly
|
||||||
|
* when the envelope carried a real AIS record. Malformed frames and error
|
||||||
|
* envelopes never reach here — the adapter classifies those — and a JSON
|
||||||
|
* object without an MMSI proves nothing about the feed.
|
||||||
|
*
|
||||||
|
* @param {Object} envelope Parsed, non-error AIS envelope.
|
||||||
|
* @returns {boolean} True when an AIS record was recognised.
|
||||||
|
*/
|
||||||
|
export function ingestAisStreamEnvelope(envelope) {
|
||||||
|
// Single shared recognition rule (also used by the adapter's tests), so the
|
||||||
|
// liveness predicate that ships is the one under test. An envelope carrying
|
||||||
|
// only an MMSI is not proof the feed works.
|
||||||
|
if (!isRecognizedAisEnvelope(envelope)) return false;
|
||||||
|
|
||||||
|
const messageType = envelope?.MessageType;
|
||||||
|
const message = envelope?.Message?.[messageType] || {};
|
||||||
|
const metadata = envelope?.MetaData || envelope?.Metadata || {};
|
||||||
|
const mmsi = stringValue(metadata.MMSI ?? message.UserID ?? message.UserId ?? message.Mmsi);
|
||||||
|
if (!mmsi) return false;
|
||||||
|
|
||||||
|
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
|
||||||
|
const staticData = {
|
||||||
|
name: vesselNameFromAis(metadata, message, _aisStreamStatic.get(mmsi)),
|
||||||
|
type: vesselTypeFromAis(message, _aisStreamStatic.get(mmsi)),
|
||||||
|
destination: stringValue(message.Destination),
|
||||||
|
imo: stringValue(message.ImoNumber ?? message.IMO),
|
||||||
|
};
|
||||||
|
_aisStreamStatic.set(mmsi, staticData);
|
||||||
|
mergeAisStaticIntoLiveVessel(mmsi, staticData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = numberValue(metadata.latitude ?? metadata.Latitude ?? message.Latitude);
|
||||||
|
const lon = numberValue(metadata.longitude ?? metadata.Longitude ?? message.Longitude);
|
||||||
|
// A positionless but well-formed record (static data) is still the feed
|
||||||
|
// delivering AIS traffic, so it counts as liveness.
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||||
|
|
||||||
|
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||||
|
_aisStreamVessels.set(mmsi, {
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
|
||||||
|
mmsi,
|
||||||
|
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||||
|
type: vesselTypeFromAis(message, staticData),
|
||||||
|
destination: stringValue(message.Destination ?? staticData.destination),
|
||||||
|
speed: numberValue(message.Sog ?? message.SOG),
|
||||||
|
course: numberValue(message.Cog ?? message.COG),
|
||||||
|
heading: normalizedHeading(message.TrueHeading ?? message.Heading),
|
||||||
|
last_position_UTC: normalizeAisTimestamp(metadata.time_utc ?? metadata.TimeUtc),
|
||||||
|
// Use the AIS message's own report time, not server ingest wall-clock —
|
||||||
|
// trail spacing and dead reckoning depend on true fix epochs.
|
||||||
|
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
||||||
|
_updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
appendAisTrackSample(mmsi, lat, lon, aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc));
|
||||||
|
|
||||||
|
pruneAisStreamCache();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an AISStream UTC timestamp into epoch seconds (fallback: now).
|
||||||
|
*/
|
||||||
|
function aisEpochSeconds(value) {
|
||||||
|
const ms = Date.parse(normalizeAisTimestamp(value));
|
||||||
|
return Number.isFinite(ms) ? Math.floor(ms / 1000) : Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a thinned position sample to a vessel's track ring buffer.
|
||||||
|
* Buffers allocate lazily on the second fix (most MMSIs are seen once);
|
||||||
|
* samples are kept only when >=AIS_TRACK_MIN_GAP_SEC and
|
||||||
|
* >=AIS_TRACK_MIN_MOVE_M from the previous stored sample, so anchored
|
||||||
|
* vessels collapse to a single point.
|
||||||
|
*/
|
||||||
|
function appendAisTrackSample(mmsi, lat, lon, epochSec) {
|
||||||
|
let track = _aisStreamTracks.get(mmsi);
|
||||||
|
if (!track) {
|
||||||
|
const pending = _aisStreamTrackPending.get(mmsi);
|
||||||
|
if (!pending) {
|
||||||
|
_aisStreamTrackPending.set(mmsi, { lat, lon, epochSec });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (epochSec - pending.epochSec < AIS_TRACK_MIN_GAP_SEC) return;
|
||||||
|
if (approxMetersBetween(pending.lat, pending.lon, lat, lon) < AIS_TRACK_MIN_MOVE_M) return;
|
||||||
|
track = {
|
||||||
|
lats: new Float32Array(AIS_TRACK_SAMPLES),
|
||||||
|
lons: new Float32Array(AIS_TRACK_SAMPLES),
|
||||||
|
times: new Uint32Array(AIS_TRACK_SAMPLES),
|
||||||
|
head: 0,
|
||||||
|
len: 0,
|
||||||
|
};
|
||||||
|
_aisStreamTracks.set(mmsi, track);
|
||||||
|
_aisStreamTrackPending.delete(mmsi);
|
||||||
|
writeAisTrackSample(track, pending.lat, pending.lon, pending.epochSec);
|
||||||
|
writeAisTrackSample(track, lat, lon, epochSec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIdx = (track.head - 1 + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
|
||||||
|
const lastEpoch = track.times[lastIdx];
|
||||||
|
if (epochSec - lastEpoch < AIS_TRACK_MIN_GAP_SEC) return;
|
||||||
|
if (approxMetersBetween(track.lats[lastIdx], track.lons[lastIdx], lat, lon) < AIS_TRACK_MIN_MOVE_M) return;
|
||||||
|
writeAisTrackSample(track, lat, lon, epochSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAisTrackSample(track, lat, lon, epochSec) {
|
||||||
|
track.lats[track.head] = lat;
|
||||||
|
track.lons[track.head] = lon;
|
||||||
|
track.times[track.head] = epochSec;
|
||||||
|
track.head = (track.head + 1) % AIS_TRACK_SAMPLES;
|
||||||
|
track.len = Math.min(track.len + 1, AIS_TRACK_SAMPLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a vessel's accumulated track in chronological order.
|
||||||
|
* @returns {Array<{lat:number,lon:number,t:number}>}
|
||||||
|
*/
|
||||||
|
export function readAisTrack(mmsi) {
|
||||||
|
const track = _aisStreamTracks.get(mmsi);
|
||||||
|
if (!track || !track.len) return [];
|
||||||
|
const samples = [];
|
||||||
|
const start = (track.head - track.len + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
|
||||||
|
for (let i = 0; i < track.len; i++) {
|
||||||
|
const idx = (start + i) % AIS_TRACK_SAMPLES;
|
||||||
|
samples.push({ lat: track.lats[idx], lon: track.lons[idx], t: track.times[idx] });
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equirectangular distance approximation — plenty for 25m thinning. */
|
||||||
|
function approxMetersBetween(lat1, lon1, lat2, lon2) {
|
||||||
|
const dLat = (lat2 - lat1) * 111320;
|
||||||
|
const dLon = (lon2 - lon1) * 111320 * Math.cos(((lat1 + lat2) / 2) * (Math.PI / 180));
|
||||||
|
return Math.hypot(dLat, dLon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
|
||||||
|
const existing = _aisStreamVessels.get(mmsi);
|
||||||
|
if (!existing) return;
|
||||||
|
if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`)) existing.name = staticData.name;
|
||||||
|
if (staticData.type && !existing.type) existing.type = staticData.type;
|
||||||
|
if (staticData.destination && !existing.destination) existing.destination = staticData.destination;
|
||||||
|
if (staticData.imo && !existing.imo) existing.imo = staticData.imo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vesselNameFromAis(metadata, message, staticData = {}) {
|
||||||
|
return stringValue(
|
||||||
|
metadata.ShipName
|
||||||
|
?? message.Name
|
||||||
|
?? message.ShipName
|
||||||
|
?? message.ReportA?.Name
|
||||||
|
?? staticData.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vesselTypeFromAis(message, staticData = {}) {
|
||||||
|
return stringValue(
|
||||||
|
message.Type
|
||||||
|
?? message.ShipType
|
||||||
|
?? message.ReportB?.ShipType
|
||||||
|
?? staticData.type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aisStreamRows(maxRows) {
|
||||||
|
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||||
|
const rows = [];
|
||||||
|
for (const row of _aisStreamVessels.values()) {
|
||||||
|
if (row._updatedAt >= cutoff) rows.push(row);
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => b._updatedAt - a._updatedAt);
|
||||||
|
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneAisStreamCache() {
|
||||||
|
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||||
|
for (const [mmsi, row] of _aisStreamVessels) {
|
||||||
|
if (row._updatedAt < cutoff) {
|
||||||
|
_aisStreamVessels.delete(mmsi);
|
||||||
|
_aisStreamTracks.delete(mmsi);
|
||||||
|
_aisStreamTrackPending.delete(mmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pending single-fix entries for vessels never seen again must not leak
|
||||||
|
const pendingCutoffSec = Math.floor(cutoff / 1000);
|
||||||
|
for (const [mmsi, pending] of _aisStreamTrackPending) {
|
||||||
|
if (pending.epochSec < pendingCutoffSec) _aisStreamTrackPending.delete(mmsi);
|
||||||
|
}
|
||||||
|
if (_aisStreamVessels.size <= AISSTREAM_CACHE_MAX) return;
|
||||||
|
const ordered = [..._aisStreamVessels.entries()].sort((a, b) => a[1]._updatedAt - b[1]._updatedAt);
|
||||||
|
for (const [mmsi] of ordered.slice(0, _aisStreamVessels.size - AISSTREAM_CACHE_MAX)) {
|
||||||
|
_aisStreamVessels.delete(mmsi);
|
||||||
|
_aisStreamTracks.delete(mmsi);
|
||||||
|
_aisStreamTrackPending.delete(mmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newestAisPositionAt(rows) {
|
||||||
|
return rows[0]?.last_position_UTC || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value) {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedHeading(value) {
|
||||||
|
const heading = numberValue(value);
|
||||||
|
return heading !== null && heading >= 0 && heading <= 360 ? heading : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAisTimestamp(value) {
|
||||||
|
const text = stringValue(value);
|
||||||
|
if (!text) return new Date().toISOString();
|
||||||
|
const normalized = text.replace(' +0000 UTC', 'Z').replace(' UTC', 'Z');
|
||||||
|
const date = new Date(normalized);
|
||||||
|
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const source = readFileSync(new URL('../server/providers/local.js', import.meta.url), 'utf8');
|
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js']
|
||||||
|
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
|
||||||
|
.join('\n');
|
||||||
const detail = 'fixture-secret-token /internal/example <html>';
|
const detail = 'fixture-secret-token /internal/example <html>';
|
||||||
|
|
||||||
// Execute the production middleware with isolated upstreams and cache storage.
|
// Execute the production middleware with isolated upstreams and cache storage.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
import * as providers from '../../server/providers/live.js';
|
||||||
|
import * as portable from '../../src/data/adsbLolFallback.js';
|
||||||
|
|
||||||
|
function install(plugin, preview = false) {
|
||||||
|
const routes = new Map();
|
||||||
|
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
|
||||||
|
middlewares: { use(route, handler) { routes.set(route, handler); } },
|
||||||
|
});
|
||||||
|
return async (route, url = '/', method = 'GET') => {
|
||||||
|
assert.ok(routes.has(route), `registered route: ${route}`);
|
||||||
|
const response = {
|
||||||
|
statusCode: 200, headers: {},
|
||||||
|
setHeader(key, value) { this.headers[key.toLowerCase()] = value; },
|
||||||
|
writeHead(status, headers = {}) {
|
||||||
|
this.statusCode = status;
|
||||||
|
for (const [key, value] of Object.entries(headers)) this.setHeader(key, value);
|
||||||
|
},
|
||||||
|
end(body) { this.body = body; },
|
||||||
|
};
|
||||||
|
await routes.get(route)({ url, method }, response);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function environment(t, values) {
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
const original = process.env[key];
|
||||||
|
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
||||||
|
t.after(() => { if (original === undefined) delete process.env[key]; else process.env[key] = original; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('live entry resolves in Node and aircraft normalization stays independently portable', async () => {
|
||||||
|
const entry = await import('gods-eye-view/server/providers/live');
|
||||||
|
assert.equal(entry.openSkyProxy, providers.openSkyProxy);
|
||||||
|
assert.equal(entry.aisLiveProxy, providers.aisLiveProxy);
|
||||||
|
const normalizer = await import('gods-eye-view/sources/adsb-lol');
|
||||||
|
assert.equal(normalizer.normalizeAdsbLolAircraftState, portable.normalizeAdsbLolAircraftState);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenSky state and track routes share tokens, retain cache and use regional fallback', async t => {
|
||||||
|
environment(t, {
|
||||||
|
OPENSKY_CLIENT_ID: 'fixture-client', OPENSKY_CLIENT_SECRET: 'fixture-secret',
|
||||||
|
OPENSKY_AUTH_MODE: 'oauth', OPENSKY_USERNAME: undefined, OPENSKY_PASSWORD: undefined,
|
||||||
|
});
|
||||||
|
let now = Date.now();
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
const calls = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.includes('/token')) return Response.json({ access_token: 'fixture-token', expires_in: 1800 });
|
||||||
|
if (url.includes('/states/')) {
|
||||||
|
assert.equal(options.headers.Authorization, 'Bearer fixture-token');
|
||||||
|
return Response.json({ time: Math.floor(now / 1000), states: [] });
|
||||||
|
}
|
||||||
|
if (url.includes('/tracks/')) {
|
||||||
|
assert.equal(options.headers.Authorization, 'Bearer fixture-token');
|
||||||
|
return Response.json({ path: [] });
|
||||||
|
}
|
||||||
|
if (url.includes('/lat/')) return Response.json({ now: now / 1000, ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }] });
|
||||||
|
throw Error(`Unexpected URL: ${url}`);
|
||||||
|
});
|
||||||
|
const states = install(providers.openSkyProxy());
|
||||||
|
assert.equal((await states('/api/opensky', '?lat=30&lon=-97')).statusCode, 200);
|
||||||
|
assert.equal((await states('/api/opensky', '?lat=30&lon=-97')).headers['x-opensky-cache'], 'HIT');
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
const tracks = install(providers.trackBackfillProxies(), true);
|
||||||
|
assert.equal((await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode, 200);
|
||||||
|
await tracks('/api/opensky-track', '?icao24=abc123');
|
||||||
|
assert.equal(calls.filter(call => call.url.includes('/token')).length, 1);
|
||||||
|
assert.equal(calls.filter(call => call.url.includes('/tracks/')).length, 1);
|
||||||
|
assert.equal((await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode, 400);
|
||||||
|
now += 130_000;
|
||||||
|
t.mock.method(globalThis, 'fetch', async url => {
|
||||||
|
if (url.includes('/states/')) return new Response('', { status: 503 });
|
||||||
|
if (url.includes('/lat/')) return Response.json({ now: now / 1000, ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }] });
|
||||||
|
throw Error(`Unexpected URL: ${url}`);
|
||||||
|
});
|
||||||
|
// A fresh request without a usable cached worldwide frame should use the regional feed.
|
||||||
|
const fresh = await import(`../../server/providers/aircraft/opensky.js?fallback=${now}`);
|
||||||
|
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
|
||||||
|
const fallback = await install(fresh.openSkyProxy())('/api/opensky', '?lat=30&lon=-97');
|
||||||
|
assert.equal(fallback.statusCode, 200);
|
||||||
|
assert.equal(fallback.headers['x-flight-source'], 'adsb.lol');
|
||||||
|
assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('military aircraft route preserves fresh cache and stale response after upstream failure', async t => {
|
||||||
|
let now = Date.now();
|
||||||
|
let calls = 0;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
t.mock.method(console, 'error', () => {});
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
if (++calls > 1) throw Error('offline');
|
||||||
|
return Response.json({ ac: [{ hex: 'abc123' }] });
|
||||||
|
});
|
||||||
|
const request = install(providers.adsbLolProxy());
|
||||||
|
const first = await request('/api/adsblol/mil');
|
||||||
|
assert.equal((await request('/api/adsblol/mil')).body, first.body);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
now += 13_000;
|
||||||
|
assert.equal((await request('/api/adsblol/mil')).body, first.body);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AIS preview route ingests through the socket, returns tracks and disposes before restart', async t => {
|
||||||
|
const upstream = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||||
|
await once(upstream, 'listening');
|
||||||
|
const sockets = [];
|
||||||
|
t.after(async () => {
|
||||||
|
for (const socket of sockets) socket.terminate();
|
||||||
|
await new Promise(resolve => upstream.close(resolve));
|
||||||
|
});
|
||||||
|
environment(t, {
|
||||||
|
AISSTREAM_API_KEY: 'fixture-key',
|
||||||
|
AISSTREAM_URL: `ws://127.0.0.1:${upstream.address().port}`,
|
||||||
|
AISSTREAM_BOUNDING_BOXES: undefined, AISSTREAM_MESSAGE_TYPES: undefined,
|
||||||
|
AISSTREAM_SILENCE_TIMEOUT_MS: '0',
|
||||||
|
});
|
||||||
|
upstream.on('connection', socket => {
|
||||||
|
sockets.push(socket);
|
||||||
|
socket.on('message', raw => {
|
||||||
|
assert.equal(JSON.parse(raw).APIKey, 'fixture-key');
|
||||||
|
for (const [lat, epoch] of [[30, 1_800_000_000], [30.01, 1_800_000_060]]) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
MessageType: 'PositionReport',
|
||||||
|
MetaData: { MMSI: 123456789, latitude: lat, longitude: -97, time_utc: new Date(epoch * 1000).toISOString() },
|
||||||
|
Message: { PositionReport: { UserID: 123456789, Sog: 10, Cog: 90, TrueHeading: 511 } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const plugin = providers.aisLiveProxy();
|
||||||
|
t.after(() => plugin.closeBundle());
|
||||||
|
const request = install(plugin, true);
|
||||||
|
let res;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
res = await request('/api/ais-live');
|
||||||
|
if (JSON.parse(res.body).rows.length) break;
|
||||||
|
await delay(10);
|
||||||
|
}
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
assert.equal(data.status, 'live');
|
||||||
|
assert.equal(data.rows[0].mmsi, '123456789');
|
||||||
|
assert.equal(data.rows[0].heading, null);
|
||||||
|
const history = await request('/api/ais-live', '/track?mmsi=123456789');
|
||||||
|
assert.equal(JSON.parse(history.body).samples.length, 2);
|
||||||
|
assert.equal((await request('/api/ais-live', '/track?mmsi=bad')).statusCode, 400);
|
||||||
|
assert.equal(sockets.length, 1);
|
||||||
|
plugin.closeBundle();
|
||||||
|
for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);
|
||||||
|
assert.equal(sockets[0].readyState, 3);
|
||||||
|
const restarted = install(plugin);
|
||||||
|
await restarted('/api/ais-live');
|
||||||
|
for (let i = 0; i < 100 && sockets.length < 2; i++) await delay(10);
|
||||||
|
assert.equal(sockets.length, 2);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user