mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #330 from bilawalsidhu/refactor/space-provider-modules
refactor: separate satellite and launch-feed providers
This commit is contained in:
13 files changed
+512
-249
No files matched your search
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
- Keep landmark names when geocoding returns only address components, preventing
|
||||||
the United States Capitol annotation from moving to a Washington hotel.
|
the United States Capitol annotation from moving to a Washington hotel.
|
||||||
Unrelated outlines leave the valid geocoded marker in place.
|
Unrelated outlines leave the valid geocoded marker in place.
|
||||||
|
|||||||
@@ -117,3 +117,18 @@ this entry independently and rejects Node imports.
|
|||||||
|
|
||||||
The browser's direct geocoding, annotation rendering and full Overpass query
|
The browser's direct geocoding, annotation rendering and full Overpass query
|
||||||
service remain in their existing modules.
|
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.
|
||||||
@@ -10,6 +10,15 @@ requested name; otherwise the annotation stays at its geocoded point.
|
|||||||
Genuine feature-name components and existing administrative/monument matching
|
Genuine feature-name components and existing administrative/monument matching
|
||||||
retain their established behavior.
|
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
|
## Build configuration and local provider boundaries
|
||||||
|
|
||||||
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
|
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
|
||||||
|
|||||||
+5
-1
@@ -77,6 +77,10 @@
|
|||||||
"./server/providers/places": {
|
"./server/providers/places": {
|
||||||
"node": "./server/providers/places.js"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,5 +52,10 @@
|
|||||||
"server/providers/places/google.js",
|
"server/providers/places/google.js",
|
||||||
"server/providers/places/routes.js",
|
"server/providers/places/routes.js",
|
||||||
"src/data/placeProviderPayloads.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"
|
||||||
]
|
]
|
||||||
@@ -72,5 +72,22 @@
|
|||||||
"exports": ["./sources/places"],
|
"exports": ["./sources/places"],
|
||||||
"modules": ["src/data/placeProviderPayloads.js"],
|
"modules": ["src/data/placeProviderPayloads.js"],
|
||||||
"external": []
|
"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
@@ -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.
|
* 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_FILE = path.join(REALTIME_DEBUG_LOG_DIR, 'realtime-conversations.jsonl');
|
||||||
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
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.
|
* TomTom traffic-flow vector-tile proxy with a daily budget governor.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
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'))
|
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
const detail = 'fixture-secret-token /internal/example <html>';
|
const detail = 'fixture-secret-token /internal/example <html>';
|
||||||
@@ -34,7 +34,7 @@ function fixture(name, overrides = {}, preview = false) {
|
|||||||
resolveTerrainHeightRequest: async () => { throw new Error(detail); },
|
resolveTerrainHeightRequest: async () => { throw new Error(detail); },
|
||||||
...overrides,
|
...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));
|
const plugin = new Function(...Object.keys(deps), `${helpers}\n${extract(name)}\nreturn ${name}();`)(...Object.values(deps));
|
||||||
let middleware;
|
let middleware;
|
||||||
plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware = handler; } } });
|
plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware = handler; } } });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user