diff --git a/.env.example b/.env.example index a2f8afa24..5ddf78383 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/README.md b/README.md index c49c53ece..c3c3d7ef3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/changelog.d/1197-launch-library-provider-guidance.md b/changelog.d/1197-launch-library-provider-guidance.md new file mode 100644 index 000000000..34cbe299e --- /dev/null +++ b/changelog.d/1197-launch-library-provider-guidance.md @@ -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). diff --git a/changelog.d/1199-launch-cache-provenance.md b/changelog.d/1199-launch-cache-provenance.md new file mode 100644 index 000000000..04f33cb7f --- /dev/null +++ b/changelog.d/1199-launch-cache-provenance.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ca07db85f..a4796ef1e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 @@ -1490,6 +1494,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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d14e64442..72f67bb8f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 50825df03..7b56b6220 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.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}` } : {}), }; } diff --git a/src/data/regionalProxy.test.mjs b/src/data/regionalProxy.test.mjs index acc1b1a22..97891bef8 100644 --- a/src/data/regionalProxy.test.mjs +++ b/src/data/regionalProxy.test.mjs @@ -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', }); }); diff --git a/src/data/spaceProviderRequests.js b/src/data/spaceProviderRequests.js index 8e3030f97..e0f1707f4 100644 --- a/src/data/spaceProviderRequests.js +++ b/src/data/spaceProviderRequests.js @@ -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; diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b5b0a3abb..f93f42070 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 019415690..6a58ab901 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Суда в реальном времени по всему миру", diff --git a/src/keySetup.js b/src/keySetup.js index aab3d62b0..b67c9df66 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -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'); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index 30c695370..10bbe32a5 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -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', }), ]); diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index d55394bcc..b0dc035cb 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) { orbitMatches: layerState._orbitMatches, lastUpdate: layerState._lastUpdate, error: layerState._lastError, + stale: layerState._stale, }; }, diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index e7eab0565..bd1529c0b 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -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; diff --git a/src/layers/launches/ingestion.test.mjs b/src/layers/launches/ingestion.test.mjs new file mode 100644 index 000000000..c19cb40e4 --- /dev/null +++ b/src/layers/launches/ingestion.test.mjs @@ -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(); + } +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 58d70d34b..75b0276a3 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -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; diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 0f0194221..d70be5b54 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,8 @@ export function createState({ services }) { state._lastError = null; + state._stale = false; + state._orbitMatches = 0; state._clickHandler = null; diff --git a/src/tooling/ll2Integration.test.mjs b/src/tooling/ll2Integration.test.mjs new file mode 100644 index 000000000..30325eaab --- /dev/null +++ b/src/tooling/ll2Integration.test.mjs @@ -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)), + ]); + } + }); +} diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index 443ca5b96..a5b2cecba 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -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', });