Files
gods-eye-view/src/sources/live/contract.js
T

121 lines
3.4 KiB
JavaScript

/**
* Browser live-source contract. Coordinates are WGS84 degrees; lengths and
* velocities are metres and metres/second; times are Unix milliseconds.
* Barometric altitude and ellipsoid altitude are separate, nullable observations.
* A missing timestamp is unknown, never the time the response was received.
*
* Sources expose getSnapshot(query, { signal }) and optionally
* getTrack(reference, { signal }). References are opaque to consumers.
* Snapshots describe coverage and completeness independently of freshness.
* Records contain observation data only, with no scene objects or transport data.
*/
export class LiveSourceError extends Error {
constructor(
code,
message,
{ status = null, retryAfterMs = 20000, source = null } = {},
) {
super(message);
this.name = 'LiveSourceError';
this.code = code;
this.status = status;
this.retryAfterMs = retryAfterMs;
this.source = source;
}
}
export function finite(value) {
if (value == null || value === '' || typeof value === 'boolean') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
export function epoch(value, scale = 1) {
const number = finite(value);
return number != null && number > 0 ? number * scale : null;
}
export function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
export function coordinates(latitude, longitude) {
return (
Number.isFinite(latitude) &&
Math.abs(latitude) <= 90 &&
Number.isFinite(longitude) &&
Math.abs(longitude) <= 180
);
}
/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
export function admitRecords(rows, normalize, label) {
if (!Array.isArray(rows))
throw new LiveSourceError('malformed', `Malformed ${label} response`);
const records = [];
const ids = new Set();
for (const row of rows) {
const record = normalize(row);
if (record && !ids.has(record.id)) {
records.push(record);
ids.add(record.id);
}
}
if (rows.length && !records.length) {
throw new LiveSourceError('malformed', `Malformed ${label} aircraft rows`);
}
return {
records,
complete: records.length === rows.length,
rejectedCount: rows.length - records.length,
};
}
/** Cancellation is checked after body parsing even when a transport ignores it. */
export async function readResponse(
fetchImpl,
url,
{ signal, ...init } = {},
source = 'Live source',
) {
signal?.throwIfAborted();
let response;
try {
response = await fetchImpl(url, { ...init, signal });
signal?.throwIfAborted();
} catch (error) {
signal?.throwIfAborted();
if (error?.name === 'AbortError') throw error;
throw new LiveSourceError('unavailable', `${source} network error`, {
source,
});
}
let payload = null;
try {
payload = await response.json();
} catch {
/* classified below */
}
signal?.throwIfAborted();
return { response, payload };
}
export function httpError(response, source) {
const status = response.status;
const code =
status === 429
? 'limited'
: status === 401 || status === 403
? 'denied'
: 'unavailable';
return new LiveSourceError(
code,
status === 429 ? `${source} rate limited` : `${source} HTTP ${status}`,
{
status,
source,
retryAfterMs: code === 'limited' || code === 'denied' ? 45000 : 20000,
},
);
}