Merge branch 'main' into fix/terrain-same-site-gate-on-686

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-19 13:51:37 +01:00
commit d921a1b801
20 files changed
+621 -22

No files matched your search

+2 -2
View File
@@ -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)
+4 -2
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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",
+2
View File
@@ -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}` } : {}),
};
}
+6 -2
View File
@@ -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',
});
});
+4 -2
View File
@@ -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;
+2 -1
View File
@@ -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}",
+2 -1
View File
@@ -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
View File
@@ -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');
+7 -4
View File
@@ -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',
}),
]);
+1
View File
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
orbitMatches: layerState._orbitMatches,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
stale: layerState._stale,
};
},
+11 -3
View File
@@ -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;
+315
View File
@@ -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();
}
});
+2
View File
@@ -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;
+2
View File
@@ -19,6 +19,8 @@ export function createState({ services }) {
state._lastError = null;
state._stale = false;
state._orbitMatches = 0;
state._clickHandler = null;
+232
View File
@@ -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)),
]);
}
});
}
+2 -2
View File
@@ -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',
});