diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2c3c35b..ceb2c887c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 3297c2e5d..bf7525e4e 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3233be2b3..14bc6c4ec 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/package.json b/package.json index 89a30f4b3..407c374d6 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fefec7ba5..1b93c61e4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] } } diff --git a/server/providers/local.js b/server/providers/local.js index 89640864a..81f9b3fdb 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -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=&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. * diff --git a/server/providers/space.js b/server/providers/space.js new file mode 100644 index 000000000..e737d043c --- /dev/null +++ b/server/providers/space.js @@ -0,0 +1,3 @@ +/** 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'; diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js new file mode 100644 index 000000000..f1b82abed --- /dev/null +++ b/server/providers/space/celestrak.js @@ -0,0 +1,121 @@ +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=&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'); + } + }); + }, + }; +} diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js new file mode 100644 index 000000000..34649253e --- /dev/null +++ b/server/providers/space/launch-library.js @@ -0,0 +1,124 @@ +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); + }, + }; +} diff --git a/src/data/spaceProviderRequests.js b/src/data/spaceProviderRequests.js new file mode 100644 index 000000000..3e6854d1d --- /dev/null +++ b/src/data/spaceProviderRequests.js @@ -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; +} diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 6372f8366..31760460f 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -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 '; @@ -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; } } }); diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs new file mode 100644 index 000000000..47dcf3d6f --- /dev/null +++ b/src/tooling/spaceProviders.test.mjs @@ -0,0 +1,99 @@ +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); +});