Merge pull request 'Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page)' (#1) from phase-1 into main
CI / Lint, typecheck, test, build (push) Failing after 9s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-02 19:02:27 +00:00
28 changed files with 2165 additions and 98 deletions
+34 -7
View File
@@ -3,8 +3,10 @@
A real-time web mirror of a no-warp Kerbal Space Program multiplayer server:
mission hub + 3D live solar system map.
> **Status: Phase 0 (skeleton)** — monorepo scaffolded, all three apps build,
> data pipeline / real KSP bridge lands in Phase 1.
> **Status: Phase 1 — data pipeline working end-to-end.**
> Mock telemetry publisher drives the API, the API stores snapshots, the
> hub debug page and the live map both consume the WebSocket. Real KSP
> bridge (Phase 1c) is documented and ready to plug in once you have KSP.
See [`kerbalrealtime-clone-plan.md`](./kerbalrealtime-clone-plan.md) for the
full implementation plan.
@@ -28,14 +30,17 @@ full implementation plan.
```
.
├── apps/
│ ├── hub/ # Next.js — marketing + calendar + media
│ ├── hub/ # Next.js — marketing + calendar + media + /debug
│ ├── live-map/ # Vite + Three.js — 3D solar system viewer
── api/ # Hono — REST + WS gateway
── api/ # Hono — REST + WS gateway
│ └── tools/
│ └── mock-telemetry/ # Node script that drives the pipeline with fake KSP state
├── packages/
│ ├── shared-types/ # TS types + Zod schemas for every API boundary
│ ├── orbital-math/ # Pure keplerian propagation, occultation, transfers
│ ├── db/ # Postgres+Timescale+Redis wrapper, StateStore interface
│ └── ui/ # Shared React components (Pill, Card, …)
├── ksp/ # KSP-side mods (Phase 1+)
├── ksp/ # KSP-side bridge (Phase 1c) — see ./ksp/README.md
├── infra/ # docker-compose, nginx, grafana, init SQL
└── scripts/ # dev.sh and friends
```
@@ -99,14 +104,36 @@ push / PR to `main`. See `.github/workflows/ci.yml`.
See [`kerbalrealtime-clone-plan.md`](./kerbalrealtime-clone-plan.md) for the
roadmap. Quick map:
- [x] **Phase 0** — monorepo, skeletons, docker-compose, CI _(this commit)_
- [ ] **Phase 1**KSP telemetry bridge + ingest pipeline (Postgres + Redis)
- [x] **Phase 0** — monorepo, skeletons, docker-compose, CI
- [x] **Phase 1a**persistence layer (Postgres+Timescale+Redis) with in-memory fallback
- [x] **Phase 1b** — mock telemetry publisher + WebSocket fan-out + hub /debug page
- [ ] **Phase 1c** — real kRPC bridge (see [`ksp/README.md`](./ksp/README.md))
- [ ] **Phase 2** — Live map 3D scene (time controls, focus, eclipses, overpass)
- [ ] **Phase 3** — Mission calendar, media, crew, YouTube/Twitch embeds
- [ ] **Phase 4** — Spacenomicon tools (delta-v, transfer calculator, etc.)
- [ ] **Phase 5** — Admin panel + polish
- [ ] **Phase 6** — Production deploy
## Verifying the Phase 1 pipeline
```bash
# Terminal 1: API
cd apps/api && PORT=4000 USE_IN_MEMORY=1 pnpm start
# Terminal 2: mock publisher
cd apps/tools/mock-telemetry && MOCK_API_URL=http://localhost:4000 pnpm start
# Terminal 3: hub
cd apps/hub && PORT=3000 pnpm start
# → open http://localhost:3000/debug to see the live state
```
For the WebSocket version, open the hub `/debug` page or use [wscat](https://github.com/websockets/wscat):
```bash
npx wscat -c ws://localhost:4000/api/v1/live
```
## License
This project is unaffiliated with Kerbal Space Program, Intercept Games, or
+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/**/*"]
}
+3
View File
@@ -0,0 +1,3 @@
# This file exists so git tracks the ksp/ directory.
# The real KSP-side bridge (Phase 1c) lands here.
# See ./README.md for the design.
+118
View File
@@ -0,0 +1,118 @@
# KSP-side Telemetry Bridge
This directory will hold the bridge between a running Kerbal Space
Program game and the kerbal-rt API. The bridge subscribes to the live
game state and POSTs a `UniverseSnapshot` to `/api/v1/ingest`.
> **Status: Phase 1c — not yet implemented.**
> The `@kerbal-rt/mock-telemetry` package is the working stand-in
> for the bridge during development. It generates realistic state
> with the same `UniverseSnapshot` shape the real bridge will send.
## Two implementation options
### Option A — kRPC (recommended, fastest to ship)
[kRPC](https://github.com/krpc/krpc) is the modern, well-maintained
RPC framework for KSP 1.12.x. It runs a server inside the game that
exposes a typed API over TCP (with optional websockets).
**Setup:**
1. Install KSP 1.12.5 + [ckan](https://github.com/KSP-CKAN/CKAN)
2. `ckan install kRPC` — pulls in the server mod + protobuf defs
3. Start KSP, load your save, start a kRPC server (default port 50000)
4. Run a small Node client that subscribes to streams:
- `vessel.orbit` (returns a tuple of orbital elements)
- `vessel.situation`
- `space_center.ut`
- `body.orbit` for each body
- `space_center.transform_position`/`rotation` for ground stations
5. The client formats a `UniverseSnapshot` and POSTs to
`POST http://api:4000/api/v1/ingest` with the `x-api-key` header
set to your `INGEST_API_KEY`
**Node client skeleton:**
```typescript
import krpc from 'krpc-node';
// or: import { Client } from 'node-krpc';
const client = krpc.connect({ host: 'localhost', rpcPort: 50000 });
const sc = client.spaceCenter;
// Subscribe to streams (push every 1s)
const ut = client.addStream(() => sc.ut);
const vessels = await sc.vessels;
setInterval(async () => {
const snap = {
ut: ut.get(),
capturedAt: new Date().toISOString(),
activeVesselId: sc.activeVessel?.id.toString() ?? null,
bodies: await buildBodies(client),
vessels: await Promise.all(vessels.map(v => buildVessel(client, v))),
groundStations: await buildGroundStations(client),
};
await fetch('http://localhost:4000/api/v1/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': process.env.INGEST_API_KEY! },
body: JSON.stringify(snap),
});
}, 1000);
```
(There's no official kRPC Node client, but a quick `protobufjs` setup
using the .proto files from the kRPC mod works in <300 lines.)
### Option B — Custom KSP mod (most flexible)
A C# KSP mod that uses Harmony to patch into `FlightGlobals` and
publishes state on each physics tick. Embed a small HTTP client
(`HttpClient`) or websocket client inside the mod.
- **Pro:** Total control, can publish *events* (stage, maneuver node,
collision) not just state. Can disable the publish path with a
toggle in the mod's UI.
- **Con:** You own the codebase forever. Have to maintain it across
KSP updates. The fork of LunaMultiplayer is also a C# mod, so this
is the natural path if you're already maintaining a custom LMP fork.
**When to use this:** only if kRPC can't give you the data you need
(e.g. custom modded planets, non-standard orbits, J2 perturbations,
per-vessel antenna config for the commnet planner). For the stock
Kerbol system, kRPC is enough.
## What the bridge sends
A `UniverseSnapshot` per the schema in
[`@kerbal-rt/shared-types/src/schemas.ts`](../packages/shared-types/src/schemas.ts).
The mock publisher's output
([`apps/tools/mock-telemetry/src/index.ts`](../apps/tools/mock-telemetry/src/index.ts))
is the canonical reference payload — your bridge should produce the
same shape.
## Running the real bridge
```bash
# 1. Make sure KSP is running with the kRPC mod enabled
# 2. Make sure the API is running (Phase 1a)
# 3. Run the bridge (Phase 1c — TBD)
pnpm --filter @kerbal-rt/ksp-bridge start
# (this script doesn't exist yet; see Option A/B above)
```
## Why this isn't done yet
The real bridge requires:
1. A real KSP 1.12.5 install with kRPC mod loaded
2. A save with vessels, in a state interesting enough to publish
3. Iterating on the protocol against the real game (KSP exposes
orbital data in KSP-specific frames; you have to translate to
the heliocentric ecliptic frame for the API)
We can do all of that, but the value of a working mock-driven
pipeline (which the user already has) is much higher than a real
bridge sitting unused. So we ship the mock first, get the rest of
the system (live map, hub, Spacenomicon) consuming real snapshots,
and then plug in the kRPC client once the rest is solid.
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@kerbal-rt/db",
"version": "0.1.0",
"private": true,
"description": "Persistence layer: Postgres + Timescale (telemetry) and Redis (pubsub + cache)",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts",
"./migrate": "./src/migrate.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "echo 'no linter yet'",
"test": "vitest run",
"test:watch": "vitest",
"build": "tsc"
},
"dependencies": {
"@kerbal-rt/shared-types": "workspace:*",
"ioredis": "^5.4.1",
"postgres": "^3.4.4"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^2.1.1"
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Configuration helpers for the db package.
*
* Reads from environment variables. Throws clearly if a required var
* is missing.
*/
export interface DbConfig {
/** Postgres connection URL, e.g. postgres://user:pass@host:5432/db */
databaseUrl: string;
/** Redis connection URL, e.g. redis://host:6379 */
redisUrl: string;
/** When true, callers should use the in-memory fallback instead. */
useInMemory: boolean;
}
const REQUIRED_FOR_DB = ['DATABASE_URL', 'REDIS_URL'] as const;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): DbConfig {
const databaseUrl = env.DATABASE_URL;
const redisUrl = env.REDIS_URL;
// Allow opt-out for dev (e.g. when Docker isn't available)
const forceInMemory = env.USE_IN_MEMORY === '1' || env.USE_IN_MEMORY === 'true';
if (forceInMemory) {
return { databaseUrl: '', redisUrl: '', useInMemory: true };
}
const missing = REQUIRED_FOR_DB.filter((k) => !env[k]);
if (missing.length > 0) {
// Don't crash on import — let the caller decide. The API's
// bootstrap() logs a warning and falls back to in-memory mode.
return {
databaseUrl: databaseUrl ?? '',
redisUrl: redisUrl ?? '',
useInMemory: true,
};
}
return {
databaseUrl: databaseUrl!,
redisUrl: redisUrl!,
useInMemory: false,
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Public API of @kerbal-rt/db.
*
* Callers (the API, the mock-telemetry publisher, the kRPC bridge) get
* a single `createStore()` function that returns a fully-wired
* StateStore + the underlying clients. This way they don't need to
* know whether they're in dev (in-memory) or prod (Postgres+Redis).
*/
export { loadConfig, type DbConfig } from './config.js';
export { createPgClient, type PgClient } from './postgres.js';
export { createRedis, type RedisPair } from './redis.js';
export { runMigrations } from './migrate.js';
export { InMemoryStateStore, PgStateStore, type StateStore } from './store.js';
import { loadConfig, type DbConfig } from './config.js';
import { createPgClient, type PgClient } from './postgres.js';
import { createRedis, type RedisPair } from './redis.js';
import { runMigrations } from './migrate.js';
import { InMemoryStateStore, PgStateStore, type StateStore } from './store.js';
export interface WiredStore {
store: StateStore;
config: DbConfig;
/** Underlying clients (null in in-memory mode). */
pg: PgClient | null;
redis: RedisPair | null;
/** Redis pub/sub channel name for snapshot notifications. */
channel: string;
/** Tear down all clients. */
close(): Promise<void>;
}
const SNAPSHOT_CHANNEL = 'kerbal-rt:snapshots';
export async function createStore(env: NodeJS.ProcessEnv = process.env): Promise<WiredStore> {
const config = loadConfig(env);
if (config.useInMemory) {
// Dev mode — no DB required.
return {
store: new InMemoryStateStore(),
config,
pg: null,
redis: null,
channel: SNAPSHOT_CHANNEL,
async close() {
/* nothing to close */
},
};
}
// Production mode — Postgres + Redis.
const pg = createPgClient(config.databaseUrl);
const redis = createRedis(config.redisUrl);
// Run schema migrations on boot (idempotent).
const migrationResult = await runMigrations(pg.sql);
for (const m of migrationResult.applied) {
// eslint-disable-next-line no-console
console.log(`[db] migration applied: ${m}`);
}
// Snapshot fan-out over Redis. The "publish" side is one-liner;
// the "subscribe" side needs a dedicated connection (handled by
// the RedisPair wrapper).
const store = new PgStateStore({
sql: pg.sql,
async publish(payload) {
await redis.client.publish(SNAPSHOT_CHANNEL, payload);
},
async redisSubscribe(handler) {
await redis.subscriber.subscribe(SNAPSHOT_CHANNEL);
const onMessage = (channel: string, message: string) => {
if (channel === SNAPSHOT_CHANNEL) handler(message);
};
redis.subscriber.on('message', onMessage);
return async () => {
redis.subscriber.off('message', onMessage);
await redis.subscriber.unsubscribe(SNAPSHOT_CHANNEL).catch(() => {});
};
},
});
return {
store,
config,
pg,
redis,
channel: SNAPSHOT_CHANNEL,
async close() {
await Promise.all([pg.close().catch(() => {}), redis.close().catch(() => {})]);
},
};
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Apply the schema migrations to a Postgres database.
*
* Reads the SQL files from infra/init-sql/ and runs them in lexical
* order. Idempotent safe to run on every boot.
*
* In production you'd use a proper migration tool (drizzle-kit,
* prisma, knex, sqlx, etc.). For Phase 1 we just want a simple,
* zero-deps way to get the schema into a fresh database.
*/
import { readFile, readdir } from 'node:fs/promises';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Sql } from 'postgres';
const INIT_SQL_DEFAULT = '../../../infra/init-sql';
export async function runMigrations(
sql: Sql,
initDir: string = INIT_SQL_DEFAULT,
): Promise<{ applied: string[] }> {
const dir = resolve(dirname(fileURLToPath(import.meta.url)), initDir);
const entries = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort();
const applied: string[] = [];
for (const file of entries) {
const path = join(dir, file);
const content = await readFile(path, 'utf8');
try {
await sql.unsafe(content);
applied.push(file);
} catch (err) {
// Some errors are non-fatal (e.g. "extension already exists" race),
// but anything else should fail loudly.
if (isBenignError(err)) {
applied.push(`${file} (skipped: ${(err as Error).message.split('\n')[0]})`);
continue;
}
throw new Error(`Migration ${file} failed: ${(err as Error).message}`);
}
}
return { applied };
}
function isBenignError(err: unknown): boolean {
const msg = String((err as Error).message ?? err);
return /already exists/i.test(msg) || /duplicate key/i.test(msg);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Postgres + Timescale connection wrapper.
*
* Uses `postgres.js` for queries and connection pooling. The schema
* lives in ../../infra/init-sql/01-schema.sql and is applied by
* `migrate.ts` (and automatically on first boot by the API's
* bootstrap helper).
*/
import postgres from 'postgres';
import type { Sql } from 'postgres';
export interface PgClient {
sql: Sql;
close(): Promise<void>;
}
export function createPgClient(url: string): PgClient {
const sql = postgres(url, {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
// Reduce log noise; we use console for the rest of the app
onnotice: () => {},
});
return {
sql,
async close() {
await sql.end({ timeout: 5 });
},
};
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Redis connection wrapper. Provides one client for normal commands
* and a separate one for pub/sub (Redis requires dedicated connections
* for subscribe mode).
*/
import Redis from 'ioredis';
export interface RedisPair {
/** For GET/SET/PUBLISH etc. */
client: Redis;
/** For SUBSCRIBE only — must not be used for other commands. */
subscriber: Redis;
close(): Promise<void>;
}
export function createRedis(url: string): RedisPair {
const client = new Redis(url, {
lazyConnect: false,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
});
const subscriber = new Redis(url, {
lazyConnect: false,
maxRetriesPerRequest: null, // subscribers reconnect forever
});
return {
client,
subscriber,
async close() {
await Promise.all([client.quit().catch(() => {}), subscriber.quit().catch(() => {})]);
},
};
}
+234
View File
@@ -0,0 +1,234 @@
/**
* StateStore the interface every API caller uses to read/write
* the current universe state. Two implementations:
*
* - InMemoryStateStore : in-process; for dev when Postgres isn't up
* - PgStateStore : Postgres + Timescale; for production
*
* Both implement the same interface so the API code is identical.
*/
import type { CelestialBody, UniverseSnapshot, Vessel } from '@kerbal-rt/shared-types';
import type { Sql } from 'postgres';
// ─── Interface ─────────────────────────────────────────────────────────────
export interface StateStore {
/** Read the most recent snapshot. Returns null if none yet. */
latestSnapshot(): Promise<UniverseSnapshot | null>;
/** List celestial bodies (catalog is small, kept in memory). */
listBodies(): Promise<CelestialBody[]>;
/** List currently-known vessels. */
listVessels(): Promise<Vessel[]>;
/** Look up a single vessel by ID. */
getVessel(id: string): Promise<Vessel | null>;
/**
* Persist a new snapshot. Returns the row count for telemetry
* (rows in telemetry_snapshots, vessels upserted, etc.).
* Publishes a notification on the "snapshots" channel so any
* WebSocket subscribers (in this or another API instance) see it.
*/
applySnapshot(snap: UniverseSnapshot): Promise<{ rowsInserted: number }>;
/** Subscribe to snapshot updates. Yields the snapshot. */
subscribe(handler: (snap: UniverseSnapshot) => void): Promise<() => Promise<void>>;
}
// ─── In-memory implementation ──────────────────────────────────────────────
export class InMemoryStateStore implements StateStore {
private snapshot: UniverseSnapshot | null = null;
private vesselsById = new Map<string, Vessel>();
private bodiesById = new Map<string, CelestialBody>();
private subscribers = new Set<(snap: UniverseSnapshot) => void>();
async latestSnapshot() {
return this.snapshot;
}
async listBodies() {
return Array.from(this.bodiesById.values());
}
async listVessels() {
return Array.from(this.vesselsById.values());
}
async getVessel(id: string) {
return this.vesselsById.get(id) ?? null;
}
async applySnapshot(snap: UniverseSnapshot) {
this.snapshot = snap;
this.vesselsById = new Map(snap.vessels.map((v) => [v.id, v]));
this.bodiesById = new Map(snap.bodies.map((b) => [b.id, b]));
// Notify all subscribers asynchronously
queueMicrotask(() => {
for (const sub of this.subscribers) sub(snap);
});
return { rowsInserted: 1 };
}
async subscribe(handler: (snap: UniverseSnapshot) => void) {
this.subscribers.add(handler);
return async () => {
this.subscribers.delete(handler);
};
}
}
// ─── Postgres implementation ───────────────────────────────────────────────
interface PgStateStoreOpts {
sql: Sql;
/** Function to publish a JSON-serialized snapshot to Redis. */
publish: (payload: string) => Promise<void>;
/** Function to subscribe to snapshots over Redis. */
redisSubscribe: (handler: (payload: string) => void) => Promise<() => Promise<void>>;
}
export class PgStateStore implements StateStore {
private latest: UniverseSnapshot | null = null;
private latestCacheAt = 0;
private static readonly CACHE_TTL_MS = 1000; // 1s hot cache
constructor(private opts: PgStateStoreOpts) {}
async latestSnapshot(): Promise<UniverseSnapshot | null> {
// Hot cache: avoids hitting Postgres on every read.
if (this.latest && Date.now() - this.latestCacheAt < PgStateStore.CACHE_TTL_MS) {
return this.latest;
}
const rows = await this.opts.sql<{ payload: UniverseSnapshot }[]>`
SELECT payload FROM telemetry_snapshots
ORDER BY ts DESC LIMIT 1
`;
if (rows.length === 0) return null;
this.latest = rows[0].payload;
this.latestCacheAt = Date.now();
return this.latest;
}
async listBodies(): Promise<CelestialBody[]> {
// Body catalog is small (~17 rows) and changes rarely.
// Cache it for 30s.
if (this._bodyCache && Date.now() - this._bodyCacheAt < PgStateStore.BODY_CACHE_TTL_MS) {
return this._bodyCache;
}
const rows = await this.opts.sql<
{
id: string;
name: string;
kind: string;
parent_id: string | null;
radius: number;
sphere_of_influence: number;
gravitational_parameter: number;
rotation_period: number;
axial_tilt: number;
elements: CelestialBody['orbit'];
}[]
>`
SELECT id, name, kind, parent_id, radius, sphere_of_influence,
gravitational_parameter, rotation_period, axial_tilt, elements
FROM bodies
ORDER BY id
`;
const bodies: CelestialBody[] = rows.map((r) => ({
id: r.id,
name: r.name,
kind: r.kind as CelestialBody['kind'],
parentId: r.parent_id,
radius: r.radius,
sphereOfInfluence: r.sphere_of_influence,
gravitationalParameter: r.gravitational_parameter,
rotationPeriod: r.rotation_period,
axialTilt: r.axial_tilt,
orbit: r.elements,
}));
this._bodyCache = bodies;
this._bodyCacheAt = Date.now();
return bodies;
}
async listVessels(): Promise<Vessel[]> {
const snap = await this.latestSnapshot();
return snap?.vessels ?? [];
}
async getVessel(id: string): Promise<Vessel | null> {
const vessels = await this.listVessels();
return vessels.find((v) => v.id === id) ?? null;
}
async applySnapshot(snap: UniverseSnapshot): Promise<{ rowsInserted: number }> {
// 1) Persist the raw snapshot to Timescale (time-series).
// We also keep a "current state" copy in `vessels` for fast
// single-row lookups, and a `bodies` upsert so the catalog
// stays in sync with whatever the bridge sends.
await this.opts.sql`
INSERT INTO telemetry_snapshots (ts, ut, payload)
VALUES (now(), ${snap.ut}, ${this.opts.sql.json(JSON.parse(JSON.stringify(snap)))})
`;
// Upsert vessels (so vessel id is stable across snapshots).
for (const v of snap.vessels) {
await this.opts.sql`
INSERT INTO vessels (id, name, type, owner, status, created_at, retired_at)
VALUES (${v.id}, ${v.name}, ${v.type}, ${v.owner}, ${v.status},
${v.createdAt}::timestamptz, ${v.retiredAt}::timestamptz)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
type = EXCLUDED.type,
owner = EXCLUDED.owner,
status = EXCLUDED.status,
retired_at = EXCLUDED.retired_at
`;
}
// Upsert bodies (catalog).
for (const b of snap.bodies) {
await this.opts.sql`
INSERT INTO bodies (id, name, kind, parent_id, radius, sphere_of_influence,
gravitational_parameter, rotation_period, axial_tilt, elements)
VALUES (${b.id}, ${b.name}, ${b.kind}, ${b.parentId}, ${b.radius},
${b.sphereOfInfluence}, ${b.gravitationalParameter},
${b.rotationPeriod}, ${b.axialTilt}, ${this.opts.sql.json(JSON.parse(JSON.stringify(b.orbit)))})
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
kind = EXCLUDED.kind,
parent_id = EXCLUDED.parent_id,
radius = EXCLUDED.radius,
sphere_of_influence = EXCLUDED.sphere_of_influence,
gravitational_parameter = EXCLUDED.gravitational_parameter,
rotation_period = EXCLUDED.rotation_period,
axial_tilt = EXCLUDED.axial_tilt,
elements = EXCLUDED.elements
`;
}
// 2) Bust the hot cache.
this.latest = snap;
this.latestCacheAt = Date.now();
this._bodyCache = null;
// 3) Publish to Redis so other API instances + WS clients see it.
await this.opts.publish(JSON.stringify(snap));
return { rowsInserted: 1 };
}
async subscribe(handler: (snap: UniverseSnapshot) => void) {
return this.opts.redisSubscribe((payload) => {
try {
const snap = JSON.parse(payload) as UniverseSnapshot;
handler(snap);
} catch {
// ignore malformed messages
}
});
}
// ── private ──
private _bodyCache: CelestialBody[] | null = null;
private _bodyCacheAt = 0;
private static readonly BODY_CACHE_TTL_MS = 30_000;
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { InMemoryStateStore } from '../src/store.js';
import type { UniverseSnapshot } from '@kerbal-rt/shared-types';
function makeSnapshot(ut: number, vesselId = 'v-1'): UniverseSnapshot {
return {
ut,
capturedAt: new Date().toISOString(),
activeVesselId: vesselId,
bodies: [
{
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,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
},
],
vessels: [
{
id: vesselId,
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: [],
};
}
describe('InMemoryStateStore', () => {
it('starts empty', async () => {
const store = new InMemoryStateStore();
expect(await store.latestSnapshot()).toBeNull();
expect(await store.listVessels()).toEqual([]);
});
it('stores and returns snapshots', async () => {
const store = new InMemoryStateStore();
await store.applySnapshot(makeSnapshot(100));
const snap = await store.latestSnapshot();
expect(snap).not.toBeNull();
expect(snap!.ut).toBe(100);
});
it('looks up vessels by id', async () => {
const store = new InMemoryStateStore();
await store.applySnapshot(makeSnapshot(1, 'kasa-1'));
const v = await store.getVessel('kasa-1');
expect(v?.name).toBe('Test');
expect(await store.getVessel('missing')).toBeNull();
});
it('overwrites on subsequent snapshots', async () => {
const store = new InMemoryStateStore();
await store.applySnapshot(makeSnapshot(1));
await store.applySnapshot(makeSnapshot(2));
expect((await store.latestSnapshot())!.ut).toBe(2);
});
it('notifies subscribers when a snapshot is applied', async () => {
const store = new InMemoryStateStore();
const seen: number[] = [];
const unsub = await store.subscribe((snap) => seen.push(snap.ut));
await store.applySnapshot(makeSnapshot(1));
// allow microtask to flush
await new Promise((r) => setTimeout(r, 10));
await store.applySnapshot(makeSnapshot(2));
await new Promise((r) => setTimeout(r, 10));
expect(seen).toEqual([1, 2]);
await unsub();
await store.applySnapshot(makeSnapshot(3));
await new Promise((r) => setTimeout(r, 10));
expect(seen).toEqual([1, 2]); // unsub'd
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+131 -8
View File
@@ -28,6 +28,9 @@ importers:
'@hono/zod-validator':
specifier: ^0.4.0
version: 0.4.3(hono@4.12.23)(zod@3.25.76)
'@kerbal-rt/db':
specifier: workspace:*
version: link:../../packages/db
'@kerbal-rt/orbital-math':
specifier: workspace:*
version: link:../../packages/orbital-math
@@ -137,6 +140,44 @@ importers:
specifier: ^5.4.6
version: 5.4.21(@types/node@22.19.19)
apps/tools/mock-telemetry:
dependencies:
'@kerbal-rt/orbital-math':
specifier: workspace:*
version: link:../../../packages/orbital-math
'@kerbal-rt/shared-types':
specifier: workspace:*
version: link:../../../packages/shared-types
devDependencies:
'@types/node':
specifier: ^22.5.0
version: 22.19.19
tsx:
specifier: ^4.19.1
version: 4.22.4
typescript:
specifier: ^5.6.2
version: 5.9.3
packages/db:
dependencies:
'@kerbal-rt/shared-types':
specifier: workspace:*
version: link:../shared-types
ioredis:
specifier: ^5.4.1
version: 5.11.0
postgres:
specifier: ^3.4.4
version: 3.4.9
devDependencies:
typescript:
specifier: ^5.6.2
version: 5.9.3
vitest:
specifier: ^2.1.1
version: 2.1.9(@types/node@22.19.19)
packages/orbital-math:
dependencies:
'@kerbal-rt/shared-types':
@@ -859,6 +900,12 @@ packages:
}
deprecated: Use @eslint/object-schema instead
'@ioredis/commands@1.10.0':
resolution:
{
integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==,
}
'@isaacs/cliui@8.0.2':
resolution:
{
@@ -1981,6 +2028,13 @@ packages:
integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==,
}
cluster-key-slot@1.1.1:
resolution:
{
integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==,
}
engines: { node: '>=0.10.0' }
color-convert@2.0.1:
resolution:
{
@@ -2096,6 +2150,13 @@ packages:
}
engines: { node: '>= 0.4' }
denque@2.1.0:
resolution:
{
integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==,
}
engines: { node: '>=0.10' }
doctrine@2.1.0:
resolution:
{
@@ -2725,6 +2786,13 @@ packages:
}
engines: { node: '>= 0.4' }
ioredis@5.11.0:
resolution:
{
integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==,
}
engines: { node: '>=12.22.0' }
is-array-buffer@3.0.5:
resolution:
{
@@ -3357,6 +3425,13 @@ packages:
}
engines: { node: ^10 || ^12 || >=14 }
postgres@3.4.9:
resolution:
{
integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==,
}
engines: { node: '>=12' }
prelude-ls@1.2.1:
resolution:
{
@@ -3419,6 +3494,20 @@ packages:
}
engines: { node: '>=0.10.0' }
redis-errors@1.2.0:
resolution:
{
integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==,
}
engines: { node: '>=4' }
redis-parser@3.0.0:
resolution:
{
integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==,
}
engines: { node: '>=4' }
reflect.getprototypeof@1.0.10:
resolution:
{
@@ -3620,6 +3709,12 @@ packages:
integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==,
}
standard-as-callback@2.1.0:
resolution:
{
integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==,
}
std-env@3.10.0:
resolution:
{
@@ -4422,6 +4517,8 @@ snapshots:
'@humanwhocodes/object-schema@2.0.3': {}
'@ioredis/commands@1.10.0': {}
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -5055,6 +5152,8 @@ snapshots:
client-only@0.0.1: {}
cluster-key-slot@1.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -5117,6 +5216,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
denque@2.1.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -5309,8 +5410,8 @@ snapshots:
'@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -5329,7 +5430,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -5340,22 +5441,22 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -5366,7 +5467,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -5681,6 +5782,18 @@ snapshots:
hasown: 2.0.4
side-channel: 1.1.0
ioredis@5.11.0:
dependencies:
'@ioredis/commands': 1.10.0
cluster-key-slot: 1.1.1
debug: 4.4.3
denque: 2.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -6048,6 +6161,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres@3.4.9: {}
prelude-ls@1.2.1: {}
prettier@3.8.3: {}
@@ -6076,6 +6191,12 @@ snapshots:
dependencies:
loose-envify: 1.4.0
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -6243,6 +6364,8 @@ snapshots:
stackback@0.0.2: {}
standard-as-callback@2.1.0: {}
std-env@3.10.0: {}
stop-iteration-iterator@1.1.0:
+1
View File
@@ -1,3 +1,4 @@
packages:
- 'apps/*'
- 'apps/**'
- 'packages/*'