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
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:
+67
-32
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user