mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
style: format satellite and launch provider modules
This commit is contained in:
1 parent
9a4b98700e
commit
290afd808d
6 files changed
+163
-69
No files matched your search
@@ -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"
|
||||
]
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user