mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(space): align Launch Library requests with provider guidance
This commit is contained in:
1 parent
93ea088f45
commit
a310cbc2f0
13 files changed
+276
-18
No files matched your search
+2
-2
@@ -118,8 +118,8 @@ OPENSKY_AUTH_MODE=oauth
|
|||||||
OPENSKY_CLIENT_ID=
|
OPENSKY_CLIENT_ID=
|
||||||
OPENSKY_CLIENT_SECRET=
|
OPENSKY_CLIENT_SECRET=
|
||||||
|
|
||||||
# Optional: higher Launch Library 2 request allowance. Public access works
|
# Launch Library 2: leave blank for standard public access (no key needed).
|
||||||
# without a token.
|
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||||
LL2_API_TOKEN=
|
LL2_API_TOKEN=
|
||||||
|
|
||||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
# 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 | 🟢 |
|
| 🚲 **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) | 🟢 |
|
| 🧭 **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 | 🟡 |
|
| 🔥 **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 | 🟢 |
|
| 🎖️ **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 | 🟢 |
|
| 🌬️ **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 | 🟢 |
|
| 🌦️ **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 |
|
| | Key | Why | Get it |
|
||||||
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
| 🟡 | **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.
|
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
|
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||||
where each configured provider was found without printing credential values.
|
where each configured provider was found without printing credential values.
|
||||||
|
|||||||
@@ -1478,6 +1478,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
|
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||||
access. Callers retain validation, transport and response policy.
|
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
|
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
|
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
|
the catalog. A set whose two lines carry different catalog numbers, or a
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
"server/providers/space/launch-library.js",
|
"server/providers/space/launch-library.js",
|
||||||
"src/data/spaceProviderRequests.js",
|
"src/data/spaceProviderRequests.js",
|
||||||
"src/tooling/spaceProviders.test.mjs",
|
"src/tooling/spaceProviders.test.mjs",
|
||||||
|
"src/tooling/ll2Integration.test.mjs",
|
||||||
"server/providers/terrain.js",
|
"server/providers/terrain.js",
|
||||||
"server/providers/traffic.js",
|
"server/providers/traffic.js",
|
||||||
"server/providers/firms.js",
|
"server/providers/firms.js",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
|
|||||||
const normalized = String(token || '').trim();
|
const normalized = String(token || '').trim();
|
||||||
return {
|
return {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
'User-Agent':
|
||||||
|
'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||||
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
|
...(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', () => {
|
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
|
||||||
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
|
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
|
||||||
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
|
const headers = {
|
||||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
|
||||||
Accept: 'application/json',
|
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',
|
Authorization: 'Token secret',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export function celestrakTleUrl(group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function launchLibraryRecentUrl(end) {
|
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/');
|
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
|
||||||
url.searchParams.set('net__gte', start.toISOString());
|
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('limit', '100');
|
||||||
url.searchParams.set('mode', 'detailed');
|
url.searchParams.set('mode', 'detailed');
|
||||||
return url;
|
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)",
|
"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",
|
"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)",
|
"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ó",
|
"Scene action failed": "La acción de escena falló",
|
||||||
"Project exported": "Proyecto exportado",
|
"Project exported": "Proyecto exportado",
|
||||||
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
|
"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}. Перезапуск — страница перезагрузится сама.",
|
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
|
||||||
"More flight-polling credits (anonymous works without)": "Повышенный лимит запросов рейсов (анонимно работает и без ключа)",
|
"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).",
|
"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 + резерв: Просмотр улиц; отдельный ключ — по желанию",
|
"Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию",
|
||||||
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
|
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
|
||||||
"Live ships, worldwide": "Суда в реальном времени по всему миру",
|
"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) {
|
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||||
const mode = readStoredCloudVoiceAuthMode();
|
const mode = readStoredCloudVoiceAuthMode();
|
||||||
@@ -259,7 +259,9 @@ function buildRow(documentRef, key) {
|
|||||||
tier.title =
|
tier.title =
|
||||||
key.tier === 'metered'
|
key.tier === 'metered'
|
||||||
? t('Metered — a billing-enabled account')
|
? 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);
|
head.append(led, title, tier);
|
||||||
if (key.clientExposed) {
|
if (key.clientExposed) {
|
||||||
const exposed = documentRef.createElement('span');
|
const exposed = documentRef.createElement('span');
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export const KEY_SETUP_APPEND_HEADER =
|
|||||||
/**
|
/**
|
||||||
* Provider credentials, in display order — most magic per
|
* Provider credentials, in display order — most magic per
|
||||||
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
|
* 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
|
* `clientExposed` marks the keys that are injected into the browser
|
||||||
* bundle by design (restrict them at the provider, per SECURITY.md).
|
* bundle by design (restrict them at the provider, per SECURITY.md).
|
||||||
* `hidden` keeps advanced configuration out of the panel and missing-key count.
|
* `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({
|
Object.freeze({
|
||||||
id: 'launch-library',
|
id: 'launch-library',
|
||||||
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||||
unlocks: msg('Higher space-missions request allowance'),
|
unlocks: msg(
|
||||||
getUrl: 'https://thespacedevs.com',
|
'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']),
|
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||||
tier: 'free',
|
tier: 'paid',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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.origin, 'https://ll.thespacedevs.com');
|
||||||
assert.equal(url.pathname, '/2.3.0/launches/');
|
assert.equal(url.pathname, '/2.3.0/launches/');
|
||||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||||
net__gte: '2026-01-30T12:34:56.000Z',
|
net__gte: '2026-01-30T12:34:00.000Z',
|
||||||
net__lte: '2026-03-01T12:34:56.000Z',
|
net__lte: '2026-03-01T12:34:00.000Z',
|
||||||
limit: '100',
|
limit: '100',
|
||||||
mode: 'detailed',
|
mode: 'detailed',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user