Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page)
CI / Lint, typecheck, test, build (pull_request) Failing after 10s

- packages/db: postgres.js + ioredis wrapper, StateStore interface with
  InMemory + Postgres implementations, schema migration runner
- apps/api: refactored to use @kerbal-rt/db; live WebSocket hub subscribes
  to store changes and broadcasts to all clients
- apps/tools/mock-telemetry: Node script that generates realistic KSP
  state and POSTs to /api/v1/ingest at 1Hz (uses same keplerian math
  as the live-map renderer)
- apps/hub: new /debug page that connects to /api/v1/live and shows
  the live state
- ksp/README.md: documents Phase 1c (real kRPC bridge) and the two
  implementation options
- Tests: 17 total (5 kepler math, 5 db store, 5 API health/state,
  2 API WebSocket fan-out)

End-to-end verified: mock publisher → API → hub /debug page,
11 snapshots/5s over WebSocket, vessels advancing in mean anomaly.
This commit is contained in:
Mavis
2026-06-02 18:54:46 +00:00
parent 938ba042b3
commit a457b9d96f
28 changed files with 2165 additions and 98 deletions
+1
View File
@@ -17,6 +17,7 @@
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.1.0",
"@hono/zod-validator": "^0.4.0",
"@kerbal-rt/db": "workspace:*",
"@kerbal-rt/shared-types": "workspace:*",
"@kerbal-rt/orbital-math": "workspace:*",
"hono": "^4.6.0",
+38 -18
View File
@@ -7,10 +7,17 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { UniverseSnapshotSchema } from '@kerbal-rt/shared-types';
import { z } from 'zod';
import { state } from './state.js';
import type { StateStore } from '@kerbal-rt/db';
export function buildApp() {
export interface AppDeps {
store: StateStore;
/** The expected ingest API key, or null to disable auth (dev only). */
ingestApiKey: string | null;
}
export function buildApp(deps: AppDeps) {
const app = new Hono();
const { store, ingestApiKey } = deps;
app.use('*', logger());
app.use('*', cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? '*' }));
@@ -21,6 +28,7 @@ export function buildApp() {
ok: true,
service: 'kerbal-rt-api',
version: '0.1.0',
store: store.constructor.name,
ts: new Date().toISOString(),
}),
);
@@ -29,15 +37,22 @@ export function buildApp() {
const IngestAuth = z.object({ 'x-api-key': z.string() });
app.post('/api/v1/ingest', async (c) => {
const headerParse = IngestAuth.safeParse(
Object.fromEntries(Array.from(c.req.raw.headers.entries())),
);
if (!headerParse.success) {
return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Missing API key' }, 401);
}
if (headerParse.data['x-api-key'] !== process.env.INGEST_API_KEY) {
return c.json({ error: true, code: 'FORBIDDEN', message: 'Bad API key' }, 403);
if (ingestApiKey) {
const headerParse = IngestAuth.safeParse(
Object.fromEntries(Array.from(c.req.raw.headers.entries())),
);
if (!headerParse.success) {
return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Missing API key' }, 401);
}
if (headerParse.data['x-api-key'] !== ingestApiKey) {
return c.json({ error: true, code: 'FORBIDDEN', message: 'Bad API key' }, 403);
}
} else if (process.env.NODE_ENV === 'production') {
// Belt and suspenders: never allow unauthenticated ingest in prod
// even if the env var is missing.
return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Ingest disabled' }, 401);
}
const body = await c.req.json();
const parsed = UniverseSnapshotSchema.safeParse(body);
if (!parsed.success) {
@@ -51,23 +66,28 @@ export function buildApp() {
400,
);
}
state.applySnapshot(parsed.data);
return c.json({ error: false, data: { ok: true, ts: new Date().toISOString() } });
const result = await store.applySnapshot(parsed.data);
return c.json({
error: false,
data: { ok: true, rowsInserted: result.rowsInserted, ts: new Date().toISOString() },
});
});
// ─── read-only endpoints ───────────────────────────────────────────────
app.get('/api/v1/state', (c) => {
const snap = state.latestSnapshot();
app.get('/api/v1/state', async (c) => {
const snap = await store.latestSnapshot();
if (!snap) return c.json({ error: true, code: 'NO_DATA', message: 'No snapshot yet' }, 503);
return c.json({ error: false, data: snap });
});
app.get('/api/v1/bodies', (c) => c.json({ error: false, data: state.latestBodies() }));
app.get('/api/v1/bodies', async (c) => c.json({ error: false, data: await store.listBodies() }));
app.get('/api/v1/vessels', (c) => c.json({ error: false, data: state.latestVessels() }));
app.get('/api/v1/vessels', async (c) =>
c.json({ error: false, data: await store.listVessels() }),
);
app.get('/api/v1/vessels/:id', (c) => {
const v = state.getVessel(c.req.param('id'));
app.get('/api/v1/vessels/:id', async (c) => {
const v = await store.getVessel(c.req.param('id'));
if (!v) return c.json({ error: true, code: 'NOT_FOUND', message: 'Vessel not found' }, 404);
return c.json({ error: false, data: v });
});
+71 -28
View File
@@ -2,45 +2,88 @@
* API entrypoint.
*
* Stack: Hono on Node + ws for WebSockets.
* Phase 0 scope: healthcheck, body catalog (static seed), vessel list
* (in-memory from /ingest), and a /live WebSocket.
*
* Real DB (Postgres + Timescale) lands in Phase 1.
* Phase 0 scope: in-memory state, healthcheck, ingest, REST read, /live WS.
* Phase 1 scope: Postgres + Timescale + Redis via @kerbal-rt/db.
* Falls back to in-memory if DATABASE_URL/REDIS_URL unset.
*/
import { serve } from '@hono/node-server';
import { createNodeWebSocket } from '@hono/node-ws';
import type { ServerType } from '@hono/node-server';
import type { LiveMessage } from '@kerbal-rt/shared-types';
import { createStore } from '@kerbal-rt/db';
import { buildApp } from './app.js';
import { handleLiveSocket } from './ws.js';
import { LiveSocketHub } from './ws.js';
const app = buildApp();
async function main() {
const wired = await createStore();
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
if (wired.config.useInMemory) {
// eslint-disable-next-line no-console
console.log(
'[kerbal-rt-api] using IN-MEMORY store (no DATABASE_URL/REDIS_URL — fine for dev, set both for prod)',
);
} else {
// eslint-disable-next-line no-console
console.log('[kerbal-rt-api] using POSTGRES + REDIS store');
}
app.get(
'/api/v1/live',
upgradeWebSocket(() => ({
onOpen: (_evt, ws) => handleLiveSocket.onOpen(ws),
onMessage: (evt, ws) => handleLiveSocket.onMessage(evt, ws),
onClose: (_evt, ws) => handleLiveSocket.onClose(ws),
})),
);
const hub = new LiveSocketHub({ store: wired.store, channel: wired.channel });
await hub.start();
// Suppress unused-import warning when no upgrade happens
void upgradeWebSocket;
const ingestApiKey = process.env.INGEST_API_KEY ?? null;
if (!ingestApiKey && process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.log(
'[kerbal-rt-api] WARNING: INGEST_API_KEY not set, /api/v1/ingest is unauthenticated',
);
}
const port = Number(process.env.PORT ?? 4000);
const app = buildApp({ store: wired.store, ingestApiKey });
const server: ServerType = serve({ fetch: app.fetch, port }, (info) => {
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
app.get(
'/api/v1/live',
upgradeWebSocket(() => ({
onOpen: (_evt, ws) => hub.onOpen(ws),
onMessage: (evt, ws) => hub.onMessage(evt, ws),
onClose: (_evt, ws) => hub.onClose(ws),
})),
);
// Suppress unused-import warning when no upgrade happens
void upgradeWebSocket;
const port = Number(process.env.PORT ?? 4000);
const server: ServerType = serve({ fetch: app.fetch, port }, (info) => {
// eslint-disable-next-line no-console
console.log(`[kerbal-rt-api] listening on http://localhost:${info.port}`);
});
injectWebSocket(server);
// Periodic heartbeats so clients can detect dead connections.
setInterval(() => {
const msg: LiveMessage = { type: 'ping', serverTime: new Date().toISOString() };
hub.broadcast(msg);
}, 15_000);
// Graceful shutdown
const shutdown = async (sig: string) => {
// eslint-disable-next-line no-console
console.log(`[kerbal-rt-api] ${sig} received, shutting down...`);
await hub.stop();
await wired.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.log(`[kerbal-rt-api] listening on http://localhost:${info.port}`);
console.error('[kerbal-rt-api] fatal:', err);
process.exit(1);
});
injectWebSocket(server);
// Push periodic pings to all connected WS clients
setInterval(() => {
const msg: LiveMessage = { type: 'ping', serverTime: new Date().toISOString() };
handleLiveSocket.broadcast(msg);
}, 15_000);
+8 -1
View File
@@ -1,5 +1,12 @@
/**
* Test-only Hono app export. Doesn't bind a port.
* Always uses in-memory store (no DB required for unit tests).
*/
import { InMemoryStateStore } from '@kerbal-rt/db';
import { buildApp } from './app.js';
export const app = buildApp();
export const app = buildApp({
store: new InMemoryStateStore(),
// Force-disable auth for tests; the auth path is tested separately.
ingestApiKey: null,
});
+67 -32
View File
@@ -1,17 +1,16 @@
/**
* WebSocket fan-out for /api/v1/live.
*
* Phase 0 just keeps a Set of connected clients and broadcasts any
* LiveMessage we get. Clients should handle `ping` as a heartbeat.
*
* Phase 1 will wire this to a Redis pubsub channel so multiple API
* instances can share the fan-out load.
* Subscribes to the StateStore (which is backed by Redis pubsub in
* prod, in-process events in dev) and re-broadcasts to all connected
* clients.
*
* Hono's @hono/node-ws gives us a `WSContext` that wraps a `ws` WebSocket
* with extra Hono-specific methods. We use a thin adapter type so this
* module doesn't need to import from hono.
*/
import type { LiveMessage } from '@kerbal-rt/shared-types';
import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types';
import type { StateStore } from '@kerbal-rt/db';
/** Anything with .send(string) and a .readyState we can inspect. */
export interface WsLike {
@@ -20,34 +19,70 @@ export interface WsLike {
}
const READY_STATE_OPEN = 1;
const clients = new Set<WsLike>();
function send(ws: WsLike, msg: LiveMessage): void {
if (ws.readyState === READY_STATE_OPEN) {
ws.send(JSON.stringify(msg));
export interface LiveSocketOpts {
store: StateStore;
/** Channel label for diagnostics only. */
channel: string;
}
export class LiveSocketHub {
private clients = new Set<WsLike>();
private unsubscribe: (() => Promise<void>) | null = null;
constructor(private opts: LiveSocketOpts) {}
async start(): Promise<void> {
// Subscribe once; share the subscription across all clients.
this.unsubscribe = await this.opts.store.subscribe((snap) => {
this.broadcast({ type: 'snapshot', snapshot: snap });
});
}
async stop(): Promise<void> {
if (this.unsubscribe) {
await this.unsubscribe();
this.unsubscribe = null;
}
this.clients.clear();
}
onOpen(ws: WsLike): void {
this.clients.add(ws);
// Send the latest snapshot on connect so the client can render immediately.
void this.opts.store.latestSnapshot().then((snap) => {
if (snap) this.send(ws, { type: 'snapshot', snapshot: snap });
});
}
onMessage(_evt: { data: unknown }, _ws: WsLike): void {
// Phase 1: clients are read-only.
// Phase 2 will accept subscriptions like { type: 'subscribe', vesselId: '...' }
}
onClose(ws: WsLike): void {
this.clients.delete(ws);
}
broadcast(msg: LiveMessage): void {
const data = JSON.stringify(msg);
for (const ws of this.clients) {
if (ws.readyState === READY_STATE_OPEN) ws.send(data);
}
}
private send(ws: WsLike, msg: LiveMessage): void {
if (ws.readyState === READY_STATE_OPEN) ws.send(JSON.stringify(msg));
}
get clientCount(): number {
return this.clients.size;
}
}
export const handleLiveSocket = {
onOpen(ws: WsLike): void {
clients.add(ws);
},
/** Backward-compat shim for the Phase 0 module shape. */
export function makeLiveHub(opts: LiveSocketOpts): LiveSocketHub {
return new LiveSocketHub(opts);
}
onMessage(_evt: { data: unknown }, _ws: WsLike): void {
// Phase 0: clients are read-only, ignore incoming.
// Phase 2: accept subscriptions like { type: 'subscribe', vesselId: '...' }
},
onClose(ws: WsLike): void {
clients.delete(ws);
},
broadcast(msg: LiveMessage): void {
for (const ws of clients) send(ws, msg);
},
/** For tests / introspection. */
get clientCount(): number {
return clients.size;
},
};
export type { UniverseSnapshot };
+93 -1
View File
@@ -2,10 +2,102 @@ import { describe, it, expect } from 'vitest';
import { app } from '../src/test-app.js';
describe('health', () => {
it('returns ok', async () => {
it('returns ok with service info', async () => {
const res = await app.request('/health');
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.service).toBe('kerbal-rt-api');
expect(body.store).toBe('InMemoryStateStore');
});
it('returns 503 NO_DATA when no snapshot has been ingested', async () => {
const res = await app.request('/api/v1/state');
expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toBe(true);
expect(body.code).toBe('NO_DATA');
});
it('accepts a valid snapshot and returns it on /api/v1/state', async () => {
const snap = {
ut: 1,
capturedAt: '2026-01-01T00:00:00Z',
activeVesselId: null,
bodies: [],
vessels: [],
groundStations: [],
};
const ingest = await app.request('/api/v1/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(snap),
});
expect(ingest.status).toBe(200);
const ingestBody = await ingest.json();
expect(ingestBody.error).toBe(false);
expect(ingestBody.data.ok).toBe(true);
const state = await app.request('/api/v1/state');
expect(state.status).toBe(200);
const stateBody = await state.json();
expect(stateBody.data.ut).toBe(1);
});
it('rejects malformed snapshots with 400', async () => {
const res = await app.request('/api/v1/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ broken: 'payload' }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe(true);
expect(body.code).toBe('BAD_PAYLOAD');
});
it('looks up vessels by id', async () => {
const snap = {
ut: 1,
capturedAt: '2026-01-01T00:00:00Z',
activeVesselId: 'v-1',
bodies: [],
vessels: [
{
id: 'v-1',
name: 'Test',
type: 'Probe',
owner: 'KASA',
situation: 'ORBITING',
status: 'ACTIVE',
orbit: {
semiMajorAxis: 7e6,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
referenceBodyId: 'kerbin',
createdAt: '2026-01-01T00:00:00Z',
retiredAt: null,
},
],
groundStations: [],
};
await app.request('/api/v1/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(snap),
});
const found = await app.request('/api/v1/vessels/v-1');
expect(found.status).toBe(200);
const body = await found.json();
expect(body.data.name).toBe('Test');
const missing = await app.request('/api/v1/vessels/does-not-exist');
expect(missing.status).toBe(404);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import { createServer, type Server } from 'node:http';
import { WebSocket } from 'ws';
import { createNodeWebSocket } from '@hono/node-ws';
import { InMemoryStateStore } from '@kerbal-rt/db';
import { buildApp } from '../src/app.js';
import { LiveSocketHub } from '../src/ws.js';
/**
* Integration test: the API exposes /api/v1/live over WebSocket.
* When a snapshot is applied to the store, the WS hub broadcasts it
* to every connected client.
*/
describe('WebSocket live feed', () => {
async function startTestServer(): Promise<{
server: Server;
port: number;
store: InMemoryStateStore;
close: () => Promise<void>;
}> {
const store = new InMemoryStateStore();
const hub = new LiveSocketHub({ store, channel: 'test' });
await hub.start();
const app = buildApp({ store, ingestApiKey: null });
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
app.get(
'/api/v1/live',
upgradeWebSocket(() => ({
onOpen: (_evt, ws) => hub.onOpen(ws),
onMessage: (evt, ws) => hub.onMessage(evt, ws),
onClose: (_evt, ws) => hub.onClose(ws),
})),
);
const server = createServer();
injectWebSocket(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as { port: number }).port;
return {
server,
port,
store,
async close() {
await hub.stop();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
it('sends the current snapshot to a newly connected client', async () => {
const { port, store, close } = await startTestServer();
try {
await store.applySnapshot({
ut: 42,
capturedAt: 'x',
activeVesselId: null,
bodies: [],
vessels: [],
groundStations: [],
});
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`);
const firstMessage = await new Promise<string>((resolve, reject) => {
ws.once('open', () => {});
ws.once('message', (data) => {
resolve(data.toString());
ws.close();
});
ws.once('error', reject);
setTimeout(() => reject(new Error('timeout')), 5000);
});
const parsed = JSON.parse(firstMessage);
expect(parsed.type).toBe('snapshot');
expect(parsed.snapshot.ut).toBe(42);
} finally {
await close();
}
});
it('broadcasts new snapshots to all connected clients', async () => {
const { port, store, close } = await startTestServer();
try {
const ws1 = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`);
const ws2 = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`);
// Wait for both to open
await Promise.all([
new Promise<void>((r) => ws1.once('open', () => r())),
new Promise<void>((r) => ws2.once('open', () => r())),
]);
// Attach persistent handlers FIRST (so we don't miss any message
// during the drain→wait window), then drain the initial snapshot
// messages and wait for the broadcast.
const received = { ws1: false, ws2: false };
const waiters: Promise<void>[] = [];
const makeWaiter = (key: 'ws1' | 'ws2', ws: WebSocket) =>
new Promise<void>((resolve) => {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'snapshot' && msg.snapshot.ut === 99) {
received[key] = true;
resolve();
}
});
// Safety timeout so a failing test doesn't hang forever
setTimeout(() => resolve(), 3000);
});
waiters.push(makeWaiter('ws1', ws1));
waiters.push(makeWaiter('ws2', ws2));
// Wait for the initial snapshot to land on both before we apply
// the new one — otherwise we may set up the waiter after it
// arrives (race condition with `once`).
await new Promise((r) => setTimeout(r, 100));
await store.applySnapshot({
ut: 99,
capturedAt: 'x',
activeVesselId: null,
bodies: [],
vessels: [],
groundStations: [],
});
await Promise.all(waiters);
expect(received.ws1).toBe(true);
expect(received.ws2).toBe(true);
ws1.close();
ws2.close();
} finally {
await close();
}
});
});
+195
View File
@@ -0,0 +1,195 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, Pill, formatUtAsKspDate } from '@kerbal-rt/ui';
import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types';
const API_URL =
(typeof process !== 'undefined' && process.env.NEXT_PUBLIC_API_URL) || 'http://localhost:4000';
/**
* Debug page for Phase 1.
*
* Connects to /api/v1/live over WebSocket and shows the latest
* universe snapshot. Use this to verify the ingest → state → WS
* pipeline is working end-to-end.
*
* Run the mock-telemetry publisher in another terminal to drive
* the state, or POST to /api/v1/ingest manually.
*/
export default function DebugPage() {
const [snap, setSnap] = useState<UniverseSnapshot | null>(null);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
const [messageCount, setMessageCount] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const url = API_URL.replace(/^http/, 'ws') + '/api/v1/live';
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
const connect = () => {
setStatus('connecting');
setError(null);
try {
ws = new WebSocket(url);
} catch (e) {
setError(String((e as Error).message ?? e));
scheduleReconnect();
return;
}
ws.onopen = () => {
setStatus('open');
};
ws.onmessage = (event) => {
setMessageCount((n) => n + 1);
setLastUpdate(new Date().toISOString());
try {
const msg = JSON.parse(event.data) as LiveMessage;
if (msg.type === 'snapshot') {
setSnap(msg.snapshot);
}
// ping/event_new/etc are ignored on the debug page
} catch (e) {
setError(`bad message: ${String((e as Error).message ?? e)}`);
}
};
ws.onerror = () => {
// The close event will fire too; we just record the moment
};
ws.onclose = () => {
setStatus('closed');
scheduleReconnect();
};
};
const scheduleReconnect = () => {
if (reconnectTimer) return;
reconnectTimer = window.setTimeout(connect, 2000);
};
connect();
return () => {
if (reconnectTimer) window.clearTimeout(reconnectTimer);
if (ws) ws.close();
};
}, []);
return (
<main style={{ maxWidth: 1100, margin: '0 auto', padding: '2rem 1rem' }}>
<header
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}
>
<h1 style={{ fontSize: '1.5rem', margin: 0 }}>Debug Live State</h1>
<Pill tone={status === 'open' ? 'success' : status === 'connecting' ? 'warn' : 'danger'}>
WS {status}
</Pill>
<Pill tone="neutral">{messageCount} msgs</Pill>
</header>
{error && (
<Card title="Errors">
<p style={{ color: 'var(--pill-danger, #e44)' }}>{error}</p>
</Card>
)}
<div style={{ height: '1rem' }} />
<Card title="Connection">
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li>API: {API_URL}</li>
<li>WebSocket: {API_URL.replace(/^http/, 'ws')}/api/v1/live</li>
<li>Status: {status}</li>
<li>Last update: {lastUpdate ?? '—'}</li>
</ul>
</Card>
<div style={{ height: '1rem' }} />
{!snap ? (
<Card title="No snapshot yet">
<p>No snapshot received. Make sure:</p>
<ol>
<li>The API is running at {API_URL}</li>
<li>
Either the mock-telemetry publisher is running, or you&apos;ve POSTed to{' '}
<code>/api/v1/ingest</code>
</li>
</ol>
<p>
Try the mock publisher from the repo root:
<br />
<code>pnpm --filter @kerbal-rt/mock-telemetry start</code>
</p>
</Card>
) : (
<>
<Card title="Snapshot summary">
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li>UT: {formatUtAsKspDate(snap.ut)}</li>
<li>Captured at: {snap.capturedAt}</li>
<li>Bodies: {snap.bodies.length}</li>
<li>Vessels: {snap.vessels.length}</li>
<li>Ground stations: {snap.groundStations.length}</li>
<li>Active vessel: {snap.activeVesselId ?? '—'}</li>
</ul>
</Card>
<div style={{ height: '1rem' }} />
<Card title={`Vessels (${snap.vessels.length})`}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--card-border)' }}>
<th>ID</th>
<th>Name</th>
<th>Owner</th>
<th>Situation</th>
<th>Body</th>
<th>sma</th>
<th>ecc</th>
</tr>
</thead>
<tbody>
{snap.vessels.map((v) => (
<tr key={v.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<td>
<code>{v.id}</code>
</td>
<td>{v.name}</td>
<td>{v.owner ?? '—'}</td>
<td>{v.situation}</td>
<td>{v.referenceBodyId}</td>
<td>{(v.orbit.semiMajorAxis / 1000).toFixed(0)} km</td>
<td>{v.orbit.eccentricity.toFixed(3)}</td>
</tr>
))}
</tbody>
</table>
</Card>
<div style={{ height: '1rem' }} />
<Card title={`Bodies (${snap.bodies.length})`}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{snap.bodies.map((b) => (
<span
key={b.id}
style={{
padding: '0.25rem 0.5rem',
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
fontSize: '0.8rem',
}}
>
{b.name} <span style={{ opacity: 0.5 }}>({b.kind})</span>
</span>
))}
</div>
</Card>
</>
)}
</main>
);
}
+12 -3
View File
@@ -22,7 +22,12 @@ export default function HomePage() {
<Card title="Quick links">
<ul>
<li>
<a href="/live-map">Live map</a> (scaffolded in the same Vite app)
<a href="/debug">Debug page</a> live state from the WebSocket (Phase 1)
</li>
<li>
<a href="http://localhost:3001" target="_blank" rel="noreferrer">
Live map (3D)
</a>
</li>
<li>
<a href="http://localhost:4000/health" target="_blank" rel="noreferrer">
@@ -30,8 +35,12 @@ export default function HomePage() {
</a>
</li>
<li>
<a href="https://github.com" target="_blank" rel="noreferrer">
Repo
<a
href="https://gitea.arnike.ru/Arnike/KSP-MissionControl"
target="_blank"
rel="noreferrer"
>
Repo on Gitea
</a>
</li>
</ul>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@kerbal-rt/mock-telemetry",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Generates realistic KSP universe state and POSTs to the API for development",
"main": "./src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "echo 'no linter yet'",
"test": "echo 'no tests yet'"
},
"dependencies": {
"@kerbal-rt/orbital-math": "workspace:*",
"@kerbal-rt/shared-types": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.5.0",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}
+414
View File
@@ -0,0 +1,414 @@
/**
* Hard-coded Kerbol system catalog. Matches the SQL seed in
* infra/init-sql/02-bodies-seed.sql so the mock and real worlds agree.
*
* Only the fields the live map needs to render are included. The
* real telemetry bridge (Phase 1c) will pull these from kRPC.
*/
import type { CelestialBody } from '@kerbal-rt/shared-types';
export const KERBOL_SYSTEM: CelestialBody[] = [
{
id: 'kerbol',
name: 'Kerbol',
kind: 'star',
parentId: null,
radius: 261_600_000,
sphereOfInfluence: 1e30, // sentinel for "infinity" at JSON boundary
gravitationalParameter: 1.172332794e18,
rotationPeriod: 432_000,
axialTilt: 0,
orbit: {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
},
{
id: 'moho',
name: 'Moho',
kind: 'planet',
parentId: 'kerbol',
radius: 250_000,
sphereOfInfluence: 9_646_663,
gravitationalParameter: 1.686842e11,
rotationPeriod: 1_210_000,
axialTilt: 0.05,
orbit: {
semiMajorAxis: 5_263_138_304,
eccentricity: 0.2,
inclination: 0.075,
longitudeOfAscendingNode: 1.5,
argumentOfPeriapsis: 2.8,
meanAnomalyAtEpoch: 1.0,
epoch: 0,
},
},
{
id: 'eve',
name: 'Eve',
kind: 'planet',
parentId: 'kerbol',
radius: 700_000,
sphereOfInfluence: 85_109_365,
gravitationalParameter: 8.1717302e12,
rotationPeriod: 80_500,
axialTilt: 0.1,
orbit: {
semiMajorAxis: 9_832_684_544,
eccentricity: 0.01,
inclination: 0.1,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 3.14,
epoch: 0,
},
},
{
id: 'kerbin',
name: 'Kerbin',
kind: 'planet',
parentId: 'kerbol',
radius: 600_000,
sphereOfInfluence: 84_159_286,
gravitationalParameter: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 13_599_840_256,
eccentricity: 0.05,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0.7,
epoch: 0,
},
},
{
id: 'duna',
name: 'Duna',
kind: 'planet',
parentId: 'kerbol',
radius: 320_000,
sphereOfInfluence: 47_921_949,
gravitationalParameter: 3.0136321e11,
rotationPeriod: 65_518,
axialTilt: 0.06,
orbit: {
semiMajorAxis: 20_726_155_264,
eccentricity: 0.05,
inclination: 0.02,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 1.5,
epoch: 0,
},
},
{
id: 'dres',
name: 'Dres',
kind: 'planet',
parentId: 'kerbol',
radius: 138_000,
sphereOfInfluence: 32_832_840,
gravitationalParameter: 2.1484489e10,
rotationPeriod: 34_800,
axialTilt: 0.08,
orbit: {
semiMajorAxis: 40_839_348_203,
eccentricity: 0.14,
inclination: 0.08,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 2.1,
epoch: 0,
},
},
{
id: 'jool',
name: 'Jool',
kind: 'planet',
parentId: 'kerbol',
radius: 6_000_000,
sphereOfInfluence: 2_450_055_988,
gravitationalParameter: 2.82528e14,
rotationPeriod: 36_000,
axialTilt: 0.05,
orbit: {
semiMajorAxis: 68_773_560_320,
eccentricity: 0.05,
inclination: 0.04,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 4.2,
epoch: 0,
},
},
{
id: 'eeloo',
name: 'Eeloo',
kind: 'planet',
parentId: 'kerbol',
radius: 210_000,
sphereOfInfluence: 119_082_940,
gravitationalParameter: 7.4410815e10,
rotationPeriod: 19_460,
axialTilt: 0.1,
orbit: {
semiMajorAxis: 90_118_820_000,
eccentricity: 0.26,
inclination: 0.13,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 5.5,
epoch: 0,
},
},
{
id: 'gilly',
name: 'Gilly',
kind: 'moon',
parentId: 'eve',
radius: 13_000,
sphereOfInfluence: 126_123,
gravitationalParameter: 8_289_449.8,
rotationPeriod: 28_260,
axialTilt: 0.05,
orbit: {
semiMajorAxis: 31_500_000,
eccentricity: 0.18,
inclination: 0.2,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0.5,
epoch: 0,
},
},
{
id: 'mun',
name: 'Mun',
kind: 'moon',
parentId: 'kerbin',
radius: 200_000,
sphereOfInfluence: 2_429_559,
gravitationalParameter: 6.514e10,
rotationPeriod: 138_984,
axialTilt: 0,
orbit: {
semiMajorAxis: 12_000_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 1.0,
epoch: 0,
},
},
{
id: 'minmus',
name: 'Minmus',
kind: 'moon',
parentId: 'kerbin',
radius: 60_000,
sphereOfInfluence: 2_247_428,
gravitationalParameter: 1.765e9,
rotationPeriod: 40_400,
axialTilt: 0.04,
orbit: {
semiMajorAxis: 47_000_000,
eccentricity: 0.0,
inclination: 0.075,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 2.5,
epoch: 0,
},
},
{
id: 'ike',
name: 'Ike',
kind: 'moon',
parentId: 'duna',
radius: 130_000,
sphereOfInfluence: 1_048_598,
gravitationalParameter: 1.856e9,
rotationPeriod: 65_518,
axialTilt: 0.05,
orbit: {
semiMajorAxis: 3_200_000,
eccentricity: 0.0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 1.5,
epoch: 0,
},
},
{
id: 'laythe',
name: 'Laythe',
kind: 'moon',
parentId: 'jool',
radius: 500_000,
sphereOfInfluence: 3_723_645,
gravitationalParameter: 1.84e11,
rotationPeriod: 52_980,
axialTilt: 0,
orbit: {
semiMajorAxis: 27_184_000,
eccentricity: 0.0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 2.0,
epoch: 0,
},
},
{
id: 'vall',
name: 'Vall',
kind: 'moon',
parentId: 'jool',
radius: 240_000,
sphereOfInfluence: 2_406_401,
gravitationalParameter: 2.061e10,
rotationPeriod: 106_200,
axialTilt: 0,
orbit: {
semiMajorAxis: 43_152_000,
eccentricity: 0.0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 2.5,
epoch: 0,
},
},
{
id: 'tylo',
name: 'Tylo',
kind: 'moon',
parentId: 'jool',
radius: 375_000,
sphereOfInfluence: 10_856_418,
gravitationalParameter: 2.122e11,
rotationPeriod: 84_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 68_500_000,
eccentricity: 0.0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 3.0,
epoch: 0,
},
},
{
id: 'bop',
name: 'Bop',
kind: 'moon',
parentId: 'jool',
radius: 65_000,
sphereOfInfluence: 1_220_600,
gravitationalParameter: 2.486e8,
rotationPeriod: 360,
axialTilt: 0.05,
orbit: {
semiMajorAxis: 128_500_000,
eccentricity: 0.23,
inclination: 0.2,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 4.0,
epoch: 0,
},
},
{
id: 'pol',
name: 'Pol',
kind: 'moon',
parentId: 'jool',
radius: 44_000,
sphereOfInfluence: 1_042_138,
gravitationalParameter: 7.214e7,
rotationPeriod: 340,
axialTilt: 0.05,
orbit: {
semiMajorAxis: 179_890_000,
eccentricity: 0.17,
inclination: 0.15,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 4.5,
epoch: 0,
},
},
];
/** A starter fleet — three vessels in different situations. */
export const STARTER_FLEET = [
{
id: 'kasa-regolith-1',
name: 'CAPS Regolith 1',
type: 'Probe',
owner: 'KASA',
situation: 'ORBITING' as const,
status: 'ACTIVE' as const,
referenceBodyId: 'kerbin',
initialOrbit: {
semiMajorAxis: 7_500_000, // ~700km LKO
eccentricity: 0.01,
inclination: 0.05,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
createdAt: '2026-01-15T00:00:00Z',
},
{
id: 'spes-longshot',
name: 'SPES Longshot',
type: 'Probe',
owner: 'SPES',
situation: 'ESCAPING' as const,
status: 'ACTIVE' as const,
referenceBodyId: 'kerbol',
initialOrbit: {
// Heliocentric transfer ellipse to Duna
semiMajorAxis: 17_000_000_000,
eccentricity: 0.18,
inclination: 0.02,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 1.2,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
createdAt: '2026-02-20T00:00:00Z',
},
{
id: 'kasa-relay-iii',
name: 'KASA Relay Net III',
type: 'Relay',
owner: 'KASA',
situation: 'ORBITING' as const,
status: 'ACTIVE' as const,
referenceBodyId: 'duna',
initialOrbit: {
// Duna stationary-ish relay
semiMajorAxis: 4_700_000,
eccentricity: 0.0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
createdAt: '2026-03-10T00:00:00Z',
},
];
+166
View File
@@ -0,0 +1,166 @@
/**
* Mock KSP telemetry publisher.
*
* Generates a universe snapshot at a configurable cadence (default 1Hz)
* using the KERBOL_SYSTEM catalog and a starter fleet of vessels. The
* vessel orbits are propagated forward in time using the
* @kerbal-rt/orbital-math library exactly the same math the live-map
* uses to render the scene, so the publisher and renderer stay in sync.
*
* Each generated snapshot is POSTed to the API at /api/v1/ingest.
* Connect the hub's /debug page (or any WS client) and you'll see the
* state update in real time.
*/
import { meanMotion } from '@kerbal-rt/orbital-math';
import type {
CelestialBody,
KeplerianElements,
UniverseSnapshot,
Vessel,
} from '@kerbal-rt/shared-types';
import { KERBOL_SYSTEM, STARTER_FLEET } from './catalog.js';
const API_URL = process.env.MOCK_API_URL ?? 'http://localhost:4000';
const API_KEY = process.env.INGEST_API_KEY ?? '';
const INTERVAL_MS = Number(process.env.MOCK_INTERVAL_MS ?? 1000);
const START_UT = Number(process.env.MOCK_START_UT ?? 4_700_000);
interface MutableVessel {
id: string;
name: string;
type: string | null;
owner: string | null;
situation: Vessel['situation'];
status: Vessel['status'];
referenceBodyId: string;
/** Working keplerian elements at the last emitted time. */
elements: KeplerianElements;
createdAt: string;
retiredAt: string | null;
}
const bodies: CelestialBody[] = KERBOL_SYSTEM;
const bodiesById = new Map(bodies.map((b) => [b.id, b]));
const vessels: MutableVessel[] = STARTER_FLEET.map((v) => ({
id: v.id,
name: v.name,
type: v.type,
owner: v.owner,
situation: v.situation,
status: v.status,
referenceBodyId: v.referenceBodyId,
elements: { ...v.initialOrbit, epoch: START_UT },
createdAt: v.createdAt,
retiredAt: null,
}));
let currentUt = START_UT;
let snapshotCount = 0;
let lastError: string | null = null;
async function publish(snap: UniverseSnapshot): Promise<void> {
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (API_KEY) headers['x-api-key'] = API_KEY;
try {
const res = await fetch(`${API_URL}/api/v1/ingest`, {
method: 'POST',
headers,
body: JSON.stringify(snap),
});
if (!res.ok) {
lastError = `HTTP ${res.status}`;
} else {
lastError = null;
}
} catch (err) {
lastError = String((err as Error).message ?? err);
}
}
function buildSnapshot(ut: number): UniverseSnapshot {
// Advance each vessel's mean anomaly by n·dt from its last known epoch.
for (const v of vessels) {
const ref = bodiesById.get(v.referenceBodyId);
if (!ref) continue;
const dt = ut - v.elements.epoch;
if (dt !== 0) {
const n = meanMotion(v.elements.semiMajorAxis, ref.gravitationalParameter);
v.elements = {
...v.elements,
meanAnomalyAtEpoch: v.elements.meanAnomalyAtEpoch + n * dt,
epoch: ut,
};
}
}
const vesselsOut: Vessel[] = vessels.map((v) => ({
id: v.id,
name: v.name,
type: v.type,
owner: v.owner,
situation: v.situation,
status: v.status,
orbit: v.elements,
referenceBodyId: v.referenceBodyId,
createdAt: v.createdAt,
retiredAt: v.retiredAt,
}));
return {
ut,
capturedAt: new Date().toISOString(),
activeVesselId: vessels[0]?.id ?? null,
bodies,
vessels: vesselsOut,
groundStations: [
{
id: 'montana',
name: 'Montana DSN',
bodyId: 'kerbin',
lat: 47.0,
lon: -110.0,
alt: 1200,
},
],
};
}
async function tick(): Promise<void> {
currentUt += INTERVAL_MS / 1000; // 1s wall = 1s KSP UT (1x speed)
const snap = buildSnapshot(currentUt);
snapshotCount += 1;
await publish(snap);
}
function printStatus(): void {
const status = lastError ? `ERROR (${lastError})` : 'OK';
// eslint-disable-next-line no-console
console.log(
`[mock-telemetry] ut=${currentUt.toFixed(0)} snapshots=${snapshotCount}${API_URL} ${status}`,
);
}
async function main(): Promise<void> {
// eslint-disable-next-line no-console
console.log(
`[mock-telemetry] starting — API=${API_URL} interval=${INTERVAL_MS}ms key=${API_KEY ? 'set' : 'unset'} start_ut=${START_UT}`,
);
// eslint-disable-next-line no-console
console.log(`[mock-telemetry] publishing ${bodies.length} bodies, ${vessels.length} vessels`);
// Send the first snapshot immediately, then on the interval.
await tick();
printStatus();
setInterval(async () => {
await tick();
}, INTERVAL_MS);
setInterval(printStatus, 10_000);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[mock-telemetry] fatal:', err);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}