Files
gods-eye-view/src/data/aisStreamAdapter.js
T
Sameh Khamis 73e789489b chore: drop exports nothing imports
Knip, configured with the application, package exports, scripts, QA
harnesses, workers, tools and tests as entry points, reports exports
that no module imports. Each was checked by hand against string-based
and namespace imports.

- Symbols that are still used inside their own module lose only the
  `export` keyword (176 constants and helpers).
- Unused re-exports are removed from barrel modules (flight, military,
  vessel, CCTV, directions, transit history, local ADS-B and street-level
  policy modules).
- Declarations that nothing uses at all are deleted, among them
  AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText,
  DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band
  constants.
- Default exports that nothing imports are removed. The ones in
  src/data/earthquakes.js, src/data/telegeographySubmarineCables.js,
  src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a
  layer instance at import time that nothing used; those instances are
  no longer created.

Every export of a package.json entry stays, as does TOMTOM_CREDIT,
which the traffic layer reads from the credits namespace it is given.
2026-10-22 19:00:40 -07:00

581 lines
19 KiB
JavaScript

// Transport adapter for the AISStream watchdog.
//
// Extracted from vite.config.js so the socket lifecycle — the part that has
// historically carried the defects — can be exercised offline with mock
// sockets. The adapter owns sockets and translates watchdog ACTIONS into
// transport calls; the watchdog owns policy and owns no I/O.
//
// Two ownership rules make the single-socket invariant hold even when events
// arrive late, out of order, or across a dev-server restart:
//
// 1. Socket generations are MONOTONIC for the lifetime of this module and
// are never reused, including across dispose(). A recycled generation
// would let a pre-disposal handler act on its successor's socket.
// 2. Every mutation of the socket map is IDENTITY-checked: an entry is only
// deleted or acted on when the map still holds *that* socket object.
//
// Liveness credit is granted only to a frame that (a) arrived on a socket we
// still own, and (b) decoded into a real AIS record. Handshakes, malformed
// frames and error envelopes are never liveness.
import { createAisWatchdog } from './aisWatchdog.js';
const DEFAULT_CLOCK = Object.freeze({
wall: () => Date.now(),
mono: () => performance.now(),
});
/** Upstream text that identifies a credential rejection. */
const AUTH_TEXT =
/(unauthoriz|unauthoris|forbidden|invalid\s*api[\s_-]*key|invalid\s*key|bad\s*api[\s_-]*key|authentic|api\s*key\s*(is\s*)?(invalid|required|missing|not\s*valid))/i;
/** Upstream text that identifies a rate limit / connection cap. */
const RATE_TEXT =
/(rate[\s_-]*limit|too\s*many\s*(requests|connections)|quota\s*exceeded|429)/i;
/**
* Parse an HTTP `Retry-After` header into milliseconds.
* Accepts delta-seconds or an HTTP-date. Returns 0 when absent/unparseable.
* @param {string|number|undefined|null} raw
* @param {number} [nowMs] Wall-clock reference for the HTTP-date form.
* @returns {number}
*/
export function parseRetryAfterMs(raw, nowMs = Date.now()) {
if (raw === null || raw === undefined) return 0;
const text = String(raw).trim();
if (!text) return 0;
if (/^\d+$/.test(text)) return Number(text) * 1000;
const date = Date.parse(text);
if (Number.isFinite(date)) return Math.max(0, date - nowMs);
return 0;
}
/**
* Classify a transport or upstream failure.
*
* Only 'transport' may walk the fast backoff ladder. An auth rejection cannot
* be fixed by retrying, and a rate limit must honour the server's own pacing —
* treating either as a generic error is how a watchdog turns into a hammer.
*
* @param {{httpStatus?: number|string, message?: string,
* retryAfterHeader?: string|number|null, nowMs?: number}} input
* @returns {{kind: string, message: string, retryAfterMs?: number}}
*/
export function classifyAisFailure(input = {}) {
const text = String(input.message || '').trim();
const fromHeader = () =>
parseRetryAfterMs(input.retryAfterHeader, input.nowMs);
let status = Number(input.httpStatus);
if (!Number.isFinite(status)) {
// ws reports a failed upgrade as "Unexpected server response: 401" when no
// 'unexpected-response' listener consumed it.
const match = /unexpected server response:\s*(\d{3})/i.exec(text);
status = match ? Number(match[1]) : NaN;
}
if (status === 401 || status === 403) {
return {
kind: 'auth',
message: `AISStream rejected the API key (HTTP ${status})`,
};
}
if (status === 429) {
return {
kind: 'rate-limit',
message: 'AISStream rate-limited this key (HTTP 429)',
retryAfterMs: fromHeader(),
};
}
if (Number.isFinite(status) && status >= 400) {
return {
kind: 'transport',
message: `AISStream upgrade failed (HTTP ${status})`,
};
}
if (AUTH_TEXT.test(text)) return { kind: 'auth', message: text };
if (RATE_TEXT.test(text))
return { kind: 'rate-limit', message: text, retryAfterMs: fromHeader() };
return { kind: 'transport', message: text || 'AISStream websocket error' };
}
/**
* Decode a frame to text WITHOUT suspending, or return null if that is
* impossible (only the Blob shape needs an await).
*
* This exists to keep the message pipeline synchronous for every shape `ws`
* actually delivers. An await inside the pipeline is a suspension point, and a
* 'close' event arriving in the same tick would otherwise overtake the frame —
* which for an auth error envelope means the rejection is dropped and the
* generic transport ladder runs instead.
*
* @param {*} data
* @returns {string|null}
*/
export function decodeAisFrameSync(data) {
if (typeof data === 'string') return data;
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
if (ArrayBuffer.isView(data)) {
return new TextDecoder().decode(
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
);
}
if (Array.isArray(data)) {
// ws delivers a fragmented message as an array of Buffers.
const parts = data.map(decodeAisFrameSync);
return parts.some((part) => part === null) ? null : parts.join('');
}
return null;
}
/**
* Decode one websocket frame to text. Handles the String (built-in WebSocket),
* Buffer/TypedArray (ws), ArrayBuffer and Blob shapes.
* @param {*} data
* @returns {Promise<string>}
*/
export async function decodeAisFrame(data) {
const sync = decodeAisFrameSync(data);
if (sync !== null) return sync;
if (data && typeof data.text === 'function') return data.text();
return String(data ?? '');
}
/**
* Upper bound on a single frame before decoding.
*
* AIS envelopes are a couple of KB; anything approaching this is not a feed
* message. The bound is applied BEFORE the synchronous decode so a hostile or
* corrupt frame cannot stall the event loop building a huge string.
*/
export const AIS_MAX_FRAME_BYTES = 1_000_000;
/**
* Approximate a frame's size without decoding it. String length is in code
* units rather than UTF-8 bytes, which only ever under-counts — fine for a
* rejection threshold.
* @param {*} data
* @returns {number}
*/
export function aisFrameByteLength(data) {
if (typeof data === 'string') return data.length;
if (data instanceof ArrayBuffer) return data.byteLength;
if (ArrayBuffer.isView(data)) return data.byteLength;
if (Array.isArray(data))
return data.reduce((sum, part) => sum + aisFrameByteLength(part), 0);
return 0;
}
/**
* AISStream message types that count as feed traffic.
*
* This is the COMPLETE `AisMessageTypes` enum from AISStream's own
* type-definition.yaml (github.com/aisstream/ais-message-models, master),
* transcribed wholesale rather than hand-picked: a type missing from this list
* is never credited as liveness, so a subscription filtered to it would read
* as dead and be recycled forever. An earlier hand-picked version omitted
* AddressedBinaryMessage, DataLinkManagementMessageData and UnknownMessage for
* exactly that reason.
*
* If AISStream adds a type, add it here — do not infer it.
*/
export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
new Set([
'PositionReport',
'UnknownMessage',
'AddressedSafetyMessage',
'AddressedBinaryMessage',
'AidsToNavigationReport',
'AssignedModeCommand',
'BaseStationReport',
'BinaryAcknowledge',
'BinaryBroadcastMessage',
'ChannelManagement',
'CoordinatedUTCInquiry',
'DataLinkManagementMessage',
'DataLinkManagementMessageData',
'ExtendedClassBPositionReport',
'GroupAssignmentCommand',
'GnssBroadcastBinaryMessage',
'Interrogation',
'LongRangeAisBroadcastMessage',
'MultiSlotBinaryMessage',
'SafetyBroadcastMessage',
'ShipStaticData',
'SingleSlotBinaryMessage',
'StandardClassBPositionReport',
'StandardSearchAndRescueAircraftReport',
'StaticDataReport',
]),
);
/**
* Resolve an envelope's MMSI, or null.
* @param {Object} envelope
* @param {Object} body
* @returns {string|null}
*/
function aisEnvelopeMmsi(envelope, body = {}) {
const metadata = envelope?.MetaData || envelope?.Metadata || {};
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
if (raw === null || raw === undefined) return null;
const text = String(raw).trim();
return text ? text : null;
}
/**
* Is this envelope real AIS traffic — the feed's only liveness proof?
*
* Requires a recognised MessageType, a body filed under that type, AND an
* MMSI. An envelope carrying nothing but an MMSI is not evidence the feed
* works: that is exactly the shape a malformed or synthetic frame takes.
*
* @param {Object} envelope
* @returns {boolean}
*/
export function isRecognizedAisEnvelope(envelope) {
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope))
return false;
const messageType = envelope.MessageType;
if (
typeof messageType !== 'string' ||
!AIS_RECOGNIZED_MESSAGE_TYPES.has(messageType)
)
return false;
const body = envelope.Message?.[messageType];
if (!body || typeof body !== 'object') return false;
return aisEnvelopeMmsi(envelope, body) !== null;
}
/**
* Parse a decoded frame into a classified envelope.
*
* @param {string} text
* @returns {{kind: 'malformed'}|{kind: 'error', message: string}|{kind: 'data', envelope: Object}}
*/
export function parseAisEnvelope(text) {
let envelope;
try {
envelope = JSON.parse(text);
} catch {
return { kind: 'malformed' };
}
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
return { kind: 'malformed' };
}
if (envelope.error) return { kind: 'error', message: String(envelope.error) };
return { kind: 'data', envelope };
}
/**
* Create the AISStream transport adapter.
*
* @param {Object} options
* @param {(url: string) => Object} options.createSocket Socket factory.
* @param {() => string} options.resolveUrl Upstream URL, read per connect.
* @param {() => Object} options.buildSubscription Subscription payload.
* @param {(envelope: Object) => boolean} options.ingestEnvelope Returns true
* only when the envelope was a real AIS record — the sole liveness proof.
* @param {{wall: function, mono: function}} [options.clock]
* @param {(message: string) => void} [options.warn]
* @returns {Object} adapter handle
*/
export function createAisStreamAdapter(options) {
const {
createSocket,
resolveUrl,
buildSubscription,
ingestEnvelope,
clock = DEFAULT_CLOCK,
warn = () => {},
} = options;
/** generation -> socket. Mutated only through the identity-checked helpers. */
const sockets = new Map();
/** Never reset, including across dispose(). See rule 1 at the top. */
let generationHighWater = 0;
let watchdog = null;
/** (Re)build the state machine, preserving the generation namespace. */
function setWatchdogOptions(watchdogOptions = {}) {
if (watchdog) {
generationHighWater = Math.max(
generationHighWater,
watchdog.highWaterGeneration(),
);
}
watchdog = createAisWatchdog({
...watchdogOptions,
startGeneration: generationHighWater,
clock,
});
return watchdog;
}
/** True while `socket` is still the adapter's socket for `generation`. */
function ownsSocket(generation, socket) {
return sockets.get(generation) === socket;
}
/** Drop the map entry only if it still holds this exact socket. */
function releaseSocketEntry(generation, socket) {
if (sockets.get(generation) === socket) sockets.delete(generation);
}
/** Hard-abort a socket. Always terminate(), never close(). */
function abort(socket, reason) {
try {
socket.terminate();
} catch (error) {
warn(
`[AISStream] terminate(${reason || 'unknown'}) failed: ${error?.message || error}`,
);
}
}
/** Terminate the socket registered for `generation`, if any. */
function terminateGeneration(generation, reason) {
const socket = sockets.get(generation);
if (!socket) return;
sockets.delete(generation);
abort(socket, reason);
}
/**
* Perform watchdog actions in order.
* @param {Object} owner Watchdog instance that produced the actions.
* @param {Array<Object>} actions
*/
function runActions(owner, actions) {
for (const action of actions || []) {
if (action.type === 'terminate')
terminateGeneration(action.generation, action.reason);
else if (action.type === 'connect') openSocket(owner, action.generation);
}
}
/** Fail a generation through its owning watchdog, with classification. */
function failGeneration(owner, generation, detail) {
runActions(owner, owner.onFailure(generation, detail));
}
/**
* Open a socket for `generation`.
*
* Handlers capture BOTH the socket object and the watchdog instance that
* commissioned them, so an event arriving after a dispose cannot reach the
* replacement machine or a replacement socket.
*/
function openSocket(owner, generation) {
generationHighWater = Math.max(generationHighWater, generation);
let socket;
try {
socket = createSocket(resolveUrl());
} catch (error) {
failGeneration(
owner,
generation,
classifyAisFailure({ message: error?.message }),
);
return;
}
if (!socket) {
failGeneration(owner, generation, {
kind: 'transport',
message: 'socket factory returned nothing',
});
return;
}
sockets.set(generation, socket);
// `ws` emitter semantics only. The built-in WebSocket's addEventListener
// hands the handler an Event rather than the frame data, so accepting it
// here would silently mis-decode every frame — and it cannot be used
// anyway, having no terminate().
if (typeof socket.on !== 'function') {
sockets.delete(generation);
abort(socket, 'unsupported-transport');
failGeneration(owner, generation, {
kind: 'transport',
message: 'AIS socket does not expose ws emitter semantics',
});
return;
}
const on = (event, handler) => socket.on(event, handler);
on('open', () => {
if (!ownsSocket(generation, socket)) {
abort(socket, 'orphan-open');
return;
}
const actions = owner.onOpen(generation);
if (actions.length) {
runActions(owner, actions);
return; // orphan — hung up, never subscribed
}
try {
socket.send(JSON.stringify(buildSubscription()));
} catch (error) {
failGeneration(
owner,
generation,
classifyAisFailure({ message: error?.message }),
);
}
});
on('message', (data) => {
// The whole body is guarded: this is an EventEmitter handler, so an
// escaping throw (including from the decode itself) would reach the
// process-level handler and take the dev server down.
try {
const size = aisFrameByteLength(data);
if (size > AIS_MAX_FRAME_BYTES) {
warn(`[AISStream] dropping oversized frame (${size} bytes)`);
return; // no decode, no state effect
}
// Fast path: every shape ws delivers decodes without suspending, so
// the frame is fully classified before any same-tick 'close' runs.
const text = decodeAisFrameSync(data);
if (text !== null) {
handleDecodedMessage(owner, generation, socket, text);
return;
}
handleMessage(owner, generation, socket, data).catch((error) => {
warn(
`[AISStream] message handling failed: ${error?.message || error}`,
);
});
} catch (error) {
// A frame that cannot even be decoded is a malformed frame: dropped,
// with no liveness credit and no other state effect.
warn(`[AISStream] message handling failed: ${error?.message || error}`);
}
});
// Consuming 'unexpected-response' gives the real HTTP status and headers,
// which is what separates an auth rejection from a generic fault.
on('unexpected-response', (_request, response) => {
const detail = classifyAisFailure({
httpStatus: response?.statusCode,
retryAfterHeader: response?.headers?.['retry-after'],
nowMs: clock.wall(),
});
try {
response?.destroy?.();
} catch {
/* already gone */
}
releaseSocketEntry(generation, socket);
abort(socket, 'unexpected-response');
failGeneration(owner, generation, detail);
});
on('error', (error) => {
// An error on a socket we already abandoned yields no watchdog action,
// so it has to be hung up here or it would linger holding the slot.
if (!ownsSocket(generation, socket)) {
abort(socket, 'orphan-error');
return;
}
failGeneration(
owner,
generation,
classifyAisFailure({
message: error?.message || String(error || ''),
nowMs: clock.wall(),
}),
);
});
on('close', () => {
releaseSocketEntry(generation, socket);
runActions(owner, owner.onClose(generation));
});
}
/**
* The message pipeline. Ownership is verified before any work AND again
* after the decode await, because that suspension point is long enough for a
* terminate to land.
*/
async function handleMessage(owner, generation, socket, data) {
if (!ownsSocket(generation, socket)) {
abort(socket, 'orphan-message');
return;
}
// Only the Blob shape reaches here, and only it needs an await.
const text = await decodeAisFrame(data);
handleDecodedMessage(owner, generation, socket, text);
}
/**
* Classify and apply one decoded frame. Ownership is re-verified here because
* the async path suspends before reaching it.
*/
function handleDecodedMessage(owner, generation, socket, text) {
if (!ownsSocket(generation, socket)) {
abort(socket, 'orphan-message');
return;
}
const parsed = parseAisEnvelope(text);
if (parsed.kind === 'malformed') return; // not liveness, not data
if (parsed.kind === 'error') {
// An error envelope is the OPPOSITE of liveness. Classifying it keeps a
// rejected key out of the fast ladder.
failGeneration(
owner,
generation,
classifyAisFailure({
message: parsed.message,
nowMs: clock.wall(),
}),
);
return;
}
const accepted = Boolean(ingestEnvelope(parsed.envelope));
if (!accepted) return; // valid JSON, but no AIS record — proves nothing
runActions(owner, owner.onMessage(generation));
}
/** Drive the machine once: re-declare the environment, then advance time. */
function ensure(env) {
if (!watchdog) setWatchdogOptions();
runActions(watchdog, watchdog.configure(env));
runActions(watchdog, watchdog.tick());
}
/** Tear down sockets and machine state, preserving the generation namespace. */
function dispose() {
if (watchdog) {
generationHighWater = Math.max(
generationHighWater,
watchdog.highWaterGeneration(),
);
runActions(watchdog, watchdog.dispose());
}
for (const [generation, socket] of [...sockets]) {
sockets.delete(generation);
abort(socket, 'dispose');
}
sockets.clear();
}
return {
setWatchdogOptions,
ensure,
dispose,
snapshot: () => (watchdog ? watchdog.snapshot() : null),
/** Diagnostics for tests. */
debug: () => ({
liveSockets: sockets.size,
generations: [...sockets.keys()],
generationHighWater,
watchdog: watchdog ? watchdog.debugState() : null,
}),
};
}