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
+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 };