feat(sdr): local receivers — browser RTL-SDR, Local ADS-B layer and decoder feeds (#732)

Adds a Local ADS-B layer fed by your own receivers: a browser WebUSB
RTL-SDR (with FM listening) and local 1090/978 MHz decoder feeds
(LOCAL_RECEIVER_FEEDS, local and private addresses only). Local aircraft
draw in magenta with class, silhouettes, 3D models, trails and a position
sanity filter; the click card names the band and input that heard them.

Builds on Sameh Khamis's SDR prototype and the initial GEV port by
Mazeyar Moeini Feizabadi (#134); the decoder-feed design follows the
notes on #57. Closes #57.

Co-authored-by: Sameh Khamis <[email protected]>
Co-authored-by: Mazeyar Moeini Feizabadi <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-23 16:00:53 -05:00
1 parent 143cffd7ca
commit 1fc795577a
78 files changed
+12267 -108

No files matched your search

+14
View File
@@ -438,6 +438,20 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: 'Light aircraft',
glider: 'Glider',
turboprop: 'Turboprop',
airliner: 'Airliner',
widebody: 'Widebody',
quadjet: 'Four-engine jet',
helicopter: 'Helicopter',
fastjet: 'Fast jet',
bizjet: 'Business jet',
uav: 'Drone',
});
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = {
light: 0.62,
+13
View File
@@ -7,3 +7,16 @@
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
browser mode — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never loaded by ordinary application startup. © TomTom.
- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*<hex>;`, one per line,
in receive order) received over Austin, TX, and
`adsb-austin-dump1090-aircraft.json` — dump1090-fa's `aircraft.json` decode
of the same frames, trimmed to the fields the tests assert on. Used by the
Local ADS-B decoder and record-adapter tests; never loaded at runtime.
- `adsb-austin-capture-timed.json` — a second ~90 s receive over Austin, TX:
846 extended squitters from six aircraft (`frames`: `[ms, hex]`), with
dump1090-fa's decoded positions and emitter categories for the same
interval (`dump1090`: per ICAO `flight`, `category` and `fixes` as
`[ms, lat, lon]`). The capture has no per-frame timestamps; each frame's
time is interpolated between the frames whose decoded position equals a
dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's
category, speed-check and track-accuracy tests; never loaded at runtime.
@@ -0,0 +1 @@
{"receiver":{"latitude":30.27,"longitude":-97.8},"frames":[[10,"8d0d0c07501d0030aa4c0c2140c6"],[80,"8da5b3a758b99408f8dda679bb00"],[150,"8da0b702e1150d0000000004eb56"],[220,"8d0d0c0722594377e37820b79a0d"],[290,"8d0d0c07990c111d483c0c92e5f3"],[360,"8da0b702590d40253c4af26e47d9"],[430,"8da0b70299105d833818018770a8"],[500,"8d0d0c07501bf7da9cd71ab9e124"],[570,"8da5b3a7ea466864013c085e68e2"],[640,"8da3627d58a580665464f23579f2"],[780,"8d0d0c07990c111d483c0c92e5f3"],[850,"8da0b702590d47cf46d60b223463"],[920,"8da0b70299105d83380c015f9ea8"],[990,"8d0d0c07f8200002004ab89cec0b"],[1060,"8da0b702f8122006005ab8b0ce08"],[1130,"8d0d0c07501bf030d84c0b00e5d5"],[1200,"8da5b3a758b9905ff8528e447e65"],[1275,"8d0d0c07990c111d483c0c92e5f3"],[1350,"8da0b702590d5025384afb963e58"],[1425,"8da0b70299105d83300481465660"],[1500,"8da3627d58a5840ef0ef82e7f39f"],[1600,"8d0d0c07501bf030f64c0af8367b"],[1675,"8da5b3a7990da30b30085d4a3fb6"],[1750,"8d0d0c07990c111d483c0b6d35d7"],[1825,"8da5b3a723041333c31c2045cc84"],[1900,"8da0b702590d4025364affe4f100"],[1963,"8da0b70299105c833008010a006e"],[2088,"8dabe7c59915ad0d380456abc760"],[2150,"8dabe7c558af87ce8ccac01d62c7"],[2213,"8d0d0c07501be031124c090e5e08"],[2275,"8d0d0c07e1058a00000000c4d44e"],[2338,"8da3627de106290000000030ef86"],[2400,"8da5b3a758b994091cdd51bae36d"],[2460,"8d0d0c07990c111d483c0c92e5f3"],[2580,"8da0b702590d47cf40d6170e6567"],[2640,"8da0b70299105c83100c01774a47"],[2700,"8d0d0c07501be031244c08456bbe"],[2800,"8da5b3a7990da30b30085d4a3fb6"],[2900,"8da27e81592147de34ccbfe1e71f"],[3000,"8d0d0c07990c111d683c0b2849fe"],[3100,"8da0b702590d57cf3cd61d759697"],[3133,"8da0b70299105c83101081d8ce87"],[3200,"8da5b3a7ea466864013c085e68e2"],[3233,"8d0d0c07501be7db16d7163df6ea"],[3267,"8da3627d991417bb100854ccc1a2"],[3300,"8da27e81592147de3cccc18db35e"],[3333,"8da27e819910390f18080b8ece70"],[3367,"8d0d0c07990c111d683c0cd799da"],[3400,"8da5b3a758b990601e52360713ad"],[3450,"8da0b702f8122006005ab8b0ce08"],[3500,"8d0d0c07f8200002004ab89cec0b"],[3550,"8da0b702590d50252e4b0d6c0eab"],[3600,"8da0b70299105b83101481e9cced"],[3650,"8da5b3a7f82300060049b8e08700"],[3700,"8d0d0c07501be7db32d715b033d5"],[3700,"8da3627d58a580652c64ebca78d9"],[3860,"8da5b3a7990da30b30085eb5d7a4"],[3940,"8d0d0c07990c111d68380b107ffe"],[4020,"8dabe7c59915ad0d380456abc760"],[4100,"8da0b702590d57cf38d625439697"],[4140,"8da0b70299105b83101801a6902d"],[4180,"8d0d0c07501bd031724c05906709"],[4220,"8dabe7c558af87ceb8ca66b4e8a7"],[4260,"8d0d0c07990c111d68380cefafda"],[4300,"8da5b3a758b9906030520dfe8d1c"],[4417,"8d0d0c07501bd031844c05445a49"],[4533,"8da0b702590d57cf36d62b019958"],[4650,"8da0b70299105b83101881a196ed"],[4767,"8da5b3a7990da30b30085d4a3fb6"],[4883,"8d0d0c07990c111d88380b35ff28"],[5000,"8da0b702590d57cf34d62ee5fde5"],[5000,"8da0b70299105b82f01481e7b168"],[5000,"8da0b702e1150d0000000004eb56"],[5000,"8d0d0c07501bd7db74d71347e30c"],[5150,"8da0b70227c74d5462082004597a"],[5300,"8da5b3a758b990604051e7fb7390"],[5333,"8d0d0c0722594377e37820b79a0d"],[5367,"8d0d0c07990c111d88380cca2f0c"],[5400,"8dabe7c558af8024c23ee7c8e962"],[5460,"8dabe7c5f8230006004ab8db4fc2"],[5520,"8da0b702590d5025264b1f025060"],[5580,"8da0b70299105b82f01401e0b7a8"],[5640,"8da5b3a7ea466864013c085e68e2"],[5700,"8d0d0c07501bd031b44c03dc465d"],[5760,"8da5b3a7990da30b30085eb5d7a4"],[5820,"8da0b702f8122006005ab8b0ce08"],[5880,"8d0d0c07990c111d88380b35ff28"],[5940,"8dabe7c59915ad0d300456c56568"],[6000,"8da0b702590d57cf30d6372dc85c"],[6050,"8da0b70299105b82f01401e0b7a8"],[6100,"8d0d0c07501bc031c64c021a765d"],[6250,"8d0d0c07f8200002004ab89cec0b"],[6400,"8dabe7c558af8024d83eba9c7813"],[6500,"8d0d0c07501bc7dbb4d71117af08"],[6600,"8d0d0c07990c111da83c0b48b501"],[6700,"8da5b3a7990da30b30085d4a3fb6"],[6800,"8dabe7c59915ad0d300456c56568"],[6900,"8d0d0c07501bc7dbc8d710a36866"],[7050,"8d0d0c07990c111da83c0cb76525"],[7200,"8da5b3a758b990606451947414a7"],[7300,"8dabe7c558af87cef8c9e42b1cae"],[7360,"8dabe7c5ea428867215c08fe0874"],[7420,"8d0d0c07e1058a00000000c4d44e"],[7540,"8d0d0c07501bc0320a4c005f6240"],[7600,"8d0d0c07990c111da83c0b48b501"],[7660,"8da0b702590d67cf28d645cd6577"],[7720,"8da0b70299105a82f01081dc8da6"],[7780,"8da5b3a7990da30b30085eb5d7a4"],[7840,"8dabe7c59915ad0d300456c56568"],[7900,"8d0d0c07501bb032264bff008fc5"],[7933,"8d0d0c07990c111da83c0cb76525"],[7967,"8da5b3a7ea466864013c085e68e2"],[8000,"8da0b702590d67cf26d6487082aa"],[8150,"8da0b70299105a82d01481a1c78f"],[8300,"8da5b3a758b9906076516b48fcb5"],[8300,"8dabe7c558af8025003e6a60fadf"],[8364,"8da5b3a7f82300060049b8e08700"],[8427,"8da0b702f8122006005ab8b0ce08"],[8491,"8d0d0c07f8200002004ab89cec0b"],[8555,"8d0d0c07990c111dc83c0b87317a"],[8618,"8d0d0c07501bb032444bfe2b5735"],[8682,"8da5b3a7e10d2800000000aefd57"],[8745,"8da0b702590d67cf24d64d94e617"],[8809,"8da0b70299105a82d01481a1c78f"],[8873,"8dabe7c59915ad0d300456c56568"],[8936,"8d0d0c07990c111dc83c0c78e15e"],[9000,"8d0d0c07501bb032564bfeedbba7"],[9200,"8da0b702590d6025164b3fca5cc9"],[9267,"8da0b70299105a82d010019ef74f"],[9333,"8da5b3a758b9940996dc34d66e25"],[9400,"8dabe7c55Line truncated
@@ -0,0 +1,53 @@
{
"now": 1790127097.5,
"messages": 112,
"aircraft": [
{
"hex": "a15c54",
"flight": "SKW3301 ",
"alt_baro": 5050,
"gs": 287.5,
"track": 291.2,
"baro_rate": 3072,
"seen": 31.5,
"messages": 14,
"rssi": -28.2
},
{
"hex": "ae5d8a",
"flight": "SHINR42 ",
"lat": 30.269662,
"lon": -97.793262,
"alt_baro": 1600,
"gs": 129.9,
"track": 330.5,
"baro_rate": 64,
"seen": 30.5,
"seen_pos": 34.6,
"messages": 74,
"rssi": -30.0
},
{
"hex": "a3c775",
"lat": 30.360625,
"lon": -97.919659,
"alt_baro": 34000,
"gs": 434.4,
"track": 283.7,
"baro_rate": 0,
"seen": 38.2,
"seen_pos": 43.4,
"messages": 20,
"rssi": -30.5
},
{
"hex": "a46e6a",
"gs": 319.8,
"track": 351.2,
"baro_rate": 2688,
"seen": 32.0,
"messages": 4,
"rssi": -37.1
}
]
}
+112
View File
@@ -0,0 +1,112 @@
*8da46e6a99143227b0ac21405d5c;
*8da46e6aea428858015c080bb810;
*5da46e6ae86d20;
*8da3c775990da70d180457361e57;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a580d77d658d63e32c82f;
*5dae5d8a54a93d;
*8da3c77558af803cce430dad950f;
*200015b8f3156e;
*02e615b82df0c7;
*02e615b82df0c7;
*8da3c775990da70d180458c9be1f;
*8dae5d8a99a4400e70088202ff80;
*5dae5d8a54a93d;
*200015b8f3156e;
*200015b8f3156e;
*8dae5d8a580d702c844b24b6523e;
*8dae5d8af80300020049bc1cd8f3;
*200015b8f3156e;
*20000197d80069;
*5dae5d8a54a93d;
*020001970f9c90;
*8dae5d8a580d77d688d63170d088;
*8da3c77558af87e6a0cdf2c8bbf8;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a274c824e4b4ca06f9608;
*5dae5d8a54a93d;
*8dae5d8a99a4400e700c823ac980;
*020001970f9c90;
*5da15c54111a4b;
*8dae5d8a580d77d6a0d62aa4760f;
*5da3c7758f35d4;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500c827cbf67;
*8da15c54581b8019e24c96ebec6d;
*8dae5d8a580d77d6ccd61fccec2f;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500c827cbf67;
*8dae5d8a580d77d6d8d61cd91129;
*8dae5d8a99a4410e500c827cbf67;
*8dae5d8a580d77d6e2d619cbefa5;
*8dae5d8af80300020049bc1cd8f3;
*20000197d80069;
*80000197580d77d6e8d6172cc0e3;
*8dae5d8a580d702d044b0189a64f;
*020001970f9c90;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d77d6fad6128791c3;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500882448967;
*020001970f9c90;
*8da15c54581bc01a424c2f038351;
*8dae5d8ae1080800000000e6168f;
*8da3c77558af87e730ccd3243e2f;
*8dae5d8a580d87d728d6051c99a8;
*5dae5d8a54a93d;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d802d584aea9aed46;
*5da15c54111a4b;
*8dae5d8a99a4410e500c827cbf67;
*2000019827a021;
*8dae5d8af80300020049bc1cd8f3;
*8dae5d8a580d87d754d5f94050dd;
*8dae5d8a99a4410e500c827cbf67;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500c827cbf67;
*02000198f03cd8;
*8dae5d8a580d87d76ed5f252fa10;
*8dae5d8aea00085ee53e004546c3;
*8da15c5499150a0df0ac0d713d8d;
*200015b8f3156e;
*8da3c775990da70d100458a71c17;
*8dae5d8a580d87d778d5ef5c1b0d;
*8dae5d8a99a4410e500882448967;
*5dae5d8a54a93d;
*02818393027eb0;
*5da3c7758f35d5;
*5da3c7758f35d5;
*5da3c7758f35d4;
*200015b8f3156e;
*8dae5d8a580d87d782d5ec2e3d53;
*8da15c54ea178858015e88a53d08;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500882448967;
*02000198f03cd8;
*8dae5d8ae1080800000000e6168f;
*200015b8f3156e;
*8dae5d8a580d802db04ad2d09e9c;
*02e195b8f5cd24;
*8da15c5499150b0db0b40d687b11;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d802dc64accee13e8;
*2000019827a021;
*8dae5d8a99a4410e500881bb6175;
*8dae5d8a580d802ddc4ac5b9d917;
*8dae5d8af80300020049bc1cd8f3;
*80000198580d87d7cad5d9283036;
*02000198f03cd8;
*5da15c54111a4b;
*8dae5d8a580d87d7ecd5d0a28b94;
*8dae5d8a99a4410e500881bb6175;
*8da15c54234cb5f3cf0c6055934a;
*5dae5d8a54a93d;
*8da15c5499150d0d50c40e1a396a;
*8da15c5499150d0d30c40ed5bd11;
*5da15c54111a4b;
*5da46e6ae86d29;
*8da15c54581f201b484af27089b0;
*02000198f03cd8;
*5dae5d8a54a93d;
+2
View File
@@ -16,6 +16,8 @@ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
'enabled-only',
'enabled+options',
'enabled+mirrored-options',
// Registered but never serialized (for example hardware-local layers).
'local-only',
]);
function isAbortError(error) {
+459
View File
@@ -0,0 +1,459 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
LOCAL_RECEIVERS_ROUTE,
createLocalReceiversHandler,
localReceiversProxy,
parseLocalReceiverFeeds,
} from '../../server/providers/local-receivers.js';
import { localProviderPlugins } from '../../server/providers/local.js';
const FIXTURE = readFileSync(
new URL('./fixtures/adsb-austin-dump1090-aircraft.json', import.meta.url),
'utf8',
);
const FIXTURE_NOW_MS = JSON.parse(FIXTURE).now * 1000;
const FEED_1090 = 'http://localhost:8080/data/aircraft.json';
const FEED_978 = 'http://127.0.0.1:8978/data/aircraft.json';
function quietLogger() {
const lines = [];
return {
lines,
warn: (line) => lines.push(line),
info: (line) => lines.push(line),
};
}
function respond(status = 200, body = FIXTURE, headers = {}) {
return new Response(body, {
status,
headers: { 'content-type': 'application/json', ...headers },
});
}
async function call(handler, method = 'GET') {
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler.handle({ url: '/', method }, res);
return { ...res, json: JSON.parse(res.body) };
}
test('LOCAL_RECEIVER_FEEDS accepts local http(s) aircraft.json feeds per band', () => {
const { configured, feeds } = parseLocalReceiverFeeds(
` 1090=${FEED_1090} , 978=http://skyaware.local/skyaware978/data/aircraft.json,1090=https://192.168.1.20:8443/tar1090/data/aircraft.json`,
);
assert.equal(configured, true);
assert.deepEqual(
feeds.map(({ band, label, url }) => ({ band, label, url })),
[
{ band: '1090', label: '1090 MHz #1', url: FEED_1090 },
{
band: '978',
label: '978 MHz UAT',
url: 'http://skyaware.local:80/skyaware978/data/aircraft.json',
},
{
band: '1090',
label: '1090 MHz #2',
url: 'https://192.168.1.20:8443/tar1090/data/aircraft.json',
},
],
);
assert.deepEqual(parseLocalReceiverFeeds(undefined), {
configured: false,
feeds: [],
});
assert.equal(parseLocalReceiverFeeds(' , ').configured, false);
});
test('LOCAL_RECEIVER_FEEDS rejects public, link-local and named hosts, bad paths and bands', () => {
const cases = {
'1090=http://8.8.8.8/data/aircraft.json': 'host must be',
'1090=http://169.254.169.254/latest/aircraft.json': 'host must be',
'1090=http://example.com/data/aircraft.json': 'host must be',
'1090=http://[::1]:8080/data/aircraft.json': 'host must be',
'1090=http://localhost:8080/data/receiver.json': 'path must end',
'1090=http://localhost:8080/data/aircraft.json.bak': 'path must end',
'1090=http://localhost:8080/data/aircraft.json?x=1': 'query',
'1090=http://user:pw@localhost:8080/data/aircraft.json': 'credentials',
'1090=ftp://localhost/data/aircraft.json': 'scheme',
'1090=not a url': 'not a valid URL',
'868=http://localhost:8080/data/aircraft.json': 'band must be',
'http://localhost:8080/data/aircraft.json': 'expected band=url',
};
for (const [entry, reason] of Object.entries(cases)) {
const [feed] = parseLocalReceiverFeeds(entry).feeds;
assert.equal(feed.url, undefined, `${entry} must not be fetchable`);
assert.match(feed.reason, new RegExp(reason), entry);
}
const [badBand] = parseLocalReceiverFeeds(
'868=http://localhost/aircraft.json',
).feeds;
assert.equal(badBand.band, null);
assert.equal(badBand.label, 'entry 1');
});
test('an unconfigured route answers 200 without fetching anything', async (t) => {
t.mock.method(globalThis, 'fetch', () => {
throw Error('must not fetch');
});
const handler = createLocalReceiversHandler({
feedsValue: undefined,
logger: quietLogger(),
});
const res = await call(handler);
assert.equal(res.status, 200);
assert.deepEqual(res.json, { configured: false, feeds: [], records: [] });
});
test('construction mounts the route once and neither parses nor fetches', (t) => {
t.mock.method(globalThis, 'fetch', () => {
throw Error('construction must not fetch');
});
const plugins = localProviderPlugins();
assert.equal(
plugins.filter((plugin) => plugin.name === 'local-receivers-proxy').length,
1,
);
const routes = [];
localReceiversProxy({
feedsValue: '',
logger: quietLogger(),
}).configureServer({ middlewares: { use: (route) => routes.push(route) } });
assert.deepEqual(routes, [LOCAL_RECEIVERS_ROUTE]);
});
test('live feeds are read in parallel and labelled with their band', async () => {
const calls = [];
const handler = createLocalReceiversHandler({
feedsValue: `1090=${FEED_1090},978=${FEED_978}`,
now: () => FIXTURE_NOW_MS + 2_000,
logger: quietLogger(),
fetchImpl: async (url, options) => {
calls.push({ url, redirect: options.redirect });
return respond();
},
});
const { status, json } = await call(handler);
assert.equal(status, 200);
assert.deepEqual(
calls.sort((a, b) => (a.url < b.url ? 1 : -1)),
[
{ url: FEED_1090, redirect: 'manual' },
{ url: FEED_978, redirect: 'manual' },
],
);
assert.equal(json.configured, true);
assert.deepEqual(json.feeds, [
{
band: '1090',
label: '1090 MHz',
status: 'live',
aircraft: 4,
ageMs: 2_000,
},
{
band: '978',
label: '978 MHz UAT',
status: 'live',
aircraft: 4,
ageMs: 2_000,
},
]);
assert.equal(json.records.length, 8);
const shinr = json.records.filter((record) => record.icao === 'ae5d8a');
assert.deepEqual(
shinr.map(({ band, source }) => ({ band, source })),
[
{ band: '1090', source: 'feed' },
{ band: '978', source: 'feed' },
],
);
// Ages are anchored at the feed's own `now`.
assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600);
assert.equal(shinr[0].lat, 30.269662);
});
test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => {
const logger = quietLogger();
const handler = createLocalReceiversHandler({
feedsValue: [
`1090=${FEED_1090}`,
`978=${FEED_978}`,
'1090=http://10.0.0.5/redirect/aircraft.json',
'1090=http://10.0.0.6/big/aircraft.json',
'1090=http://10.0.0.7/error/aircraft.json',
'978=http://10.0.0.8/garbage/aircraft.json',
'1090=http://8.8.8.8/aircraft.json',
].join(','),
now: () => FIXTURE_NOW_MS + 10_001,
logger,
maxBytes: 4_096,
fetchImpl: async (url) => {
if (url === FEED_978) throw new TypeError('connect ECONNREFUSED secret');
if (url.includes('/redirect/'))
return respond(302, '', { location: 'http://169.254.169.254/' });
if (url.includes('/big/'))
return respond(200, `{"aircraft":[${' '.repeat(5_000)}]}`);
if (url.includes('/error/'))
return respond(500, 'upstream internal detail');
if (url.includes('/garbage/')) return respond(200, '<html>');
if (url.includes('8.8.8.8')) throw Error('invalid feed was fetched');
return respond();
},
});
const { json, body } = await call(handler);
assert.deepEqual(
json.feeds.map(({ band, status }) => `${band} ${status}`),
[
'1090 stale',
'978 unreachable',
'1090 unreachable',
'1090 unreachable',
'1090 unreachable',
'978 unreachable',
'1090 invalid',
],
);
assert.equal(json.feeds[0].ageMs, 10_001);
assert.equal(json.records.length, 4, 'stale feed records still flow');
for (const secret of ['ECONNREFUSED', 'secret', 'internal detail', '169.254'])
assert.equal(body.includes(secret), false, `${secret} leaked`);
assert.equal(
body.includes('10.0.0'),
false,
'feed addresses stay server-side',
);
assert.ok(
logger.lines.some((line) =>
/LOCAL_RECEIVER_FEEDS feed-7 .* rejected: host must be/.test(line),
),
);
assert.ok(logger.lines.some((line) => /REDIRECT_REFUSED/.test(line)));
assert.ok(logger.lines.some((line) => /RESPONSE_TOO_LARGE/.test(line)));
});
test('a feed that exceeds the timeout is unreachable', async () => {
const handler = createLocalReceiversHandler({
feedsValue: `1090=${FEED_1090}`,
logger: quietLogger(),
timeoutMs: 5,
fetchImpl: (url, { signal }) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
}),
});
const { json } = await call(handler);
assert.equal(json.feeds[0].status, 'unreachable');
});
test('requests within a second share one read of every feed', async () => {
let clock = FIXTURE_NOW_MS;
let fetches = 0;
const handler = createLocalReceiversHandler({
feedsValue: `1090=${FEED_1090}`,
now: () => clock,
logger: quietLogger(),
fetchImpl: async () => {
fetches += 1;
return respond();
},
});
await Promise.all([call(handler), call(handler), call(handler)]);
assert.equal(fetches, 1, 'concurrent requests are single-flight');
clock += 500;
await call(handler);
assert.equal(fetches, 1, 'cached for about a second');
clock += 1_000;
await call(handler);
assert.equal(fetches, 2);
const res = await call(handler, 'POST');
assert.equal(res.status, 405);
});
test('a feed name is resolved, every address checked, and the connection pinned to it', async () => {
const lookups = [];
const answers = {
'good.local': [{ address: '10.0.0.9', family: 4 }],
'public.local': [{ address: '93.184.216.34', family: 4 }],
'linklocal.local': [{ address: '169.254.169.254', family: 4 }],
'mixed.local': [
{ address: '192.168.1.4', family: 4 },
{ address: '8.8.8.8', family: 4 },
],
};
const fetched = [];
const logger = quietLogger();
const handler = createLocalReceiversHandler({
feedsValue: [
'1090=http://good.local:8080/data/aircraft.json',
'1090=http://public.local:8080/data/aircraft.json',
'978=http://linklocal.local/data/aircraft.json',
'978=http://mixed.local/data/aircraft.json',
`1090=${FEED_978}`,
].join(','),
now: () => FIXTURE_NOW_MS,
logger,
lookupImpl: async (hostname, options) => {
lookups.push({ hostname, all: options?.all });
return answers[hostname];
},
fetchImpl: async (url, options) => {
fetched.push({ url, lookup: options.lookup });
return respond();
},
});
const { json } = await call(handler);
assert.deepEqual(
json.feeds.map(({ status }) => status),
['live', 'unreachable', 'unreachable', 'unreachable', 'live'],
);
assert.deepEqual(
lookups.map(({ hostname }) => hostname).sort(),
['good.local', 'linklocal.local', 'mixed.local', 'public.local'],
'IP literals are not resolved',
);
assert.ok(
lookups.every(({ all }) => all === true),
'every address',
);
assert.deepEqual(
fetched.map(({ url }) => url).sort(),
[FEED_978, 'http://good.local:8080/data/aircraft.json'],
'a name resolving outside loopback/RFC1918 is never contacted',
);
// The connection resolves only to the validated address, whatever the
// resolver would answer later.
answers['good.local'] = [{ address: '93.184.216.34', family: 4 }];
const pinned = fetched.find(({ url }) => url.includes('good.local')).lookup;
assert.equal(typeof pinned, 'function');
const one = await new Promise((resolve) =>
pinned('good.local', {}, (error, address, family) =>
resolve({ error, address, family }),
),
);
assert.deepEqual(one, { error: null, address: '10.0.0.9', family: 4 });
const all = await new Promise((resolve) =>
pinned('good.local', { all: true }, (error, addresses) =>
resolve(addresses),
),
);
assert.deepEqual(all, [{ address: '10.0.0.9', family: 4 }]);
assert.ok(logger.lines.some((line) => /FORBIDDEN_ADDRESS/.test(line)));
});
test('the default transport connects to the validated address, not a re-resolution', async (t) => {
const { createServer } = await import('node:http');
const server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ now: FIXTURE_NOW_MS / 1000, aircraft: [] }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
const { port } = server.address();
// `receiver-pin-test.local` has no real DNS entry: only the pinned lookup
// can reach the loopback server.
const handler = createLocalReceiversHandler({
feedsValue: `1090=http://receiver-pin-test.local:${port}/data/aircraft.json`,
now: () => FIXTURE_NOW_MS,
logger: quietLogger(),
lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }],
});
const { json } = await call(handler);
assert.equal(json.feeds[0].status, 'live');
});
test('null-body statuses (204, 205, 304) from a named feed are a clean feed error, never an uncaught exception', async (t) => {
const { createServer } = await import('node:http');
const server = createServer((req, res) => {
const status = Number(req.url.split('/')[1]);
res.writeHead(status, { 'content-type': 'application/json' });
res.end();
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
const { port } = server.address();
const uncaught = [];
const onUncaught = (error) => uncaught.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
const handler = createLocalReceiversHandler({
feedsValue: [204, 205, 304]
.map(
(status) =>
`1090=http://receiver-null-body.local:${port}/${status}/aircraft.json`,
)
.join(','),
now: () => FIXTURE_NOW_MS,
logger: quietLogger(),
timeoutMs: 300,
lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }],
});
const { status, json } = await call(handler);
// Let any exception thrown inside the response callback surface.
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(uncaught, [], 'no uncaught exception');
assert.equal(status, 200);
assert.deepEqual(
json.feeds.map((feed) => feed.status),
['unreachable', 'unreachable', 'unreachable'],
);
});
test('a stalled DNS lookup fails its feed at the deadline without holding other feeds or later polls', async () => {
let clock = FIXTURE_NOW_MS;
const lookups = [
// Never answers.
() => new Promise(() => {}),
// Answers only after the deadline: the late answer is ignored.
() =>
new Promise((resolve) =>
setTimeout(() => resolve([{ address: '127.0.0.1', family: 4 }]), 80),
),
];
const fetched = [];
const handler = createLocalReceiversHandler({
feedsValue: `1090=http://stalled.local/data/aircraft.json,978=${FEED_978}`,
now: () => clock,
logger: quietLogger(),
timeoutMs: 20,
lookupImpl: () => lookups.shift()(),
fetchImpl: async (url) => {
fetched.push(url);
return respond();
},
});
const within = (promise) =>
Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve('timed out'), 60)),
]);
const first = await within(call(handler));
assert.notEqual(first, 'timed out', 'the snapshot is not held by DNS');
assert.deepEqual(
first.json.feeds.map((feed) => feed.status),
['unreachable', 'live'],
);
clock += 2_000;
const second = await within(call(handler));
assert.notEqual(second, 'timed out', 'a later poll is not held either');
assert.deepEqual(
second.json.feeds.map((feed) => feed.status),
['unreachable', 'live'],
);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.ok(
fetched.every((url) => !url.includes('stalled.local')),
'a late DNS answer is never fetched',
);
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Shared 3D-model eligibility for the aircraft layers.
*
* Eligibility is by DISTANCE (the mode's add/keep band) with ON-SCREEN
* PRIORITY under the cap, in four visible-first passes so the slots are spent
* on what the operator can see:
* 1. KEEP on-screen contacts that already have a model (hysteresis for
* visible planes);
* 2. ADD on-screen new contacts inside the add radius, nearest first;
* 3. KEEP off-screen contacts that already have a model;
* 4. ADD off-screen new contacts inside the add radius with leftover slots.
* KEEP is split by frustum so an off-screen retained model can never starve
* an on-screen contact that wants one.
*
* Portable: no Cesium, DOM or layer state.
*/
/**
* @param {Array<[string, number, boolean]>} candidates `[id, distanceSq,
* onScreen]` for every contact inside the KEEP radius, sorted nearest first.
* @param {object} options
* @param {number} options.cap Maximum number of eligible contacts.
* @param {number} options.addDistSq Squared ADD radius (m²).
* @param {(id: string) => boolean} options.isModeled Whether the contact
* already has a model (live, not merely pending).
* @returns {Set<string>} Contacts that may hold a model this tick.
*/
export function selectModelEligible(candidates, { cap, addDistSq, isModeled }) {
const eligible = new Set();
const passes = [
(onScreen, modeled) => onScreen && modeled,
(onScreen, modeled, inAdd) => onScreen && inAdd,
(onScreen, modeled) => !onScreen && modeled,
(onScreen, modeled, inAdd) => !onScreen && inAdd,
];
for (const admit of passes) {
for (const [id, distanceSq, onScreen] of candidates) {
if (eligible.size >= cap) return eligible;
if (eligible.has(id)) continue;
if (admit(onScreen, isModeled(id), distanceSq <= addDistSq))
eligible.add(id);
}
}
return eligible;
}
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { selectModelEligible } from './modelEligibility.js';
const ADD_SQ = 100 * 100;
test('visible contacts win the cap before off-screen retained models', () => {
// Nearest first: an off-screen retained model, then two on-screen contacts.
const candidates = [
['off-kept', 10, false],
['on-new', 20, true],
['on-kept', 30, true],
];
const modeled = new Set(['off-kept', 'on-kept']);
const eligible = selectModelEligible(candidates, {
cap: 2,
addDistSq: ADD_SQ,
isModeled: (id) => modeled.has(id),
});
assert.deepEqual([...eligible], ['on-kept', 'on-new']);
});
test('retained models keep their slot beyond the add radius; new ones do not', () => {
const candidates = [
['near-new', 50, true],
['far-kept', ADD_SQ + 1, true],
['far-new', ADD_SQ + 2, true],
['off-new', 60, false],
];
const eligible = selectModelEligible(candidates, {
cap: 10,
addDistSq: ADD_SQ,
isModeled: (id) => id === 'far-kept',
});
assert.deepEqual([...eligible], ['far-kept', 'near-new', 'off-new']);
});
test('an empty cap admits nothing', () => {
assert.equal(
selectModelEligible([['a', 1, true]], {
cap: 0,
addDistSq: ADD_SQ,
isModeled: () => true,
}).size,
0,
);
});
+4 -2
View File
@@ -415,8 +415,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
`${name}: converted contacts are dropped BEFORE entering the capped candidate list`);
// ...and the cap really is applied to that list, so a dropped candidate is a freed slot.
assert.match(source, /modelEligible\.size >= cap/,
// ...and the cap really is applied to that list, so a dropped candidate is a
// freed slot. The four-pass selection itself is the shared
// selectModelEligible (behavior-tested in modelEligibility.test.mjs).
assert.match(source, /modelEligible\s*=\s*selectModelEligible\(\s*cand,\s*\{\s*cap,/,
`${name}: the cap bounds the candidate-derived eligible set`);
}
});
+1
View File
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
const READOUT_CONTEXT_LAYERS = new Set([
'military-installations',
'alpr-cameras',
'local-adsb',
]);
/**