diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4e45d2aab..aff90961f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/server/providers/space.js b/server/providers/space.js index e737d043c..bf2a2da4e 100644 --- a/server/providers/space.js +++ b/server/providers/space.js @@ -1,3 +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'; +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 index f1b82abed..2cc9cc1f8 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -31,8 +31,11 @@ export function celestrakProxy() { 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 */ } + if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at)) + return parsed; + } catch { + /* no disk cache yet */ + } return null; } @@ -51,7 +54,10 @@ export function celestrakProxy() { 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)' }, + 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(); @@ -64,7 +70,9 @@ export function celestrakProxy() { name: 'celestrak-proxy', configureServer(server) { server.middlewares.use('/api/celestrak', async (req, res) => { - const group = String(req.url || '').replace(/^\//, '').split('?')[0]; + 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'); @@ -75,7 +83,10 @@ export function celestrakProxy() { // 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.writeHead(status, { + 'Content-Type': 'text/plain', + 'x-tle-cache': cacheStatus, + }); res.end(body); }; try { @@ -91,17 +102,22 @@ export function celestrakProxy() { } // 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))); + 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) { diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 34649253e..50825df03 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -1,6 +1,9 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; -import { readResponseTextCapped, coalesceProxyRequest } from '../common/http.js'; +import { + readResponseTextCapped, + coalesceProxyRequest, +} from '../common/http.js'; import { launchLibraryRecentUrl } from '../../../src/data/spaceProviderRequests.js'; export const LL2_CACHE_TTL_MS = 15 * 60_000; @@ -19,7 +22,11 @@ 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'); + const cachePath = path.join( + process.cwd(), + '.gev-cache', + 'launch-library-2-v2.3.json', + ); let cache = null; let diskLoaded = false; const inFlight = new Map(); @@ -29,13 +36,16 @@ export function rocketLaunchesProxy() { diskLoaded = true; try { const stat = await fsp.stat(cachePath); - if (stat.size > maxDiskCacheBytes) throw new Error('cache file too large'); + 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 */ } + } catch { + /* first run or invalid cache */ + } } async function saveDiskCache(entry) { @@ -70,7 +80,8 @@ export function rocketLaunchesProxy() { throw error; } const parsed = JSON.parse(body); - if (!Array.isArray(parsed?.results)) throw new Error('malformed upstream response'); + if (!Array.isArray(parsed?.results)) + throw new Error('malformed upstream response'); const fresh = { at: Date.now(), body }; cache = fresh; void saveDiskCache(fresh); @@ -90,14 +101,23 @@ export function rocketLaunchesProxy() { return; } const stale = cache; - const request = coalesceProxyRequest(inFlight, 'recent-launches', refreshUpstream); + 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' : ''}`); + 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; diff --git a/src/data/spaceProviderRequests.js b/src/data/spaceProviderRequests.js index 3e6854d1d..8e3030f97 100644 --- a/src/data/spaceProviderRequests.js +++ b/src/data/spaceProviderRequests.js @@ -1,17 +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'); + 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'); + 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/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index 47dcf3d6f..6ccd8d57c 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -1,22 +1,48 @@ 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 { + 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); } } }); + 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; } }; + 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, '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 () => {}); } @@ -31,12 +57,20 @@ test('portable requests keep fixed origins, encode group data and preserve the 3 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.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.launchLibraryRequestHeaders, + launchLibraryRequestHeaders, + ); assert.equal(compatibility.LL2_CACHE_TTL_MS, LL2_CACHE_TTL_MS); }); @@ -46,8 +80,11 @@ test('exported CelesTrak plugin coalesces refreshes, retains stale TLEs, and rea 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; }); + 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'); @@ -60,40 +97,52 @@ test('exported CelesTrak plugin coalesces refreshes, retains stale TLEs, and rea 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); + 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'); + 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'); }); + 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' }] }); +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); }); - 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); -});