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:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user