mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat: name live feed routes and sources for what they serve
The flight, military and vessel feeds were named after the providers the bundled server uses (/api/opensky, /api/adsblol/mil, /api/ais-live and their track routes; createOpenSkySource, createAdsbLolSource, createAisStreamSource). Any server should be able to answer a feed from its own provider, so the feeds are now: /api/flights /api/flights/track?icao24= /api/military /api/military/track?hex= /api/vessels /api/vessels/track?mmsi= with createFlightSource, createMilitarySource and createVesselSource. The old routes are removed. Each server names its provider in X-Feed-Source (the bundled server sends OpenSky Network, adsb.lol and AISStream, so its attribution is unchanged) and the military feed's cache state and age in X-Feed-Cache and X-Feed-Age-Ms. Sources report the provider the server names and fall back to the feed's name.
This commit is contained in:
1 parent
5bf3beea84
commit
60644c5f5d
43 files changed
+564
-312
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createOpenSkySource,
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createFlightSource,
|
||||
createMilitarySource,
|
||||
createVesselSource,
|
||||
normalizeReadsbAircraft,
|
||||
normalizeVesselObservation,
|
||||
openSkySnapshot,
|
||||
@@ -133,7 +133,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
|
||||
const requests = [];
|
||||
const fetchImpl = async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
if (url.startsWith('/api/opensky?'))
|
||||
if (url.startsWith('/api/flights?'))
|
||||
return response(
|
||||
{ time: now / 1000, states: [aircraft] },
|
||||
{
|
||||
@@ -141,27 +141,27 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
|
||||
'x-flight-coverage': '250 nm regional',
|
||||
},
|
||||
);
|
||||
if (url.startsWith('/api/opensky-track'))
|
||||
if (url.startsWith('/api/flights/track'))
|
||||
return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
|
||||
if (url === '/api/adsblol/mil')
|
||||
if (url === '/api/military')
|
||||
return response(
|
||||
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] },
|
||||
{ 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' },
|
||||
{ 'x-feed-age-ms': '60000', 'x-feed-cache': 'STALE' },
|
||||
);
|
||||
return response({
|
||||
timestamp: now / 1000 - 60,
|
||||
trace: [[10, 30, -97, 1000]],
|
||||
});
|
||||
};
|
||||
const civil = createOpenSkySource({ fetchImpl, now: () => now });
|
||||
const military = createAdsbLolSource({ fetchImpl, now: () => now });
|
||||
const civil = createFlightSource({ fetchImpl, now: () => now });
|
||||
const military = createMilitarySource({ fetchImpl, now: () => now });
|
||||
assert.equal(requests.length, 0);
|
||||
const signal = new AbortController().signal;
|
||||
const snapshot = await civil.getSnapshot(
|
||||
{ latitude: 30.123456, longitude: -97 },
|
||||
{ signal },
|
||||
);
|
||||
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000');
|
||||
assert.equal(requests[0].url, '/api/flights?lat=30.1235&lon=-97.0000');
|
||||
assert.equal(requests[0].init.signal, signal);
|
||||
assert.equal(snapshot.source, 'adsb.lol');
|
||||
assert.equal(snapshot.coverage, '250 nm regional');
|
||||
@@ -180,7 +180,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
|
||||
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
|
||||
let release;
|
||||
const controller = new AbortController();
|
||||
const source = createOpenSkySource({
|
||||
const source = createFlightSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -199,7 +199,7 @@ test('cancellation after slow body parsing rejects even with a transport that ig
|
||||
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
|
||||
for (const status of [401, 403, 429, 502]) {
|
||||
let calls = 0;
|
||||
const source = createOpenSkySource({
|
||||
const source = createFlightSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response({ error: 'sensitive upstream detail' }, {}, status);
|
||||
@@ -216,12 +216,12 @@ test('denials and outages never start another source and do not echo arbitrary r
|
||||
});
|
||||
|
||||
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
|
||||
const source = createAisStreamSource({
|
||||
const source = createVesselSource({
|
||||
origin: () => 'http://example.test',
|
||||
fetchImpl: async (url) => {
|
||||
if (url.includes('/track?'))
|
||||
return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
|
||||
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500');
|
||||
assert.equal(url, 'http://example.test/api/vessels?maxRows=500');
|
||||
return response({
|
||||
status: 'reconnecting',
|
||||
refreshing: true,
|
||||
@@ -249,7 +249,7 @@ test('AIS reports limited received coverage and keeps connection state separate
|
||||
});
|
||||
|
||||
test('a malformed vessel row cannot prevent admission of valid positions', async () => {
|
||||
const source = createAisStreamSource({
|
||||
const source = createVesselSource({
|
||||
fetchImpl: async () =>
|
||||
response({
|
||||
status: 'live',
|
||||
@@ -273,7 +273,7 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
|
||||
});
|
||||
|
||||
test('classification identities include positionless aircraft without admitting them to rendering', async () => {
|
||||
const source = createAdsbLolSource({
|
||||
const source = createMilitarySource({
|
||||
fetchImpl: async () =>
|
||||
response({
|
||||
ac: [
|
||||
@@ -293,12 +293,12 @@ test('classification identities include positionless aircraft without admitting
|
||||
});
|
||||
|
||||
test('identity lookup validates its response and honors body-parse cancellation', async () => {
|
||||
const malformed = createAdsbLolSource({
|
||||
const malformed = createMilitarySource({
|
||||
fetchImpl: async () => response({ ac: [{}] }),
|
||||
});
|
||||
await assert.rejects(malformed.getIdentities(), /Malformed/);
|
||||
const abort = new AbortController();
|
||||
const source = createAdsbLolSource({
|
||||
const source = createMilitarySource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
||||
@@ -13,7 +13,7 @@ export {
|
||||
vesselSnapshot,
|
||||
} from './vessels.js';
|
||||
export {
|
||||
createOpenSkySource,
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createFlightSource,
|
||||
createMilitarySource,
|
||||
createVesselSource,
|
||||
} from './standalone.js';
|
||||
@@ -17,7 +17,7 @@ const defaultFetch = (...args) => globalThis.fetch(...args);
|
||||
const header = (response, name) => response.headers?.get?.(name);
|
||||
|
||||
function openSkyError(response) {
|
||||
const error = httpError(response, 'OpenSky');
|
||||
const error = httpError(response, 'Flights');
|
||||
const mode = String(
|
||||
header(response, 'x-opensky-auth-mode-used') ||
|
||||
header(response, 'x-opensky-auth') ||
|
||||
@@ -49,13 +49,24 @@ function openSkyError(response) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Existing same-origin aircraft routes; no request starts during construction. */
|
||||
export function createOpenSkySource({
|
||||
/**
|
||||
* The provider a feed response names in `X-Feed-Source`, so a server that
|
||||
* answers a feed route from another provider is reported as that provider.
|
||||
*/
|
||||
function feedSource(response, fallback) {
|
||||
const named = header(response, 'x-feed-source');
|
||||
return typeof named === 'string' && named.trim()
|
||||
? named.trim().slice(0, 80)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** The flights feed (/api/flights); no request starts during construction. */
|
||||
export function createFlightSource({
|
||||
fetchImpl = defaultFetch,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
label: 'OpenSky Network',
|
||||
label: 'Flights',
|
||||
async getSnapshot(query = {}, { signal } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
|
||||
@@ -64,14 +75,17 @@ export function createOpenSkySource({
|
||||
}
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
`/api/opensky${params.size ? '?' + params : ''}`,
|
||||
`/api/flights${params.size ? '?' + params : ''}`,
|
||||
{ signal },
|
||||
'OpenSky',
|
||||
'Flights',
|
||||
);
|
||||
if (!response.ok) throw openSkyError(response);
|
||||
return {
|
||||
...openSkySnapshot(payload, {
|
||||
source: header(response, 'x-flight-source') || 'OpenSky Network',
|
||||
// A fallback provider names itself in X-Flight-Source.
|
||||
source:
|
||||
header(response, 'x-flight-source') ||
|
||||
feedSource(response, 'Flights'),
|
||||
coverage:
|
||||
header(response, 'x-flight-coverage') ||
|
||||
'worldwide upstream snapshot',
|
||||
@@ -83,11 +97,11 @@ export function createOpenSkySource({
|
||||
async getTrack(reference, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/opensky-track?icao24=' + encodeURIComponent(reference),
|
||||
'/api/flights/track?icao24=' + encodeURIComponent(reference),
|
||||
{ signal },
|
||||
'OpenSky',
|
||||
'Flights',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'OpenSky');
|
||||
if (!response.ok) throw httpError(response, 'Flights');
|
||||
return {
|
||||
records: normalizeAircraftTrack(payload?.path),
|
||||
complete: false,
|
||||
@@ -108,36 +122,37 @@ export function createOpenSkySource({
|
||||
};
|
||||
}
|
||||
|
||||
export function createAdsbLolSource({
|
||||
export function createMilitarySource({
|
||||
fetchImpl = defaultFetch,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
label: 'adsb.lol',
|
||||
label: 'Military aircraft',
|
||||
async getIdentities(_query = {}, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/adsblol/mil',
|
||||
'/api/military',
|
||||
{ signal },
|
||||
'adsb.lol',
|
||||
'Military aircraft',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||
if (!response.ok) throw httpError(response, 'Military aircraft');
|
||||
return readsbIdentities(payload);
|
||||
},
|
||||
async getSnapshot(_query = {}, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/adsblol/mil',
|
||||
'/api/military',
|
||||
{ signal },
|
||||
'adsb.lol',
|
||||
'Military aircraft',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
|
||||
if (!response.ok) throw httpError(response, 'Military aircraft');
|
||||
const age = finite(header(response, 'x-feed-age-ms'));
|
||||
return {
|
||||
...readsbSnapshot(payload, {
|
||||
source: feedSource(response, 'Military aircraft'),
|
||||
observedAtMs: now() - (age != null && age > 0 ? age : 0),
|
||||
now: now(),
|
||||
stale: header(response, 'x-ads-b-cache') === 'STALE',
|
||||
stale: header(response, 'x-feed-cache') === 'STALE',
|
||||
}),
|
||||
status: response.status,
|
||||
};
|
||||
@@ -145,11 +160,11 @@ export function createAdsbLolSource({
|
||||
async getTrack(reference, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/adsblol/trace?hex=' + encodeURIComponent(reference),
|
||||
'/api/military/track?hex=' + encodeURIComponent(reference),
|
||||
{ signal },
|
||||
'adsb.lol',
|
||||
'Military aircraft',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||
if (!response.ok) throw httpError(response, 'Military aircraft');
|
||||
const baseTimeMs = epoch(payload?.timestamp, 1000);
|
||||
return {
|
||||
records:
|
||||
@@ -165,13 +180,13 @@ export function createAdsbLolSource({
|
||||
};
|
||||
}
|
||||
|
||||
export function createAisStreamSource({
|
||||
export function createVesselSource({
|
||||
fetchImpl = defaultFetch,
|
||||
apiUrl = '/api/ais-live',
|
||||
apiUrl = '/api/vessels',
|
||||
origin = () => globalThis.location?.origin || 'http://localhost',
|
||||
} = {}) {
|
||||
return {
|
||||
label: 'AISStream',
|
||||
label: 'Vessels',
|
||||
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
|
||||
const url = new URL(apiUrl, origin());
|
||||
url.searchParams.set('maxRows', String(maxRows));
|
||||
@@ -190,10 +205,10 @@ export function createAisStreamSource({
|
||||
fetchImpl,
|
||||
url.toString(),
|
||||
{ signal, cache: 'no-store' },
|
||||
'AIS live',
|
||||
'Vessels',
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = httpError(response, 'AIS live');
|
||||
const error = httpError(response, 'Vessels');
|
||||
const reasons = {
|
||||
'missing-key': 'AISSTREAM_API_KEY not set',
|
||||
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
|
||||
@@ -204,16 +219,19 @@ export function createAisStreamSource({
|
||||
error.message = reasons[payload?.status] || error.message;
|
||||
throw error;
|
||||
}
|
||||
return { ...vesselSnapshot(payload), status: response.status };
|
||||
return {
|
||||
...vesselSnapshot(payload, { source: feedSource(response, 'Vessels') }),
|
||||
status: response.status,
|
||||
};
|
||||
},
|
||||
async getTrack(reference, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference),
|
||||
'/api/vessels/track?mmsi=' + encodeURIComponent(reference),
|
||||
{ signal },
|
||||
'AIS live',
|
||||
'Vessels',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'AIS live');
|
||||
if (!response.ok) throw httpError(response, 'Vessels');
|
||||
return {
|
||||
records: normalizeVesselTrack(payload?.samples),
|
||||
complete: false,
|
||||
|
||||
Reference in new issue
Block a user