mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into upstream/cctv-tarktee-refresh
This commit is contained in:
20 files changed
+621
-22
No files matched your search
+2
-2
@@ -118,8 +118,8 @@ OPENSKY_AUTH_MODE=oauth
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# Optional: higher Launch Library 2 request allowance. Public access works
|
||||
# without a token.
|
||||
# Launch Library 2: leave blank for standard public access (no key needed).
|
||||
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||
LL2_API_TOKEN=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
|
||||
@@ -385,7 +385,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
@@ -527,9 +527,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
|
||||
| | Key | Why | Get it |
|
||||
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
|
||||
|
||||
Add these if you need higher polling allowances.
|
||||
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
|
||||
paid Patreon upgrades for higher limits; there is no free API-key signup.
|
||||
|
||||
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||
where each configured provider was found without printing credential values.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- Share Launch Library request cache keys across installations refreshing in the
|
||||
same UTC minute, identify the app to the provider, and explain that standard
|
||||
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
|
||||
upgrades; existing local cache and server-only token handling are unchanged
|
||||
(rudycelekli, #1197).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
|
||||
launch data, and return to NOMINAL after a fresh response without discarding
|
||||
retained missions (rudycelekli, #1199).
|
||||
+13
-1
@@ -812,7 +812,11 @@ position stay off the map. Genuine zero coordinates remain valid. Undisclosed
|
||||
stage coordinates, downrange distance and flight numbers remain unknown; the
|
||||
mission panel omits the flight-number label when no number was reported.
|
||||
Pending mission requests abort on disable/destruction and malformed launch
|
||||
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
|
||||
snapshots preserve the last accepted display. A Launch Library stale-cache fallback
|
||||
retains its missions but reports STALE in Data Layers; a fresh snapshot clears that
|
||||
provenance. Hiding retained missions does not relabel them as fresh, and teardown
|
||||
clears the instance state. Injected getLaunches-only sources remain supported.
|
||||
Catalog groups, propagation cadence,
|
||||
tracking intent, replay timing, controls and source attribution remain unchanged.
|
||||
|
||||
The fire layer now exposes `./layers/firms`: an instance factory with separate
|
||||
@@ -1478,6 +1482,14 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
|
||||
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||
access. Callers retain validation, transport and response policy.
|
||||
|
||||
LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day
|
||||
window, so installations refreshing in the same minute share an upstream cache
|
||||
key. The upper bound trails the current time by less than one minute. Requests
|
||||
identify the app and repository through User-Agent; the existing 15-minute local
|
||||
cache, stale fallback and optional server-only Authorization remain unchanged.
|
||||
Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades:
|
||||
standard public access needs no key, and its field can remain blank.
|
||||
|
||||
The shared TLE text parser skips incomplete named sets and resumes at the next
|
||||
complete set. A missing orbital line does not discard the later satellites in
|
||||
the catalog. A set whose two lines carry different catalog numbers, or a
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"server/providers/space/launch-library.js",
|
||||
"src/data/spaceProviderRequests.js",
|
||||
"src/tooling/spaceProviders.test.mjs",
|
||||
"src/tooling/ll2Integration.test.mjs",
|
||||
"server/providers/terrain.js",
|
||||
"server/providers/traffic.js",
|
||||
"server/providers/firms.js",
|
||||
@@ -446,6 +447,7 @@
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
|
||||
@@ -13,6 +13,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
|
||||
const normalized = String(token || '').trim();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'User-Agent':
|
||||
'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () =
|
||||
|
||||
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
|
||||
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
};
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), headers);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
...headers,
|
||||
Authorization: 'Token secret',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,12 @@ export function celestrakTleUrl(group) {
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
const start = new Date(end.getTime() - 30 * 86400000);
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
const start = new Date(roundedEnd.getTime() - 30 * 86400000);
|
||||
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
|
||||
url.searchParams.set('net__gte', start.toISOString());
|
||||
url.searchParams.set('net__lte', end.toISOString());
|
||||
url.searchParams.set('net__lte', roundedEnd.toISOString());
|
||||
url.searchParams.set('limit', '100');
|
||||
url.searchParams.set('mode', 'detailed');
|
||||
return url;
|
||||
|
||||
@@ -842,7 +842,8 @@
|
||||
"Real live traffic (keyless runs a simulation)": "Tráfico real en vivo (sin clave se usa una simulación)",
|
||||
"Bing imagery map stacks + world terrain": "Imágenes de Bing + terreno mundial",
|
||||
"More flight-polling credits (anonymous works without)": "Más créditos de consulta de vuelos (sin clave funciona el modo anónimo)",
|
||||
"Higher space-missions request allowance": "Mayor límite de peticiones para misiones espaciales",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "El acceso público a misiones espaciales no necesita clave. Déjalo en blanco para el uso estándar; los tokens de pago solo aumentan los límites de solicitudes.",
|
||||
"Optional paid token — public access works without one": "Token opcional de pago — el acceso público funciona sin él",
|
||||
"Scene action failed": "La acción de escena falló",
|
||||
"Project exported": "Proyecto exportado",
|
||||
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
|
||||
"More flight-polling credits (anonymous works without)": "Повышенный лимит запросов рейсов (анонимно работает и без ключа)",
|
||||
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token).",
|
||||
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Общедоступные данные космических миссий не требуют ключа. Для обычного использования оставьте поле пустым; платные токены только повышают лимиты запросов.",
|
||||
"Optional paid token — public access works without one": "Необязательный платный токен — публичный доступ работает без него",
|
||||
"Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию",
|
||||
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
|
||||
"Live ships, worldwide": "Суда в реальном времени по всему миру",
|
||||
|
||||
+4
-2
@@ -215,7 +215,7 @@ export function stripKeylessBasemapFromHash(hash) {
|
||||
}
|
||||
}
|
||||
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
|
||||
|
||||
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const mode = readStoredCloudVoiceAuthMode();
|
||||
@@ -259,7 +259,9 @@ function buildRow(documentRef, key) {
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? t('Metered — a billing-enabled account')
|
||||
: t('Free key — register, paste, done');
|
||||
: key.tier === 'paid'
|
||||
? t('Optional paid token — public access works without one')
|
||||
: t('Free key — register, paste, done');
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
|
||||
@@ -29,7 +29,8 @@ export const KEY_SETUP_APPEND_HEADER =
|
||||
/**
|
||||
* Provider credentials, in display order — most magic per
|
||||
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
|
||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
|
||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key, and
|
||||
* 'paid' (🔴) is an optional paid higher-rate token.
|
||||
* `clientExposed` marks the keys that are injected into the browser
|
||||
* bundle by design (restrict them at the provider, per SECURITY.md).
|
||||
* `hidden` keeps advanced configuration out of the panel and missing-key count.
|
||||
@@ -119,10 +120,12 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||
unlocks: msg('Higher space-missions request allowance'),
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
unlocks: msg(
|
||||
'Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.',
|
||||
),
|
||||
getUrl: 'https://thespacedevs.com/supportus',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
tier: 'free',
|
||||
tier: 'paid',
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
Vendored
+1
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
orbitMatches: layerState._orbitMatches,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
stale: layerState._stale,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -168,9 +168,16 @@ export function createIngestion({
|
||||
async function performMissionUpdate(token) {
|
||||
try {
|
||||
ensureActiveTleLookup(token);
|
||||
const payload = await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
});
|
||||
const { payload, stale } = source.getLaunchSnapshot
|
||||
? await source.getLaunchSnapshot({
|
||||
signal: layerState._sourceController.signal,
|
||||
})
|
||||
: {
|
||||
payload: await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
}),
|
||||
stale: false,
|
||||
};
|
||||
const launches = parts.model.normalizeRocketLaunches(payload);
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
@@ -214,6 +221,7 @@ export function createIngestion({
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function fixture({ legacySource = false } = {}) {
|
||||
const launch = {
|
||||
id: 'owned-launch',
|
||||
name: 'Owned mission fixture',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
const mode = { cache: 'HIT', results: [launch], hold: false };
|
||||
let arrival;
|
||||
let release;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/celestrak/active') {
|
||||
res.end('');
|
||||
return;
|
||||
}
|
||||
assert.equal(req.url, '/api/launches');
|
||||
const send = () => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GEV-Cache': mode.cache,
|
||||
});
|
||||
res.end(JSON.stringify({ results: mode.results }));
|
||||
};
|
||||
if (mode.hold) {
|
||||
release = send;
|
||||
arrival?.();
|
||||
} else send();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const launchSource = createLaunchSource({
|
||||
fetchImpl: (url, options) => {
|
||||
assert.ok(['/api/launches', '/api/celestrak/active'].includes(url));
|
||||
return fetch(new URL(url, origin), options);
|
||||
},
|
||||
});
|
||||
const source = legacySource
|
||||
? {
|
||||
getLaunches: launchSource.getLaunches,
|
||||
getActiveTle: launchSource.getActiveTle,
|
||||
}
|
||||
: launchSource;
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
state._enabled = true;
|
||||
state._lifecycleToken = 1;
|
||||
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
});
|
||||
const viewer = {
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
await viewer.dataSources.add(state._dataSource);
|
||||
// Only presentation boundaries are inert. Source, normalization, state,
|
||||
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
|
||||
const parts = {
|
||||
panel: {
|
||||
createMissionPanel: noop,
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
overlays: {
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const current = () => {
|
||||
const stats = parts.controls.methods.getStats();
|
||||
return {
|
||||
stats,
|
||||
feed: layerFeedState(stats),
|
||||
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
|
||||
enabled: state._enabled,
|
||||
};
|
||||
};
|
||||
return {
|
||||
mode,
|
||||
state,
|
||||
source,
|
||||
current,
|
||||
viewer,
|
||||
init: () => parts.lifecycle.methods.init(viewer),
|
||||
update: () => parts.ingestion.methods.update(),
|
||||
disable: () => parts.lifecycle.methods.disable(),
|
||||
destroy: () => parts.lifecycle.methods.destroy(viewer),
|
||||
holdArrival: () =>
|
||||
new Promise((resolve) => {
|
||||
arrival = resolve;
|
||||
}),
|
||||
release: () => release?.(),
|
||||
async close() {
|
||||
await parts.lifecycle.methods.destroy(viewer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fresh launches remain nominal through ingestion and the layer stats', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.ok(value.stats.lastUpdate);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
assert.equal((await f.source.getLaunchSnapshot()).stale, true);
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a fresh recovery clears stale launch provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
const stale = f.current();
|
||||
f.mode.cache = 'MISS';
|
||||
await f.update();
|
||||
const recovered = f.current();
|
||||
assert.equal(recovered.feed, 'nominal');
|
||||
assert.equal(recovered.stats.count, 1);
|
||||
assert.equal(recovered.stats.error, null);
|
||||
assert.equal(stale.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty stale snapshot still identifies its fallback provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.results = [];
|
||||
await f.update();
|
||||
assert.equal(f.current().stats.count, 0);
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('getLaunches-only injected sources remain supported', async () => {
|
||||
const f = await fixture({ legacySource: true });
|
||||
try {
|
||||
await f.update();
|
||||
assert.equal(f.current().feed, 'nominal');
|
||||
assert.deepEqual(f.current().rendered, ['owned-launch']);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable cancels a pending stale response without committing it', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const healthy = f.current();
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.hold = true;
|
||||
const arrived = f.holdArrival();
|
||||
const pending = f.update();
|
||||
await arrived;
|
||||
await f.disable();
|
||||
f.release();
|
||||
await pending;
|
||||
const value = f.current();
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(f.state._sourceController.signal.aborted, true);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('hiding retained stale records does not relabel them as fresh', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.disable();
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
assert.equal(f.current().stats.count, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy clears the previous feed state and records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.enabled, false);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy followed by actual init starts a clean instance with owned handlers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
await f.init();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(f.viewer.dataSources.length, 1);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
|
||||
const click = f.state._clickHandler;
|
||||
const move = f.state._moveHandler;
|
||||
assert.ok(click instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.ok(move instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.equal(
|
||||
typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK),
|
||||
'function',
|
||||
);
|
||||
await f.destroy();
|
||||
assert.equal(click.isDestroyed(), true);
|
||||
assert.equal(move.isDestroyed(), true);
|
||||
assert.equal(f.viewer.dataSources.length, 0);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export function createLifecycle({
|
||||
viewer.dataSources.add(layerState._dataSource);
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._orbitMatches = 0;
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
@@ -160,6 +161,7 @@ export function createLifecycle({
|
||||
layerState._count = 0;
|
||||
layerState._orbitMatches = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._activeTleText = null;
|
||||
layerState._activeTlePromise = null;
|
||||
layerState._activeTlePromiseToken = 0;
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import { launchLibraryRecentUrl } from '../data/spaceProviderRequests.js';
|
||||
import {
|
||||
launchLibraryRequestHeaders,
|
||||
rocketLaunchesProxy,
|
||||
} from '../../server/providers/space/launch-library.js';
|
||||
import { keySetupStatus } from '../keySetupCore.mjs';
|
||||
import { initKeySetup } from '../keySetup.js';
|
||||
|
||||
test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => {
|
||||
const first = new Date('2026-10-17T16:20:00.001Z');
|
||||
const last = new Date('2026-10-17T16:20:59.999Z');
|
||||
assert.equal(
|
||||
launchLibraryRecentUrl(first).href,
|
||||
launchLibraryRecentUrl(last).href,
|
||||
);
|
||||
assert.equal(first.toISOString(), '2026-10-17T16:20:00.001Z');
|
||||
assert.equal(last.toISOString(), '2026-10-17T16:20:59.999Z');
|
||||
});
|
||||
|
||||
for (const [date, end] of [
|
||||
['2026-10-17T16:20:00.001Z', '2026-10-17T16:20:00.000Z'],
|
||||
['2026-10-17T16:20:59.999Z', '2026-10-17T16:20:00.000Z'],
|
||||
['2026-10-17T16:21:00.000Z', '2026-10-17T16:21:00.000Z'],
|
||||
['2026-01-01T00:00:00.001Z', '2026-01-01T00:00:00.000Z'],
|
||||
]) {
|
||||
test(`LL2 bounds retain the 30-day UTC window at ${date}`, () => {
|
||||
const url = launchLibraryRecentUrl(new Date(date));
|
||||
assert.equal(url.origin, 'https://ll.thespacedevs.com');
|
||||
assert.equal(url.pathname, '/2.3.0/launches/');
|
||||
assert.equal(url.searchParams.get('net__lte'), end);
|
||||
assert.equal(
|
||||
Date.parse(end) - Date.parse(url.searchParams.get('net__gte')),
|
||||
30 * 86400000,
|
||||
);
|
||||
assert.equal(url.searchParams.get('limit'), '100');
|
||||
assert.equal(url.searchParams.get('mode'), 'detailed');
|
||||
});
|
||||
}
|
||||
|
||||
test('LL2 headers identify the app and preserve optional server-only authorization', () => {
|
||||
const publicHeaders = launchLibraryRequestHeaders('');
|
||||
assert.match(publicHeaders['User-Agent'], /^gods-eye-view.*\/1\.0 /);
|
||||
assert.ok(
|
||||
publicHeaders['User-Agent'].includes(
|
||||
'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
),
|
||||
);
|
||||
assert.equal(publicHeaders.Accept, 'application/json');
|
||||
assert.equal(publicHeaders.Authorization, undefined);
|
||||
const paidHeaders = launchLibraryRequestHeaders(' owned-fixture-token ');
|
||||
assert.equal(paidHeaders.Authorization, 'Token owned-fixture-token');
|
||||
assert.equal(paidHeaders['User-Agent'], publicHeaders['User-Agent']);
|
||||
});
|
||||
|
||||
test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => {
|
||||
const created = [];
|
||||
const node = () => {
|
||||
const value = {
|
||||
dataset: {},
|
||||
children: [],
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute() {},
|
||||
remove() {},
|
||||
};
|
||||
created.push(value);
|
||||
return value;
|
||||
};
|
||||
const rows = node();
|
||||
const root = node();
|
||||
root.querySelector = (selector) =>
|
||||
selector === '[data-key-setup-rows]' ? rows : null;
|
||||
const chip = node();
|
||||
const status = keySetupStatus({});
|
||||
status.keys = status.keys.filter((key) =>
|
||||
['launch-library', 'mapillary'].includes(key.id),
|
||||
);
|
||||
const setup = await initKeySetup({
|
||||
documentRef: {
|
||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||
createElement: node,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
fetchImpl: async () => ({ ok: true, json: async () => status }),
|
||||
});
|
||||
assert.ok(setup);
|
||||
try {
|
||||
const ll2 = rows.children.find(
|
||||
(row) => row.dataset.keyId === 'launch-library',
|
||||
);
|
||||
const free = rows.children.find((row) => row.dataset.keyId === 'mapillary');
|
||||
const tier = (row) =>
|
||||
row.children[0].children.find(
|
||||
(child) => child.className === 'key-setup-tier',
|
||||
);
|
||||
assert.match(tier(ll2).title, /optional paid token/i);
|
||||
assert.match(ll2.children[1].textContent, /leave blank/i);
|
||||
assert.match(ll2.children[1].textContent, /no key/i);
|
||||
assert.equal(tier(free).title, 'Free key — register, paste, done');
|
||||
const serialized = JSON.stringify(status);
|
||||
assert.ok(!serialized.includes('owned-fixture-token'));
|
||||
} finally {
|
||||
setup.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('real LL2 proxy sends cacheable headers and bounds, retaining its memory cache', async (t) => {
|
||||
for (const method of ['readFile', 'stat'])
|
||||
t.mock.method(fsp, method, async () => {
|
||||
throw new Error('owned fixture has no disk cache');
|
||||
});
|
||||
t.mock.method(fsp, 'mkdir', async () => {});
|
||||
t.mock.method(fsp, 'writeFile', async () => {});
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
calls.push({ url: new URL(url), options });
|
||||
return new Response(
|
||||
JSON.stringify({ results: [{ id: 'owned-launch-fixture' }] }),
|
||||
);
|
||||
});
|
||||
let handler;
|
||||
rocketLaunchesProxy().configureServer({
|
||||
middlewares: {
|
||||
use(route, value) {
|
||||
assert.equal(route, '/api/launches');
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
async function request() {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ method: 'GET' }, res);
|
||||
return res;
|
||||
}
|
||||
const first = await request();
|
||||
const second = await request();
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.body, first.body);
|
||||
assert.equal(second.headers['X-GEV-Cache'], 'HIT');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].options.headers['User-Agent'], /gods-eye-view/);
|
||||
assert.match(calls[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
|
||||
assert.match(calls[0].url.searchParams.get('net__gte'), /:00\.000Z$/);
|
||||
});
|
||||
|
||||
for (const preview of [false, true]) {
|
||||
test(`LL2 proxy sends its identifying headers over real owned HTTP in ${preview ? 'preview' : 'development'}`, async (t) => {
|
||||
for (const method of ['readFile', 'stat'])
|
||||
t.mock.method(fsp, method, async () => {
|
||||
throw new Error('no owned cache');
|
||||
});
|
||||
t.mock.method(fsp, 'mkdir', async () => {});
|
||||
t.mock.method(fsp, 'writeFile', async () => {});
|
||||
const received = [];
|
||||
const upstream = http.createServer((req, res) => {
|
||||
received.push({
|
||||
url: new URL(req.url, 'http://fixture.invalid'),
|
||||
headers: req.headers,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: [{ id: 'owned-wire-fixture' }] }));
|
||||
});
|
||||
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.method(globalThis, 'fetch', (url, options) => {
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.origin, 'https://ll.thespacedevs.com');
|
||||
return originalFetch(
|
||||
`http://127.0.0.1:${upstream.address().port}${parsed.pathname}${parsed.search}`,
|
||||
options,
|
||||
);
|
||||
});
|
||||
let handler;
|
||||
const plugin = rocketLaunchesProxy();
|
||||
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
|
||||
middlewares: {
|
||||
use(_route, value) {
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
const proxy = http.createServer((req, res) => {
|
||||
void handler(req, res);
|
||||
});
|
||||
await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const result = await originalFetch(
|
||||
`http://127.0.0.1:${proxy.address().port}/api/launches`,
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.deepEqual(await result.json(), {
|
||||
results: [{ id: 'owned-wire-fixture' }],
|
||||
});
|
||||
assert.equal(received.length, 1);
|
||||
assert.match(
|
||||
received[0].headers['user-agent'],
|
||||
/^gods-eye-view-launch-library-proxy\/1\.0 /,
|
||||
);
|
||||
assert.equal(received[0].headers.authorization, undefined);
|
||||
assert.match(received[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
|
||||
} finally {
|
||||
proxy.closeAllConnections();
|
||||
upstream.closeAllConnections();
|
||||
await Promise.all([
|
||||
new Promise((resolve) => proxy.close(resolve)),
|
||||
new Promise((resolve) => upstream.close(resolve)),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -81,8 +81,8 @@ test('portable requests keep fixed origins, encode group data and preserve the 3
|
||||
assert.equal(url.origin, 'https://ll.thespacedevs.com');
|
||||
assert.equal(url.pathname, '/2.3.0/launches/');
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||
net__gte: '2026-01-30T12:34:56.000Z',
|
||||
net__lte: '2026-03-01T12:34:56.000Z',
|
||||
net__gte: '2026-01-30T12:34:00.000Z',
|
||||
net__lte: '2026-03-01T12:34:00.000Z',
|
||||
limit: '100',
|
||||
mode: 'detailed',
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user