refactor: split satellite and launch provider modules

This commit is contained in:
Sameh Khamis committed 2026-09-11 21:22:15 -07:00
1 parent bbabba89bf
commit 9a4b98700e
12 files changed
+417 -248

No files matched your search

+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.
*
+3
View File
@@ -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';
+121
View File
@@ -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=<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');
}
});
},
};
}
+124
View File
@@ -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);
},
};
}