Merge pull request #330 from bilawalsidhu/refactor/space-provider-modules

refactor: separate satellite and launch-feed providers
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-11 21:29:35 -07:00
commit 3ec3fe6822
13 files changed
+512 -249

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Split satellite and launch-feed server providers into focused modules with
portable request URL builders, preserving routes and cache/error behavior.
- Keep landmark names when geocoding returns only address components, preventing
the United States Capitol annotation from moving to a Washington hotel.
Unrelated outlines leave the valid geocoded marker in place.
+15
View File
@@ -117,3 +117,18 @@ this entry independently and rejects Node imports.
The browser's direct geocoding, annotation rendering and full Overpass query
service remain in their existing modules.
## Satellite and launch providers
`gods-eye-view/server/providers/space` is a Node-only entry for the CelesTrak
and Launch Library 2 middleware. Separate files under `server/providers/space/`
own each feed's acquisition, memory/disk cache and error handling. The existing
local composition mounts them in the same order. Importing the entry performs
no acquisition; factory calls create independent cache state.
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
Callers supply the group or end date and own validation, credentials, transport,
response limits and cache policy. The boundary gate checks this portable entry
separately from the Node providers. Satellite rendering and launch replay remain
in their existing browser modules.
+9
View File
@@ -10,6 +10,15 @@ requested name; otherwise the annotation stays at its geocoded point.
Genuine feature-name components and existing administrative/monument matching
retain their established behavior.
## Satellite and launch provider modules
`server/providers/space/` owns the CelesTrak TLE and Launch Library 2 Node
proxies. Their routes, six-hour/15-minute caches, disk storage, stale fallback,
request coalescing and optional LL2 server token retain existing behavior.
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.
## Build configuration and local provider boundaries
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
+5 -1
View File
@@ -77,6 +77,10 @@
"./server/providers/places": {
"node": "./server/providers/places.js"
},
"./sources/places": "./src/data/placeProviderPayloads.js"
"./sources/places": "./src/data/placeProviderPayloads.js",
"./server/providers/space": {
"node": "./server/providers/space.js"
},
"./sources/space": "./src/data/spaceProviderRequests.js"
}
}
+6 -1
View File
@@ -52,5 +52,10 @@
"server/providers/places/google.js",
"server/providers/places/routes.js",
"src/data/placeProviderPayloads.js",
"src/tooling/placeProviders.test.mjs"
"src/tooling/placeProviders.test.mjs",
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/space/launch-library.js",
"src/data/spaceProviderRequests.js",
"src/tooling/spaceProviders.test.mjs"
]
+17
View File
@@ -72,5 +72,22 @@
"exports": ["./sources/places"],
"modules": ["src/data/placeProviderPayloads.js"],
"external": []
},
"space-providers": {
"runtime": "node",
"exports": ["./server/providers/space"],
"modules": [
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/space/launch-library.js",
"server/providers/common/http.js",
"src/data/spaceProviderRequests.js"
],
"external": []
},
"space-requests": {
"exports": ["./sources/space"],
"modules": ["src/data/spaceProviderRequests.js"],
"external": []
}
}
+2 -245
View File
@@ -1,3 +1,5 @@
import { celestrakProxy, rocketLaunchesProxy } from './space.js';
export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
/**
* Local Node provider middleware for God's Eye View.
*
@@ -1138,251 +1140,6 @@ const REALTIME_DEBUG_LOG_DIR = path.join(__dirname, '.gev-logs');
const REALTIME_DEBUG_LOG_FILE = path.join(REALTIME_DEBUG_LOG_DIR, 'realtime-conversations.jsonl');
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
/**
* Vite plugin: CelesTrak TLE proxy.
*
* CelesTrak does not send CORS headers, so this middleware fetches
* satellite TLE data server-side and forwards it to the browser.
* Upstream URL: https://celestrak.org/NORAD/elements/gp.php
*
* @returns {import('vite').Plugin}
*/
/**
* CelesTrak GP/TLE proxy with a memory + disk cache.
* Upstream: https://celestrak.org/NORAD/elements/gp.php?GROUP=<group>&FORMAT=tle
* CelesTrak asks clients not to re-fetch GP data more than ~every 2 h and
* throttles offenders; every dev reload used to refetch every group. Cache TTL
* 6 h; on upstream failure the freshest stale copy is served (a stale TLE
* beats an empty satellites layer). Pattern mirrors openSkyProxy's
* cache+serve-stale. Adapted from skylight's TleStore (MIT).
*/
function celestrakProxy() {
const TLE_TTL_MS = 6 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`);
async function readDisk(group) {
try {
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at)) return parsed;
} catch { /* no disk cache yet */ }
return null;
}
async function writeDisk(group, entry) {
try {
await fsp.mkdir(CACHE_DIR, { recursive: true });
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
} catch (err) {
console.warn('[celestrak-proxy] cache write failed');
}
}
async function fetchUpstream(group) {
const url = new URL('https://celestrak.org/NORAD/elements/gp.php');
url.searchParams.set('GROUP', group);
url.searchParams.set('FORMAT', 'tle');
const res = await fetch(url.toString(), {
signal: AbortSignal.timeout(20000),
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
// descriptive User-Agent with a contact point.
headers: { 'User-Agent': 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.text();
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
return { at: Date.now(), body };
}
return {
name: 'celestrak-proxy',
configureServer(server) {
server.middlewares.use('/api/celestrak', async (req, res) => {
const group = String(req.url || '').replace(/^\//, '').split('?')[0];
if (!/^[a-z0-9-]+$/i.test(group)) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('invalid group');
return;
}
const send = (status, body, cacheStatus) => {
// Guard against a double-send (e.g. a throw AFTER a response already
// went out routing into the catch's send): writeHead after headersSent
// throws "Cannot set headers after they are sent".
if (res.headersSent) return;
res.writeHead(status, { 'Content-Type': 'text/plain', 'x-tle-cache': cacheStatus });
res.end(body);
};
try {
const now = Date.now();
let entry = mem.get(group);
if (!entry) {
entry = await readDisk(group);
if (entry) mem.set(group, entry);
}
if (entry && now - entry.at < TLE_TTL_MS) {
send(200, entry.body, 'HIT');
return;
}
// Stale or missing → refresh, single-flight per group.
if (!inflight.has(group)) {
inflight.set(group, fetchUpstream(group)
.then(async (fresh) => {
mem.set(group, fresh);
await writeDisk(group, fresh);
return fresh;
})
.catch((err) => {
console.warn('[celestrak-proxy] refresh failed — serving cache if any');
return null;
})
.finally(() => inflight.delete(group)));
}
const fresh = await inflight.get(group);
if (fresh) {
send(200, fresh.body, 'MISS');
} else if (entry) {
send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty
} else {
send(502, 'celestrak fetch failed and no cache available', 'NONE');
}
} catch (err) {
console.error('[celestrak-proxy] request failed');
send(500, 'celestrak proxy error', 'ERROR');
}
});
},
};
}
export const LL2_CACHE_TTL_MS = 15 * 60_000;
/** Build LL2 request headers without exposing its optional token client-side. */
export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
const normalized = String(token || '').trim();
return {
Accept: 'application/json',
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
};
}
/** Proxy the public Launch Library 2 recent-launch feed server-side. */
function rocketLaunchesProxy() {
const ttlMs = LL2_CACHE_TTL_MS;
const maxResponseBytes = 12 * 1024 * 1024;
const maxDiskCacheBytes = 24 * 1024 * 1024;
const cachePath = path.join(process.cwd(), '.gev-cache', 'launch-library-2-v2.3.json');
let cache = null;
let diskLoaded = false;
const inFlight = new Map();
async function loadDiskCache() {
if (diskLoaded) return;
diskLoaded = true;
try {
const stat = await fsp.stat(cachePath);
if (stat.size > maxDiskCacheBytes) throw new Error('cache file too large');
const parsed = JSON.parse(await fsp.readFile(cachePath, 'utf8'));
if (Number.isFinite(parsed?.at) && typeof parsed?.body === 'string') {
const body = JSON.parse(parsed.body);
if (Array.isArray(body?.results)) cache = parsed;
}
} catch { /* first run or invalid cache */ }
}
async function saveDiskCache(entry) {
try {
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
await fsp.writeFile(cachePath, JSON.stringify(entry), 'utf8');
} catch (error) {
console.warn('[launch-library-proxy] cache write failed');
}
}
function send(res, status, body, cacheState) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store',
'X-GEV-Cache': cacheState,
});
res.end(body);
}
async function refreshUpstream() {
const end = new Date();
const start = new Date(end.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('limit', '100');
url.searchParams.set('mode', 'detailed');
const upstream = await fetch(url, {
signal: AbortSignal.timeout(20000),
headers: launchLibraryRequestHeaders(),
});
const body = await readResponseTextCapped(upstream, maxResponseBytes);
if (!upstream.ok) {
const error = new Error(`upstream HTTP ${upstream.status}`);
error.upstreamStatus = upstream.status;
throw error;
}
const parsed = JSON.parse(body);
if (!Array.isArray(parsed?.results)) throw new Error('malformed upstream response');
const fresh = { at: Date.now(), body };
cache = fresh;
void saveDiskCache(fresh);
return fresh;
}
function install(middlewares) {
middlewares.use('/api/launches', async (req, res) => {
if (req.method !== 'GET') {
send(res, 405, JSON.stringify({ error: 'Method Not Allowed' }), 'NONE');
return;
}
await loadDiskCache();
const now = Date.now();
if (cache && now - cache.at < ttlMs) {
send(res, 200, cache.body, 'HIT');
return;
}
const stale = cache;
const request = coalesceProxyRequest(inFlight, 'recent-launches', refreshUpstream);
try {
const fresh = await request.promise;
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
} catch (error) {
// Log only a bounded status, never upstream bodies, URLs, or credentials.
const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502;
if (!request.shared) console.warn(`[launch-library-proxy] refresh failed (HTTP ${status})${stale ? ' — serving stale cache' : ''}`);
if (stale) {
send(res, 200, stale.body, 'STALE-ERROR');
return;
}
send(
res,
status,
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
'NONE',
);
}
});
}
return {
name: 'rocket-launches-proxy',
configureServer(server) {
install(server.middlewares);
},
configurePreviewServer(server) {
install(server.middlewares);
},
};
}
/**
* TomTom traffic-flow vector-tile proxy with a daily budget governor.
*
+7
View File
@@ -0,0 +1,7 @@
/** Satellite and launch-feed middleware for Node development servers. */
export { celestrakProxy } from './space/celestrak.js';
export {
rocketLaunchesProxy,
LL2_CACHE_TTL_MS,
launchLibraryRequestHeaders,
} from './space/launch-library.js';
+137
View File
@@ -0,0 +1,137 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
/**
* Vite plugin: CelesTrak TLE proxy.
*
* CelesTrak does not send CORS headers, so this middleware fetches
* satellite TLE data server-side and forwards it to the browser.
* Upstream URL: https://celestrak.org/NORAD/elements/gp.php
*
* @returns {import('vite').Plugin}
*/
/**
* CelesTrak GP/TLE proxy with a memory + disk cache.
* Upstream: https://celestrak.org/NORAD/elements/gp.php?GROUP=<group>&FORMAT=tle
* CelesTrak asks clients not to re-fetch GP data more than ~every 2 h and
* throttles offenders; every dev reload used to refetch every group. Cache TTL
* 6 h; on upstream failure the freshest stale copy is served (a stale TLE
* beats an empty satellites layer). Pattern mirrors openSkyProxy's
* cache+serve-stale. Adapted from skylight's TleStore (MIT).
*/
export function celestrakProxy() {
const TLE_TTL_MS = 6 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`);
async function readDisk(group) {
try {
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
return parsed;
} catch {
/* no disk cache yet */
}
return null;
}
async function writeDisk(group, entry) {
try {
await fsp.mkdir(CACHE_DIR, { recursive: true });
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
} catch (err) {
console.warn('[celestrak-proxy] cache write failed');
}
}
async function fetchUpstream(group) {
const url = celestrakTleUrl(group);
const res = await fetch(url.toString(), {
signal: AbortSignal.timeout(20000),
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
// descriptive User-Agent with a contact point.
headers: {
'User-Agent':
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.text();
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
return { at: Date.now(), body };
}
return {
name: 'celestrak-proxy',
configureServer(server) {
server.middlewares.use('/api/celestrak', async (req, res) => {
const group = String(req.url || '')
.replace(/^\//, '')
.split('?')[0];
if (!/^[a-z0-9-]+$/i.test(group)) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('invalid group');
return;
}
const send = (status, body, cacheStatus) => {
// Guard against a double-send (e.g. a throw AFTER a response already
// went out routing into the catch's send): writeHead after headersSent
// throws "Cannot set headers after they are sent".
if (res.headersSent) return;
res.writeHead(status, {
'Content-Type': 'text/plain',
'x-tle-cache': cacheStatus,
});
res.end(body);
};
try {
const now = Date.now();
let entry = mem.get(group);
if (!entry) {
entry = await readDisk(group);
if (entry) mem.set(group, entry);
}
if (entry && now - entry.at < TLE_TTL_MS) {
send(200, entry.body, 'HIT');
return;
}
// Stale or missing → refresh, single-flight per group.
if (!inflight.has(group)) {
inflight.set(
group,
fetchUpstream(group)
.then(async (fresh) => {
mem.set(group, fresh);
await writeDisk(group, fresh);
return fresh;
})
.catch((err) => {
console.warn(
'[celestrak-proxy] refresh failed — serving cache if any',
);
return null;
})
.finally(() => inflight.delete(group)),
);
}
const fresh = await inflight.get(group);
if (fresh) {
send(200, fresh.body, 'MISS');
} else if (entry) {
send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty
} else {
send(502, 'celestrak fetch failed and no cache available', 'NONE');
}
} catch (err) {
console.error('[celestrak-proxy] request failed');
send(500, 'celestrak proxy error', 'ERROR');
}
});
},
};
}
+144
View File
@@ -0,0 +1,144 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import {
readResponseTextCapped,
coalesceProxyRequest,
} from '../common/http.js';
import { launchLibraryRecentUrl } from '../../../src/data/spaceProviderRequests.js';
export const LL2_CACHE_TTL_MS = 15 * 60_000;
/** Build LL2 request headers without exposing its optional token client-side. */
export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
const normalized = String(token || '').trim();
return {
Accept: 'application/json',
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
};
}
/** Proxy the public Launch Library 2 recent-launch feed server-side. */
export function rocketLaunchesProxy() {
const ttlMs = LL2_CACHE_TTL_MS;
const maxResponseBytes = 12 * 1024 * 1024;
const maxDiskCacheBytes = 24 * 1024 * 1024;
const cachePath = path.join(
process.cwd(),
'.gev-cache',
'launch-library-2-v2.3.json',
);
let cache = null;
let diskLoaded = false;
const inFlight = new Map();
async function loadDiskCache() {
if (diskLoaded) return;
diskLoaded = true;
try {
const stat = await fsp.stat(cachePath);
if (stat.size > maxDiskCacheBytes)
throw new Error('cache file too large');
const parsed = JSON.parse(await fsp.readFile(cachePath, 'utf8'));
if (Number.isFinite(parsed?.at) && typeof parsed?.body === 'string') {
const body = JSON.parse(parsed.body);
if (Array.isArray(body?.results)) cache = parsed;
}
} catch {
/* first run or invalid cache */
}
}
async function saveDiskCache(entry) {
try {
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
await fsp.writeFile(cachePath, JSON.stringify(entry), 'utf8');
} catch (error) {
console.warn('[launch-library-proxy] cache write failed');
}
}
function send(res, status, body, cacheState) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store',
'X-GEV-Cache': cacheState,
});
res.end(body);
}
async function refreshUpstream() {
const end = new Date();
const url = launchLibraryRecentUrl(end);
const upstream = await fetch(url, {
signal: AbortSignal.timeout(20000),
headers: launchLibraryRequestHeaders(),
});
const body = await readResponseTextCapped(upstream, maxResponseBytes);
if (!upstream.ok) {
const error = new Error(`upstream HTTP ${upstream.status}`);
error.upstreamStatus = upstream.status;
throw error;
}
const parsed = JSON.parse(body);
if (!Array.isArray(parsed?.results))
throw new Error('malformed upstream response');
const fresh = { at: Date.now(), body };
cache = fresh;
void saveDiskCache(fresh);
return fresh;
}
function install(middlewares) {
middlewares.use('/api/launches', async (req, res) => {
if (req.method !== 'GET') {
send(res, 405, JSON.stringify({ error: 'Method Not Allowed' }), 'NONE');
return;
}
await loadDiskCache();
const now = Date.now();
if (cache && now - cache.at < ttlMs) {
send(res, 200, cache.body, 'HIT');
return;
}
const stale = cache;
const request = coalesceProxyRequest(
inFlight,
'recent-launches',
refreshUpstream,
);
try {
const fresh = await request.promise;
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
} catch (error) {
// Log only a bounded status, never upstream bodies, URLs, or credentials.
const status = Number.isInteger(error?.upstreamStatus)
? error.upstreamStatus
: 502;
if (!request.shared)
console.warn(
`[launch-library-proxy] refresh failed (HTTP ${status})${stale ? ' — serving stale cache' : ''}`,
);
if (stale) {
send(res, 200, stale.body, 'STALE-ERROR');
return;
}
send(
res,
status,
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
'NONE',
);
}
});
}
return {
name: 'rocket-launches-proxy',
configureServer(server) {
install(server.middlewares);
},
configurePreviewServer(server) {
install(server.middlewares);
},
};
}
+17
View File
@@ -0,0 +1,17 @@
/** Fixed upstream request URLs; callers own validation, credentials and transport. */
export function celestrakTleUrl(group) {
const url = new URL('https://celestrak.org/NORAD/elements/gp.php');
url.searchParams.set('GROUP', group);
url.searchParams.set('FORMAT', 'tle');
return url;
}
export function launchLibraryRecentUrl(end) {
const start = new Date(end.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('limit', '100');
url.searchParams.set('mode', 'detailed');
return url;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js']
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
.join('\n');
const detail = 'fixture-secret-token /internal/example <html>';
@@ -34,7 +34,7 @@ function fixture(name, overrides = {}, preview = false) {
resolveTerrainHeightRequest: async () => { throw new Error(detail); },
...overrides,
};
const helpers = ['readResponseTextCapped', 'coalesceProxyRequest', 'launchLibraryRequestHeaders'].map(extract).join('\n');
const helpers = ['readResponseTextCapped', 'coalesceProxyRequest', 'launchLibraryRequestHeaders', 'celestrakTleUrl', 'launchLibraryRecentUrl'].map(extract).join('\n');
const plugin = new Function(...Object.keys(deps), `${helpers}\n${extract(name)}\nreturn ${name}();`)(...Object.values(deps));
let middleware;
plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware = handler; } } });
+148
View File
@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import {
celestrakProxy,
rocketLaunchesProxy,
launchLibraryRequestHeaders,
LL2_CACHE_TTL_MS,
} from 'gods-eye-view/server/providers/space';
import {
celestrakTleUrl,
launchLibraryRecentUrl,
} from 'gods-eye-view/sources/space';
import * as compatibility from '../../server/providers/local.js';
function install(plugin, preview = false) {
const routes = new Map();
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
return async (route, url = '/', method = 'GET') => {
const res = {
headersSent: false,
writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true });
},
end(body) {
this.body = body;
},
};
await routes.get(route)({ url, method }, res);
return res;
};
}
function isolateDisk(t) {
t.mock.method(fsp, 'readFile', async () => {
throw Error('no cache');
});
t.mock.method(fsp, 'stat', async () => {
throw Error('no cache');
});
t.mock.method(fsp, 'mkdir', async () => {});
t.mock.method(fsp, 'writeFile', async () => {});
}
test('portable requests keep fixed origins, encode group data and preserve the 30-day UTC window', () => {
const tle = celestrakTleUrl('stations&FORMAT=json');
assert.equal(tle.origin, 'https://celestrak.org');
assert.equal(tle.pathname, '/NORAD/elements/gp.php');
assert.equal(tle.searchParams.get('GROUP'), 'stations&FORMAT=json');
assert.equal(tle.searchParams.get('FORMAT'), 'tle');
const end = new Date('2026-03-01T12:34:56.000Z');
const url = launchLibraryRecentUrl(end);
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',
limit: '100',
mode: 'detailed',
});
assert.equal(end.toISOString(), '2026-03-01T12:34:56.000Z');
});
test('compatibility exports retain the same LL2 header helper and TTL', () => {
assert.equal(
compatibility.launchLibraryRequestHeaders,
launchLibraryRequestHeaders,
);
assert.equal(compatibility.LL2_CACHE_TTL_MS, LL2_CACHE_TTL_MS);
});
test('exported CelesTrak plugin coalesces refreshes, retains stale TLEs, and reads disk in a new instance', async (t) => {
isolateDisk(t);
let now = Date.now();
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'warn', () => {});
const tle = 'ISS\n1 25544U fixture\n2 25544 fixture';
let calls = 0,
release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
calls++;
assert.equal(new URL(url).searchParams.get('GROUP'), 'stations');
await gate;
return new Response(tle);
});
const request = install(celestrakProxy());
assert.equal((await request('/api/celestrak', '/../bad')).status, 400);
assert.equal(calls, 0);
const first = request('/api/celestrak', '/stations');
const second = request('/api/celestrak', '/stations');
release();
for (const res of await Promise.all([first, second]))
assert.equal(res.body, tle);
assert.equal(calls, 1);
assert.equal(
(await request('/api/celestrak', '/stations')).headers['x-tle-cache'],
'HIT',
);
now += 6 * 3600_000;
t.mock.method(globalThis, 'fetch', async () => new Response('not a TLE'));
const stale = await request('/api/celestrak', '/stations');
assert.equal(stale.body, tle);
assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR');
t.mock.method(fsp, 'readFile', async () =>
JSON.stringify({ at: now, body: tle }),
);
t.mock.method(globalThis, 'fetch', async () => {
throw Error('fresh disk must prevent fetch');
});
const disk = await install(celestrakProxy())('/api/celestrak', '/stations');
assert.equal(disk.headers['x-tle-cache'], 'HIT');
assert.equal(disk.body, tle);
});
for (const preview of [false, true])
test(`exported launch plugin preserves optional server auth and cache in ${preview ? 'preview' : 'development'}`, async (t) => {
isolateDisk(t);
const prior = process.env.LL2_API_TOKEN;
t.after(() => {
if (prior === undefined) delete process.env.LL2_API_TOKEN;
else process.env.LL2_API_TOKEN = prior;
});
process.env.LL2_API_TOKEN = ' fixture-token ';
let calls = 0;
t.mock.method(globalThis, 'fetch', async (url, options) => {
calls++;
assert.equal(url.searchParams.get('limit'), '100');
assert.equal(options.headers.Authorization, 'Token fixture-token');
return Response.json({ results: [{ id: 'launch-fixture' }] });
});
const request = install(rocketLaunchesProxy(), preview);
assert.equal((await request('/api/launches', '/', 'POST')).status, 405);
const first = await request('/api/launches');
assert.equal(first.headers['X-GEV-Cache'], 'MISS');
assert.doesNotMatch(first.body, /fixture-token/);
const hit = await request('/api/launches');
assert.equal(hit.headers['X-GEV-Cache'], 'HIT');
assert.equal(hit.body, first.body);
assert.equal(calls, 1);
});