Files
gods-eye-view/src/layers/localAdsb/feeds.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

254 lines
7.3 KiB
JavaScript

import {
localAdsbRecordIsLive,
localAdsbRecordIsNewer,
} from '../../sources/adsbRecords.js';
/** Same-origin route served by `server/providers/local-receivers.js`. */
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
const LOCAL_RECEIVER_POLL_MS = 1_000;
/**
* Browser-side deadline for one route request, body included. The server
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
*/
const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
function abortError() {
const error = new Error('Local receiver request aborted');
error.name = 'AbortError';
return error;
}
/** Reject with an AbortError as soon as `signal` aborts. */
function untilAborted(promise, signal) {
if (signal.aborted) return Promise.reject(abortError());
let onAbort = null;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(abortError());
signal.addEventListener('abort', onAbort, { once: true });
});
return Promise.race([promise, aborted]).finally(() =>
signal.removeEventListener('abort', onAbort),
);
}
function shiftTime(value, offset) {
return Number.isFinite(value) ? value + offset : value;
}
/**
* Browser poller for the server's local decoder feeds.
*
* Polls only between `start()` and `stop()` (the Local ADS-B layer calls them
* on enable/disable), and only while the route reports `configured`: an
* unconfigured server, or a build without the route, stops the loop after one
* request. `probe()` makes that one request without polling, so the Radio card
* can show which feeds exist before the layer is turned on.
*
* Records are rebased from the server clock to this browser's clock and kept
* by ICAO and band for up to 60 s after their last message, so a feed that
* drops an aircraft or goes briefly unreachable does not erase it at once.
* @param {object} [options]
* @param {typeof fetch} [options.fetchImpl]
* @param {string} [options.url]
* @param {number} [options.intervalMs]
* @param {number} [options.requestTimeoutMs] Deadline per request.
* @param {() => number} [options.now]
* @returns {object} Feed session: start, stop, probe, getState, subscribe.
*/
export function createLocalReceiverFeeds({
fetchImpl = (...args) => globalThis.fetch(...args),
url = LOCAL_RECEIVERS_URL,
intervalMs = LOCAL_RECEIVER_POLL_MS,
requestTimeoutMs = LOCAL_RECEIVER_REQUEST_TIMEOUT_MS,
now = Date.now,
} = {}) {
const listeners = new Set();
const retained = new Map();
/** Abort controllers of requests still in flight. */
const inFlight = new Set();
let state = {
configured: null,
polling: false,
feeds: [],
records: [],
error: null,
updatedAt: null,
};
let generation = 0;
let timer = null;
let probing = null;
function emit(patch) {
state = { ...state, ...patch };
for (const listener of listeners) listener(state);
}
function retain(records, at) {
for (const record of records) {
if (!record?.icao) continue;
// Two feeds on one band both report an aircraft: keep the fresher
// record, never simply the one listed (or polled) last.
const key = `${record.icao}|${record.band}`;
const current = retained.get(key);
if (!current || !localAdsbRecordIsNewer(current, record))
retained.set(key, record);
}
for (const [key, record] of retained) {
if (!localAdsbRecordIsLive(record, at)) retained.delete(key);
}
return [...retained.values()];
}
/** One route request with a deadline through the body, abortable by stop. */
async function request() {
const controller = new AbortController();
inFlight.add(controller);
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await untilAborted(
fetchImpl(url, {
cache: 'no-store',
headers: { Accept: 'application/json' },
signal: controller.signal,
}),
controller.signal,
);
if (!response?.ok) throw new Error('local receiver route unavailable');
return await untilAborted(response.json(), controller.signal);
} finally {
clearTimeout(timer);
inFlight.delete(controller);
}
}
function abortInFlight() {
for (const controller of inFlight) controller.abort();
inFlight.clear();
}
function apply(body) {
if (body?.configured !== true) {
retained.clear();
emit({
configured: false,
polling: false,
feeds: [],
records: [],
error: null,
});
return false;
}
const at = now();
const offset = Number.isFinite(body.generatedAt)
? at - body.generatedAt
: 0;
const records = (Array.isArray(body.records) ? body.records : []).map(
(record) => ({
...record,
lastPositionAt: shiftTime(record.lastPositionAt, offset),
lastMessageAt: shiftTime(record.lastMessageAt, offset),
}),
);
emit({
configured: true,
feeds: Array.isArray(body.feeds) ? body.feeds : [],
records: retain(records, at),
error: null,
updatedAt: at,
});
return true;
}
function fail() {
// A route that never answered means there is nothing to poll; a route
// that answered before is reported unreachable and polled again.
if (state.configured !== true) {
emit({ configured: false, polling: false, error: 'unavailable' });
return false;
}
emit({
feeds: state.feeds.map((feed) =>
feed.status === 'invalid'
? feed
: { ...feed, status: 'unreachable', aircraft: 0 },
),
records: retain([], now()),
error: 'unavailable',
});
return true;
}
async function tick(current) {
timer = null;
let keepPolling;
try {
const body = await request();
if (current !== generation) return;
keepPolling = apply(body);
} catch {
if (current !== generation) return;
keepPolling = fail();
}
if (!keepPolling) {
generation += 1;
return;
}
timer = setTimeout(() => void tick(current), intervalMs);
}
return {
/** Start polling (no-op while already polling). */
start() {
if (state.polling) return;
generation += 1;
emit({ polling: true });
void tick(generation);
},
/** Stop polling; the last feed statuses stay readable. */
stop() {
generation += 1;
clearTimeout(timer);
timer = null;
abortInFlight();
if (state.polling) emit({ polling: false });
},
/**
* Ask the route once whether feeds are configured, without polling.
* @returns {Promise<void>}
*/
probe() {
if (state.polling || state.configured !== null) return Promise.resolve();
if (!probing) {
const current = generation;
probing = request()
.then((body) => {
if (current === generation && !state.polling) apply(body);
})
.catch(() => {
if (current === generation && !state.polling)
emit({ configured: false, error: 'unavailable' });
})
.finally(() => {
probing = null;
});
}
return probing;
},
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
this.stop();
listeners.clear();
retained.clear();
},
};
}