mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
@@ -0,0 +1,530 @@
|
||||
// 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}
|
||||
*/
|
||||
export 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user