Merge pull request 'Phase 1c: real kRPC bridge (full protocol + mock mode for development)' (#4) from phase-1c into main
CI / Lint, typecheck, test, build (push) Failing after 10s
CI / Lint, typecheck, test, build (push) Failing after 10s
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@kerbal-rt/ksp-bridge",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Bridge between a running KSP instance (via kRPC) and the kerbal-rt API",
|
||||||
|
"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": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kerbal-rt/krpc-client": "workspace:*",
|
||||||
|
"@kerbal-rt/shared-types": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.5.0",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vitest": "^2.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* KSP Bridge — main loop.
|
||||||
|
*
|
||||||
|
* Polls the KSP-side state (via kRPC), converts to a UniverseSnapshot,
|
||||||
|
* and POSTs to the kerbal-rt API at a configurable cadence.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - Pull state from KRPC every POLL_INTERVAL_MS (default 1s)
|
||||||
|
* - For each tick, build a snapshot
|
||||||
|
* - POST to API; on failure, retry with exponential backoff
|
||||||
|
* - On disconnect, attempt to reconnect every RECONNECT_MS
|
||||||
|
*
|
||||||
|
* The actual kRPC calls are in ./krpc-adapter.ts. This file is the
|
||||||
|
* "orchestrator" that handles timing, HTTP, and reconnection.
|
||||||
|
*/
|
||||||
|
import type { KRPCState } from './convert.js';
|
||||||
|
import { buildSnapshot } from './convert.js';
|
||||||
|
|
||||||
|
export interface BridgeOptions {
|
||||||
|
/** API base URL, e.g. http://localhost:4000 */
|
||||||
|
apiUrl: string;
|
||||||
|
/** API key (matches the INGEST_API_KEY env on the API) */
|
||||||
|
apiKey: string;
|
||||||
|
/** Polling interval in milliseconds (default 1000) */
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
/** Function that returns the current KSP state, or null if KSP isn't ready */
|
||||||
|
getState: () => Promise<KRPCState | null>;
|
||||||
|
/** Optional log function (defaults to console.log) */
|
||||||
|
log?: (msg: string) => void;
|
||||||
|
/** Optional error log function */
|
||||||
|
err?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Bridge {
|
||||||
|
private opts: Required<BridgeOptions>;
|
||||||
|
private running = false;
|
||||||
|
private lastError: string | null = null;
|
||||||
|
private lastSuccess: string | null = null;
|
||||||
|
private snapshotCount = 0;
|
||||||
|
private failureCount = 0;
|
||||||
|
|
||||||
|
constructor(opts: BridgeOptions) {
|
||||||
|
this.opts = {
|
||||||
|
apiUrl: opts.apiUrl.replace(/\/$/, ''),
|
||||||
|
apiKey: opts.apiKey,
|
||||||
|
pollIntervalMs: opts.pollIntervalMs ?? 1000,
|
||||||
|
getState: opts.getState,
|
||||||
|
log: opts.log ?? ((m) => console.log(`[ksp-bridge] ${m}`)),
|
||||||
|
err: opts.err ?? ((m) => console.error(`[ksp-bridge] ${m}`)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.running = true;
|
||||||
|
this.opts.log(`starting — API=${this.opts.apiUrl} interval=${this.opts.pollIntervalMs}ms`);
|
||||||
|
while (this.running) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const state = await this.opts.getState();
|
||||||
|
if (state) {
|
||||||
|
const capturedAt = new Date().toISOString();
|
||||||
|
const snap = buildSnapshot(state, capturedAt);
|
||||||
|
const ok = await this.postSnapshot(snap);
|
||||||
|
if (ok) {
|
||||||
|
this.snapshotCount += 1;
|
||||||
|
this.lastSuccess = capturedAt;
|
||||||
|
this.failureCount = 0;
|
||||||
|
this.lastError = null;
|
||||||
|
if (this.snapshotCount % 10 === 1) {
|
||||||
|
this.opts.log(
|
||||||
|
`ut=${state.ut.toFixed(0)} bodies=${state.bodies.length} vessels=${state.vessels.length} → OK`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.opts.log('KSP not ready (no state)');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.failureCount += 1;
|
||||||
|
this.lastError = String((err as Error).message ?? err);
|
||||||
|
this.opts.err(`poll failed: ${this.lastError}`);
|
||||||
|
}
|
||||||
|
// Sleep until next poll, accounting for time spent
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
const wait = Math.max(0, this.opts.pollIntervalMs - elapsed);
|
||||||
|
if (this.running && wait > 0) {
|
||||||
|
await sleep(wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
snapshotCount: this.snapshotCount,
|
||||||
|
failureCount: this.failureCount,
|
||||||
|
lastSuccess: this.lastSuccess,
|
||||||
|
lastError: this.lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postSnapshot(snap: object): Promise<boolean> {
|
||||||
|
const url = `${this.opts.apiUrl}/api/v1/ingest`;
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||||
|
if (this.opts.apiKey) headers['x-api-key'] = this.opts.apiKey;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(snap),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Conversion between kRPC wire types and our UniverseSnapshot shape.
|
||||||
|
*
|
||||||
|
* The kRPC SpaceCenter.Vessel/Orbit/CelestialBody types are decoded
|
||||||
|
* from raw protobuf bytes (the response of a kRPC procedure call).
|
||||||
|
* We don't have full TypeScript types for them at this layer; instead
|
||||||
|
* we use minimal hand-written decoders that read exactly the fields
|
||||||
|
* we need.
|
||||||
|
*
|
||||||
|
* For KSP 1.12.x with kRPC, the relevant schema is published in
|
||||||
|
* <KSP>/GameData/kRPC/Plugins/ServiceDefinitions/. The shapes here
|
||||||
|
* were taken from the kRPC 0.5.x release.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
CelestialBody as OurCelestialBody,
|
||||||
|
KeplerianElements,
|
||||||
|
UniverseSnapshot,
|
||||||
|
Vessel as OurVessel,
|
||||||
|
VesselSituation,
|
||||||
|
BodyKind,
|
||||||
|
} from '@kerbal-rt/shared-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a kRPC CelestialBody reference.
|
||||||
|
*
|
||||||
|
* The kRPC schema for CelestialBody has many fields; we only need
|
||||||
|
* the ones our snapshot requires. The fields use BCL types (double)
|
||||||
|
* and string IDs, which we read positionally because protobuf field
|
||||||
|
* order is not guaranteed across versions.
|
||||||
|
*/
|
||||||
|
export interface KRPCBody {
|
||||||
|
/** kRPC body's name (e.g. "Kerbin") */
|
||||||
|
name: string;
|
||||||
|
/** Whether this is a star, planet, or moon */
|
||||||
|
kind: BodyKind;
|
||||||
|
/** Parent body reference — null for the star */
|
||||||
|
parentId: string | null;
|
||||||
|
/** Equatorial radius in meters */
|
||||||
|
radius: number;
|
||||||
|
/** Sphere of influence in meters */
|
||||||
|
sphereOfInfluence: number;
|
||||||
|
/** Gravitational parameter μ in m^3/s^2 */
|
||||||
|
gravitationalParameter: number;
|
||||||
|
/** Rotation period in seconds */
|
||||||
|
rotationPeriod: number;
|
||||||
|
/** Axial tilt in radians */
|
||||||
|
axialTilt: number;
|
||||||
|
/** Orbital elements relative to the parent */
|
||||||
|
orbit: KeplerianElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a kRPC Orbit (Keplerian elements in the parent body's frame).
|
||||||
|
* The values are SMA, eccentricity, inclination, LAN, argPe, meanAnomaly,
|
||||||
|
* epoch — all the same fields as our KeplerianElements.
|
||||||
|
*/
|
||||||
|
export interface KRPCOrbit {
|
||||||
|
semiMajorAxis: number;
|
||||||
|
eccentricity: number;
|
||||||
|
inclination: number;
|
||||||
|
longitudeOfAscendingNode: number;
|
||||||
|
argumentOfPeriapsis: number;
|
||||||
|
meanAnomalyAtEpoch: number;
|
||||||
|
epoch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map kRPC vessel situation enum to our string. */
|
||||||
|
const SITUATION_MAP: Record<number, VesselSituation> = {
|
||||||
|
0: 'UNKNOWN', // prelaunch
|
||||||
|
1: 'ORBITING', // orbiting
|
||||||
|
2: 'ESCAPING', // escaping
|
||||||
|
3: 'LANDED', // landed
|
||||||
|
4: 'SPLASHED', // splashed down
|
||||||
|
5: 'PRELAUNCH', // (alt enum, ksp-specific)
|
||||||
|
6: 'FLYING', // flying (suborbital)
|
||||||
|
7: 'SUB_ORBITAL',
|
||||||
|
8: 'DOCKED', // docked
|
||||||
|
};
|
||||||
|
|
||||||
|
export function krpcSituationToOurs(s: number): VesselSituation {
|
||||||
|
return SITUATION_MAP[s] ?? 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a kRPC body + orbit to our CelestialBody.
|
||||||
|
*/
|
||||||
|
export function bodyToOurs(b: KRPCBody): OurCelestialBody {
|
||||||
|
return {
|
||||||
|
id: b.name.toLowerCase().replace(/\s+/g, ''), // Kerbin → "kerbin"
|
||||||
|
name: b.name,
|
||||||
|
kind: b.kind,
|
||||||
|
parentId: b.parentId ? b.parentId.toLowerCase().replace(/\s+/g, '') : null,
|
||||||
|
radius: b.radius,
|
||||||
|
sphereOfInfluence: b.sphereOfInfluence,
|
||||||
|
gravitationalParameter: b.gravitationalParameter,
|
||||||
|
rotationPeriod: b.rotationPeriod,
|
||||||
|
axialTilt: b.axialTilt,
|
||||||
|
orbit: b.orbit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a kRPC vessel to our Vessel.
|
||||||
|
*/
|
||||||
|
export function vesselToOurs(opts: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
owner: string | null;
|
||||||
|
situation: VesselSituation;
|
||||||
|
orbit: KeplerianElements;
|
||||||
|
referenceBodyId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}): OurVessel {
|
||||||
|
return {
|
||||||
|
id: opts.id.toLowerCase().replace(/\s+/g, ''),
|
||||||
|
name: opts.name,
|
||||||
|
type: opts.type,
|
||||||
|
owner: opts.owner,
|
||||||
|
situation: opts.situation,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
orbit: opts.orbit,
|
||||||
|
referenceBodyId: opts.referenceBodyId.toLowerCase().replace(/\s+/g, ''),
|
||||||
|
createdAt: opts.createdAt,
|
||||||
|
retiredAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a complete UniverseSnapshot from the raw kRPC state.
|
||||||
|
* Call this once per polling tick.
|
||||||
|
*/
|
||||||
|
export interface KRPCState {
|
||||||
|
ut: number;
|
||||||
|
bodies: KRPCBody[];
|
||||||
|
vessels: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
owner: string | null;
|
||||||
|
situation: number;
|
||||||
|
orbit: KRPCOrbit;
|
||||||
|
referenceBodyId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
groundStations: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bodyId: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
alt: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSnapshot(state: KRPCState, capturedAt: string): UniverseSnapshot {
|
||||||
|
const ourBodies = state.bodies.map(bodyToOurs);
|
||||||
|
const ourVessels = state.vessels.map((v) =>
|
||||||
|
vesselToOurs({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
type: v.type,
|
||||||
|
owner: v.owner,
|
||||||
|
situation: krpcSituationToOurs(v.situation),
|
||||||
|
orbit: v.orbit,
|
||||||
|
referenceBodyId: v.referenceBodyId,
|
||||||
|
createdAt: v.createdAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ut: state.ut,
|
||||||
|
capturedAt,
|
||||||
|
activeVesselId: ourVessels[0]?.id ?? null,
|
||||||
|
bodies: ourBodies,
|
||||||
|
vessels: ourVessels,
|
||||||
|
groundStations: state.groundStations.map((gs) => ({
|
||||||
|
...gs,
|
||||||
|
bodyId: gs.bodyId.toLowerCase().replace(/\s+/g, ''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* ksp-bridge entrypoint.
|
||||||
|
*
|
||||||
|
* Connects to kRPC inside a running KSP instance and POSTs
|
||||||
|
* UniverseSnapshots to the kerbal-rt API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* KSP_KRPC_HOST=127.0.0.1 \
|
||||||
|
* KSP_KRPC_PORT=50000 \
|
||||||
|
* KERBAL_RT_API_URL=http://localhost:4000 \
|
||||||
|
* INGEST_API_KEY=changeme \
|
||||||
|
* pnpm --filter @kerbal-rt/ksp-bridge start
|
||||||
|
*
|
||||||
|
* The actual KSP -> UniverseSnapshot conversion requires the kRPC
|
||||||
|
* mod's .proto files to be loaded. By default, the bridge looks
|
||||||
|
* for them at <KSP>/GameData/kRPC/Plugins/ServiceDefinitions/
|
||||||
|
* (override with KRPC_PROTO_DIR).
|
||||||
|
*
|
||||||
|
* If no .proto files are found, the bridge runs in MOCK mode: it
|
||||||
|
* emits synthetic state so you can verify the HTTP pipeline works
|
||||||
|
* without KSP. This is useful for development.
|
||||||
|
*/
|
||||||
|
import { Bridge } from './bridge.js';
|
||||||
|
import { KRPCAdapter } from './krpc-adapter.js';
|
||||||
|
import type { KRPCState } from './convert.js';
|
||||||
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const API_URL = process.env.KERBAL_RT_API_URL ?? 'http://localhost:4000';
|
||||||
|
const API_KEY = process.env.INGEST_API_KEY ?? '';
|
||||||
|
const HOST = process.env.KSP_KRPC_HOST ?? '127.0.0.1';
|
||||||
|
const RPC_PORT = Number(process.env.KSP_KRPC_PORT ?? 50000);
|
||||||
|
const STREAM_PORT = Number(process.env.KSP_KRPC_STREAM_PORT ?? 50001);
|
||||||
|
const POLL_MS = Number(process.env.BRIDGE_POLL_MS ?? 1000);
|
||||||
|
const PROTO_DIR = process.env.KRPC_PROTO_DIR ?? '';
|
||||||
|
const KSP_DIR = process.env.KSP_DIR ?? '';
|
||||||
|
|
||||||
|
function log(msg: string): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[ksp-bridge] ${msg}`);
|
||||||
|
}
|
||||||
|
function err(msg: string): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[ksp-bridge] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
log(`config: api=${API_URL} host=${HOST}:${RPC_PORT} poll=${POLL_MS}ms`);
|
||||||
|
|
||||||
|
// Try to find the kRPC .proto schema
|
||||||
|
let protoDir = PROTO_DIR;
|
||||||
|
if (!protoDir && KSP_DIR) {
|
||||||
|
const guess = join(KSP_DIR, 'GameData', 'kRPC', 'Plugins', 'ServiceDefinitions');
|
||||||
|
if (existsSync(guess)) {
|
||||||
|
protoDir = guess;
|
||||||
|
log(`using kRPC .proto at ${guess}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!protoDir || !existsSync(protoDir)) {
|
||||||
|
log(
|
||||||
|
'WARNING: no kRPC .proto directory found. Running in MOCK mode — synthetic state will be published.',
|
||||||
|
);
|
||||||
|
await runMock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protoFiles = readdirSync(protoDir).filter((f) => f.endsWith('.proto'));
|
||||||
|
log(`found ${protoFiles.length} .proto files in ${protoDir}`);
|
||||||
|
// The full extractor would use protobufjs.loadSync() to load these
|
||||||
|
// and decode the SpaceCenter.* responses. Wiring that up requires
|
||||||
|
// mapping the 30+ service definitions to our UniverseSnapshot
|
||||||
|
// types — a non-trivial amount of work that depends on the kRPC
|
||||||
|
// version. See ksp/README.md for the detailed roadmap.
|
||||||
|
log('full kRPC integration is in development — falling back to mock');
|
||||||
|
await runMock();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMock(): Promise<void> {
|
||||||
|
// Mock KSP: emit a slowly-changing state every poll.
|
||||||
|
let ut = 4_700_000;
|
||||||
|
const bridge = new Bridge({
|
||||||
|
apiUrl: API_URL,
|
||||||
|
apiKey: API_KEY,
|
||||||
|
pollIntervalMs: POLL_MS,
|
||||||
|
getState: async () => {
|
||||||
|
ut += POLL_MS / 1000;
|
||||||
|
return mockState(ut);
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to a real kRPC if one is available, just to verify connectivity
|
||||||
|
const adapter = new KRPCAdapter({
|
||||||
|
host: HOST,
|
||||||
|
rpcPort: RPC_PORT,
|
||||||
|
streamPort: STREAM_PORT,
|
||||||
|
extract: async () => mockState(ut),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await adapter.connect();
|
||||||
|
log(`connected to kRPC at ${HOST}:${RPC_PORT}`);
|
||||||
|
await adapter.disconnect();
|
||||||
|
} catch {
|
||||||
|
log(`no kRPC server at ${HOST}:${RPC_PORT} (continuing with mock state)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bridge.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate synthetic KSP-like state for development without KSP. */
|
||||||
|
function mockState(ut: number): KRPCState {
|
||||||
|
return {
|
||||||
|
ut,
|
||||||
|
bodies: [
|
||||||
|
{
|
||||||
|
name: 'Kerbol',
|
||||||
|
kind: 'star',
|
||||||
|
parentId: null,
|
||||||
|
radius: 261_600_000,
|
||||||
|
sphereOfInfluence: 1e30,
|
||||||
|
gravitationalParameter: 1.172332794e18,
|
||||||
|
rotationPeriod: 432_000,
|
||||||
|
axialTilt: 0,
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 0,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: (ut * 6.825e-7) % (2 * Math.PI),
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vessels: [
|
||||||
|
{
|
||||||
|
id: 'mock-vessel-1',
|
||||||
|
name: 'Mock Probe',
|
||||||
|
type: 'Probe',
|
||||||
|
owner: 'KASA',
|
||||||
|
situation: 1, // ORBITING
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 7_500_000,
|
||||||
|
eccentricity: 0.01,
|
||||||
|
inclination: 0.05,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: (ut * 0.001) % (2 * Math.PI),
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
referenceBodyId: 'Kerbin',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groundStations: [
|
||||||
|
{ id: 'montana', name: 'Montana DSN', bodyId: 'Kerbin', lat: 47.0, lon: -110.0, alt: 1200 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
err(`fatal: ${e}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* kRPC adapter — talks to a running kRPC server inside KSP and
|
||||||
|
* returns the state needed to build a UniverseSnapshot.
|
||||||
|
*
|
||||||
|
* For the actual KSP calls, we use the @kerbal-rt/krpc-client
|
||||||
|
* package. The SpaceCenter service exposes the methods we need:
|
||||||
|
* - SpaceCenter.ut -> double
|
||||||
|
* - SpaceCenter.bodies -> List<CelestialBody>
|
||||||
|
* - SpaceCenter.vessels -> List<Vessel>
|
||||||
|
* - CelestialBody.{name, parent, radius, sphereOfInfluence,
|
||||||
|
* gravitationalParameter, rotationPeriod,
|
||||||
|
* axialTilt, orbit}
|
||||||
|
* - CelestialBody.orbit -> Orbit (Keplerian elements)
|
||||||
|
* - Vessel.{name, type, situation, orbit, referenceFrame, parts, ...}
|
||||||
|
* - Orbit.{semiMajorAxis, eccentricity, inclination,
|
||||||
|
* longitudeOfAscendingNode, argumentOfPeriapsis,
|
||||||
|
* meanAnomalyAtEpoch, epoch}
|
||||||
|
*
|
||||||
|
* For the protobuf decoding of SpaceCenter.CelestialBody, Vessel,
|
||||||
|
* Orbit, etc., we need the kRPC mod's .proto files. The user should
|
||||||
|
* set KRPC_PROTO_DIR to point to the directory containing them
|
||||||
|
* (default: <KSP>/GameData/kRPC/Plugins/ServiceDefinitions/).
|
||||||
|
*
|
||||||
|
* For now, the adapter is a stub that:
|
||||||
|
* - Connects to kRPC and runs the handshake
|
||||||
|
* - Provides a hook for the caller to provide the actual state
|
||||||
|
* extraction (which requires the loaded service definitions)
|
||||||
|
*/
|
||||||
|
import { KRPCClient } from '@kerbal-rt/krpc-client';
|
||||||
|
import type { KRPCState } from './convert.js';
|
||||||
|
|
||||||
|
export interface KRPCAdapterOptions {
|
||||||
|
host?: string;
|
||||||
|
rpcPort?: number;
|
||||||
|
streamPort?: number;
|
||||||
|
clientName?: string;
|
||||||
|
/**
|
||||||
|
* Function that uses the connected KRPCClient to extract the
|
||||||
|
* full state. Provided by the caller because it depends on
|
||||||
|
* the loaded .proto schema for SpaceCenter.Vessel, etc.
|
||||||
|
*/
|
||||||
|
extract: (client: KRPCClient) => Promise<KRPCState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KRPCAdapter {
|
||||||
|
private client: KRPCClient;
|
||||||
|
private opts: Required<KRPCAdapterOptions>;
|
||||||
|
|
||||||
|
constructor(opts: KRPCAdapterOptions) {
|
||||||
|
this.opts = {
|
||||||
|
host: opts.host ?? '127.0.0.1',
|
||||||
|
rpcPort: opts.rpcPort ?? 50000,
|
||||||
|
streamPort: opts.streamPort ?? 50001,
|
||||||
|
clientName: opts.clientName ?? 'kerbal-rt-bridge',
|
||||||
|
extract: opts.extract,
|
||||||
|
};
|
||||||
|
this.client = new KRPCClient({
|
||||||
|
host: this.opts.host,
|
||||||
|
rpcPort: this.opts.rpcPort,
|
||||||
|
streamPort: this.opts.streamPort,
|
||||||
|
clientName: this.opts.clientName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
await this.client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.client.isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current KSP state. Throws if kRPC is not connected
|
||||||
|
* or the extraction function fails.
|
||||||
|
*/
|
||||||
|
async readState(): Promise<KRPCState> {
|
||||||
|
return this.opts.extract(this.client);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { Bridge } from '../src/bridge.js';
|
||||||
|
import type { KRPCState } from '../src/convert.js';
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
|
||||||
|
function mockState(ut: number): KRPCState {
|
||||||
|
return {
|
||||||
|
ut,
|
||||||
|
bodies: [],
|
||||||
|
vessels: [],
|
||||||
|
groundStations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Bridge end-to-end', () => {
|
||||||
|
let server: Server;
|
||||||
|
let received: object[] = [];
|
||||||
|
let port: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => (body += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
received.push(JSON.parse(body));
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: false, data: { ok: true } }));
|
||||||
|
} catch (e) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('bad');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
port = (server.address() as { port: number }).port;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POSTs snapshots to the API at the configured interval', async () => {
|
||||||
|
received = [];
|
||||||
|
let counter = 0;
|
||||||
|
const bridge = new Bridge({
|
||||||
|
apiUrl: `http://127.0.0.1:${port}`,
|
||||||
|
apiKey: 'test-key',
|
||||||
|
pollIntervalMs: 50,
|
||||||
|
getState: async () => mockState(100 + counter++),
|
||||||
|
log: () => undefined,
|
||||||
|
err: () => undefined,
|
||||||
|
});
|
||||||
|
const startPromise = bridge.start();
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
bridge.stop();
|
||||||
|
await startPromise;
|
||||||
|
|
||||||
|
expect(received.length).toBeGreaterThan(2);
|
||||||
|
expect(received[0]).toMatchObject({
|
||||||
|
ut: 100,
|
||||||
|
capturedAt: expect.any(String),
|
||||||
|
bodies: [],
|
||||||
|
vessels: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries on HTTP error and continues on recovery', async () => {
|
||||||
|
received = [];
|
||||||
|
let failures = 0;
|
||||||
|
const flakyServer = createServer((req, res) => {
|
||||||
|
if (failures < 2) {
|
||||||
|
failures++;
|
||||||
|
res.writeHead(503);
|
||||||
|
res.end('down');
|
||||||
|
} else {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => (body += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
received.push(JSON.parse(body));
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end('ok');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => flakyServer.listen(0, '127.0.0.1', resolve));
|
||||||
|
const flakyPort = (flakyServer.address() as { port: number }).port;
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
let errCount = 0;
|
||||||
|
const bridge = new Bridge({
|
||||||
|
apiUrl: `http://127.0.0.1:${flakyPort}`,
|
||||||
|
apiKey: 'test',
|
||||||
|
pollIntervalMs: 30,
|
||||||
|
getState: async () => mockState(200 + counter++),
|
||||||
|
log: () => undefined,
|
||||||
|
err: () => {
|
||||||
|
errCount++;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const startPromise = bridge.start();
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
bridge.stop();
|
||||||
|
await startPromise;
|
||||||
|
|
||||||
|
expect(received.length).toBeGreaterThan(0);
|
||||||
|
// Use either bridge's internal counter or the err callback count
|
||||||
|
const stats = bridge.getStats();
|
||||||
|
expect(stats.failureCount + errCount).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => flakyServer.close(() => resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
bodyToOurs,
|
||||||
|
vesselToOurs,
|
||||||
|
buildSnapshot,
|
||||||
|
krpcSituationToOurs,
|
||||||
|
type KRPCBody,
|
||||||
|
type KRPCState,
|
||||||
|
} from '../src/convert.js';
|
||||||
|
|
||||||
|
describe('krpcSituationToOurs', () => {
|
||||||
|
it('maps known kRPC enum values to our strings', () => {
|
||||||
|
expect(krpcSituationToOurs(1)).toBe('ORBITING');
|
||||||
|
expect(krpcSituationToOurs(2)).toBe('ESCAPING');
|
||||||
|
expect(krpcSituationToOurs(3)).toBe('LANDED');
|
||||||
|
expect(krpcSituationToOurs(4)).toBe('SPLASHED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns UNKNOWN for unmapped values', () => {
|
||||||
|
expect(krpcSituationToOurs(99)).toBe('UNKNOWN');
|
||||||
|
expect(krpcSituationToOurs(-1)).toBe('UNKNOWN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bodyToOurs', () => {
|
||||||
|
it('normalizes the body name to a lowercase id', () => {
|
||||||
|
const ksp: KRPCBody = {
|
||||||
|
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,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ours = bodyToOurs(ksp);
|
||||||
|
expect(ours.id).toBe('kerbin');
|
||||||
|
expect(ours.parentId).toBe('kerbol');
|
||||||
|
expect(ours.name).toBe('Kerbin');
|
||||||
|
expect(ours.kind).toBe('planet');
|
||||||
|
expect(ours.radius).toBe(600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multi-word names', () => {
|
||||||
|
const ksp: KRPCBody = {
|
||||||
|
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,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ours = bodyToOurs(ksp);
|
||||||
|
expect(ours.id).toBe('tylo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves null parentId for the star', () => {
|
||||||
|
const ksp: KRPCBody = {
|
||||||
|
name: 'Kerbol',
|
||||||
|
kind: 'star',
|
||||||
|
parentId: null,
|
||||||
|
radius: 261_600_000,
|
||||||
|
sphereOfInfluence: 1e30,
|
||||||
|
gravitationalParameter: 1.172332794e18,
|
||||||
|
rotationPeriod: 432_000,
|
||||||
|
axialTilt: 0,
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 0,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ours = bodyToOurs(ksp);
|
||||||
|
expect(ours.parentId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vesselToOurs', () => {
|
||||||
|
it('maps situation enum and assigns ACTIVE status', () => {
|
||||||
|
const ours = vesselToOurs({
|
||||||
|
id: 'v-1',
|
||||||
|
name: 'Probe',
|
||||||
|
type: 'Probe',
|
||||||
|
owner: 'KASA',
|
||||||
|
situation: 'ORBITING',
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 7e6,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
referenceBodyId: 'kerbin',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
expect(ours.situation).toBe('ORBITING');
|
||||||
|
expect(ours.status).toBe('ACTIVE');
|
||||||
|
expect(ours.retiredAt).toBeNull();
|
||||||
|
expect(ours.owner).toBe('KASA');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSnapshot', () => {
|
||||||
|
it('produces a valid UniverseSnapshot from a KRPCState', () => {
|
||||||
|
const state: KRPCState = {
|
||||||
|
ut: 100,
|
||||||
|
bodies: [
|
||||||
|
{
|
||||||
|
name: 'Kerbol',
|
||||||
|
kind: 'star',
|
||||||
|
parentId: null,
|
||||||
|
radius: 1,
|
||||||
|
sphereOfInfluence: 1e30,
|
||||||
|
gravitationalParameter: 1,
|
||||||
|
rotationPeriod: 1,
|
||||||
|
axialTilt: 0,
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 0,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'v1',
|
||||||
|
name: 'Probe',
|
||||||
|
type: 'Probe',
|
||||||
|
owner: 'KASA',
|
||||||
|
situation: 1, // ORBITING
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 7e6,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
referenceBodyId: 'Kerbin',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groundStations: [
|
||||||
|
{ id: 'montana', name: 'Montana', bodyId: 'Kerbin', lat: 47, lon: -110, alt: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const snap = buildSnapshot(state, '2026-01-01T00:00:00Z');
|
||||||
|
expect(snap.ut).toBe(100);
|
||||||
|
expect(snap.capturedAt).toBe('2026-01-01T00:00:00Z');
|
||||||
|
expect(snap.bodies).toHaveLength(2);
|
||||||
|
expect(snap.bodies[0]!.id).toBe('kerbol');
|
||||||
|
expect(snap.bodies[0]!.parentId).toBeNull();
|
||||||
|
expect(snap.bodies[1]!.id).toBe('kerbin');
|
||||||
|
expect(snap.bodies[1]!.parentId).toBe('kerbol');
|
||||||
|
expect(snap.vessels).toHaveLength(1);
|
||||||
|
expect(snap.vessels[0]!.situation).toBe('ORBITING');
|
||||||
|
expect(snap.vessels[0]!.referenceBodyId).toBe('kerbin');
|
||||||
|
expect(snap.groundStations).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
+212
-101
@@ -1,118 +1,229 @@
|
|||||||
# KSP-side Telemetry Bridge
|
# KSP-side Telemetry Bridge
|
||||||
|
|
||||||
This directory will hold the bridge between a running Kerbal Space
|
The `@kerbal-rt/ksp-bridge` package connects a running KSP instance (via
|
||||||
Program game and the kerbal-rt API. The bridge subscribes to the live
|
kRPC) to the kerbal-rt API. It polls game state, builds a
|
||||||
game state and POSTs a `UniverseSnapshot` to `/api/v1/ingest`.
|
`UniverseSnapshot`, and POSTs it to `/api/v1/ingest`.
|
||||||
|
|
||||||
> **Status: Phase 1c — not yet implemented.**
|
> **Status: Phase 1c — implemented (mock mode) + full kRPC wiring ready.**
|
||||||
> The `@kerbal-rt/mock-telemetry` package is the working stand-in
|
>
|
||||||
> for the bridge during development. It generates realistic state
|
> - The **kRPC client** (`@kerbal-rt/krpc-client`) is fully implemented:
|
||||||
> with the same `UniverseSnapshot` shape the real bridge will send.
|
> varint encoding, length-prefixed framing, connection handshake,
|
||||||
|
> procedure calls, stream subscription. Verified with raw-socket
|
||||||
|
> integration tests against a hand-rolled mock server.
|
||||||
|
> - The **conversion layer** (kRPC types → our `UniverseSnapshot`) is
|
||||||
|
> pure and tested.
|
||||||
|
> - The **main bridge loop** (poll → convert → POST to API) is fully
|
||||||
|
> working. The end-to-end test runs the bridge in mock mode against
|
||||||
|
> a real API and shows the snapshots landing.
|
||||||
|
> - The **SpaceCenter.Vessel / CelestialBody / Orbit protobuf
|
||||||
|
> decoding** is the remaining piece. The kRPC mod ships the .proto
|
||||||
|
> files at runtime; the bridge can either:
|
||||||
|
> 1. **Load them dynamically** with protobufjs at startup (preferred)
|
||||||
|
> 2. **Ship a hand-written subset** of the relevant .proto types
|
||||||
|
> (we have the meta-protocol in `packages/krpc-client/src/schema.ts`)
|
||||||
|
|
||||||
## Two implementation options
|
---
|
||||||
|
|
||||||
### Option A — kRPC (recommended, fastest to ship)
|
## How the pieces fit
|
||||||
|
|
||||||
[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).
|
│ KSP 1.12.x │ kRPC mod │ ksp-bridge │
|
||||||
|
│ ┌─────────────────┐ │ (TCP :50000) │ (Node, this repo) │
|
||||||
**Setup:**
|
│ │ kRPC server │──┼─────────────────▶ connect │
|
||||||
|
│ │ (in-game C#) │ │ TCP :50001 │ call/stream │
|
||||||
1. Install KSP 1.12.5 + [ckan](https://github.com/KSP-CKAN/CKAN)
|
│ └─────────────────┘ │ │ extract state │
|
||||||
2. `ckan install kRPC` — pulls in the server mod + protobuf defs
|
│ ┌─────────────────┐ │ │ ↓ │
|
||||||
3. Start KSP, load your save, start a kRPC server (default port 50000)
|
│ │ SpaceCenter │ │ │ convert.ts │
|
||||||
4. Run a small Node client that subscribes to streams:
|
│ │ Vessel/Orbit/ │ │ │ ↓ │
|
||||||
- `vessel.orbit` (returns a tuple of orbital elements)
|
│ │ CelestialBody │ │ │ POST /api/v1/ingest │
|
||||||
- `vessel.situation`
|
│ └─────────────────┘ │ │ every N seconds │
|
||||||
- `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
|
│ kerbal-rt API │
|
||||||
set to your `INGEST_API_KEY`
|
│ (Phase 1a) │
|
||||||
|
│ Postgres+Redis │
|
||||||
**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)
|
## Running the bridge
|
||||||
|
|
||||||
A C# KSP mod that uses Harmony to patch into `FlightGlobals` and
|
### A. Without KSP (mock mode)
|
||||||
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,
|
The bridge ships with a synthetic-state generator. Use it to verify the
|
||||||
collision) not just state. Can disable the publish path with a
|
HTTP pipeline end-to-end without needing KSP:
|
||||||
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
|
```bash
|
||||||
# 1. Make sure KSP is running with the kRPC mod enabled
|
# Terminal 1: API
|
||||||
# 2. Make sure the API is running (Phase 1a)
|
cd apps/api && PORT=4000 USE_IN_MEMORY=1 pnpm start
|
||||||
# 3. Run the bridge (Phase 1c — TBD)
|
|
||||||
pnpm --filter @kerbal-rt/ksp-bridge start
|
# Terminal 2: bridge (no kRPC_HOST, no KSP install)
|
||||||
# (this script doesn't exist yet; see Option A/B above)
|
cd apps/tools/ksp-bridge
|
||||||
|
KERBAL_RT_API_URL=http://localhost:4000 \
|
||||||
|
INGEST_API_KEY=test \
|
||||||
|
BRIDGE_POLL_MS=500 \
|
||||||
|
pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why this isn't done yet
|
You'll see `[ksp-bridge] no kRPC server at 127.0.0.1:50000 (continuing with mock state)`,
|
||||||
|
followed by `ut=… bodies=2 vessels=1 → OK` every 500ms.
|
||||||
|
|
||||||
The real bridge requires:
|
### B. With KSP (real kRPC)
|
||||||
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
|
#### 1. Install KSP + kRPC
|
||||||
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
|
```bash
|
||||||
the system (live map, hub, Spacenomicon) consuming real snapshots,
|
# Install KSP 1.12.5 (Steam) or wherever you keep it
|
||||||
and then plug in the kRPC client once the rest is solid.
|
# Install CKAN
|
||||||
|
# https://github.com/KSP-CKAN/CKAN/releases
|
||||||
|
ckan install kRPC
|
||||||
|
# This pulls in the kRPC mod and its server
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the kRPC mod is at:
|
||||||
|
```
|
||||||
|
<KSP>/GameData/kRPC/
|
||||||
|
Plugins/
|
||||||
|
kRPC.dll
|
||||||
|
ServiceDefinitions/
|
||||||
|
KRPC.proto
|
||||||
|
SpaceCenter.proto
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Start KSP, load your save, start the kRPC server
|
||||||
|
|
||||||
|
1. Launch KSP, load a save (your "no-warp" multiplayer save)
|
||||||
|
2. Right-click the kRPC icon in the toolbar → "Start server"
|
||||||
|
3. Defaults: port `50000` for RPC, port `50001` for stream
|
||||||
|
|
||||||
|
#### 3. Point the bridge at it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/tools/ksp-bridge
|
||||||
|
KSP_KRPC_HOST=127.0.0.1 \
|
||||||
|
KSP_KRPC_PORT=50000 \
|
||||||
|
KSP_DIR=/path/to/Kerbal\ Space\ Program \
|
||||||
|
KERBAL_RT_API_URL=http://localhost:4000 \
|
||||||
|
INGEST_API_KEY=test \
|
||||||
|
BRIDGE_POLL_MS=1000 \
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `KSP_DIR` to the path containing `GameData/kRPC/Plugins/ServiceDefinitions/`.
|
||||||
|
The bridge looks there for the .proto files. With that set, you'll see
|
||||||
|
`[ksp-bridge] found N .proto files in <path>`.
|
||||||
|
|
||||||
|
#### 4. Verify
|
||||||
|
|
||||||
|
- API `/api/v1/state` should return non-zero vessel/body counts
|
||||||
|
- `apps/live-map` (http://localhost:3001) shows real KSP vessels
|
||||||
|
- `apps/hub/debug` shows the same
|
||||||
|
- `[ksp-bridge]` log shows `ut=… → OK` every poll
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What kRPC calls does the bridge need?
|
||||||
|
|
||||||
|
The bridge's `extract` function (passed to `KRPCAdapter`) needs to call
|
||||||
|
these SpaceCenter methods:
|
||||||
|
|
||||||
|
| Method | What it returns |
|
||||||
|
|---|---|
|
||||||
|
| `SpaceCenter.ut()` | double — KSP universal time |
|
||||||
|
| `SpaceCenter.bodies` | list of CelestialBody |
|
||||||
|
| `SpaceCenter.vessels` | list of Vessel |
|
||||||
|
| `SpaceCenter.active_vessel` | Vessel (or null) |
|
||||||
|
| `CelestialBody.name` | string |
|
||||||
|
| `CelestialBody.parent` | CelestialBody (or null) |
|
||||||
|
| `CelestialBody.radius` | double (m) |
|
||||||
|
| `CelestialBody.sphere_of_influence` | double (m) |
|
||||||
|
| `CelestialBody.gravitational_parameter` | double (m³/s²) |
|
||||||
|
| `CelestialBody.rotation_period` | double (s) |
|
||||||
|
| `CelestialBody.axial_tilt` | double (rad) |
|
||||||
|
| `CelestialBody.orbit` | Orbit (Keplerian elements) |
|
||||||
|
| `Vessel.name` | string |
|
||||||
|
| `Vessel.type` | enum string (Probe, Ship, Station, Lander, Base, Rover, EVA) |
|
||||||
|
| `Vessel.situation` | enum (prelaunch, orbiting, escaping, landed, splashed, flying, docked) |
|
||||||
|
| `Vessel.orbit` | Orbit (Keplerian elements around the reference body) |
|
||||||
|
| `Orbit.semi_major_axis` | double (m) |
|
||||||
|
| `Orbit.eccentricity` | double |
|
||||||
|
| `Orbit.inclination` | double (rad) |
|
||||||
|
| `Orbit.longitude_of_ascending_node` | double (rad) |
|
||||||
|
| `Orbit.argument_of_periapsis` | double (rad) |
|
||||||
|
| `Orbit.mean_anomaly_at_epoch` | double (rad) |
|
||||||
|
| `Orbit.epoch` | double (s) |
|
||||||
|
| `Orbit.reference_frame` | ReferenceFrame (we use body-relative, ignore the frame) |
|
||||||
|
|
||||||
|
That's about 20 calls per snapshot × the number of bodies/vessels.
|
||||||
|
For a save with 20 vessels and 17 bodies, expect ~400 RPC calls per
|
||||||
|
poll. At 1Hz polling, kRPC can easily handle this (it batches).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the kRPC protocol works (for the next dev)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Client connects TCP to kRPC server (default :50000 for RPC, :50001 for streams)
|
||||||
|
2. Client sends ConnectionRequest { type: RPC, clientName: "kerbal-rt-bridge" }
|
||||||
|
3. Server replies ConnectionResponse { status: OK, clientIdentifier: <16 bytes> }
|
||||||
|
4. Client sends Request { calls: [ ProcedureCall { service, procedure, arguments } ] }
|
||||||
|
5. Server replies Response { results: [ ProcedureResult { value: <bytes> } ] }
|
||||||
|
6. For streams: client opens second TCP, sends ConnectionRequest with type: STREAM + the
|
||||||
|
client identifier from step 3, then AddStream to subscribe, then reads StreamUpdate
|
||||||
|
messages indefinitely.
|
||||||
|
|
||||||
|
Wire format: each message is [varint length][protobuf payload] (length-prefixed framing).
|
||||||
|
The varint is the standard protobuf base-128 varint — note that JavaScript's `<<` operator
|
||||||
|
truncates to 32 bits, so use multiplication for values ≥ 2^32.
|
||||||
|
```
|
||||||
|
|
||||||
|
The full implementation is in `packages/krpc-client/src/`:
|
||||||
|
- `connection.ts` — varint + length-prefix framing + per-socket read queue
|
||||||
|
- `schema.ts` — hand-written protobufjs schema for the kRPC meta-protocol
|
||||||
|
- `client.ts` — `KRPCClient` class with connect/invoke/addStream/close
|
||||||
|
|
||||||
|
Verified with:
|
||||||
|
- 8 varint round-trip tests (including uint64-via-varint)
|
||||||
|
- 2 raw-socket wire-format tests (handshake + request/response)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap for the full kRPC integration
|
||||||
|
|
||||||
|
1. **Load .proto files dynamically** at bridge startup:
|
||||||
|
```ts
|
||||||
|
import * as protobuf from 'protobufjs';
|
||||||
|
const root = await protobuf.load(`${protoDir}/KRPC.proto`);
|
||||||
|
const root2 = await protobuf.load(`${protoDir}/SpaceCenter.proto`);
|
||||||
|
// merge into one root, then build typed service proxies
|
||||||
|
```
|
||||||
|
2. **Build a typed SpaceCenter proxy** that auto-encodes arguments and
|
||||||
|
decodes return values. The kRPC mod generates this for C# and Python;
|
||||||
|
for Node we build a thin wrapper around the loaded protobuf types.
|
||||||
|
3. **Implement the `extract` function** in `apps/tools/ksp-bridge/src/krpc-adapter.ts`:
|
||||||
|
- Call `SpaceCenter.ut()` for the current UT
|
||||||
|
- Iterate `SpaceCenter.bodies` and read each property
|
||||||
|
- Iterate `SpaceCenter.vessels` and read each property
|
||||||
|
- Build a `KRPCState` and return
|
||||||
|
4. **Stream where possible**: the kRPC server has a stream API that
|
||||||
|
auto-emits state changes. Switching to streams reduces RPC overhead.
|
||||||
|
5. **Custom LMP integration**: if you're running a custom LunaMultiplayer
|
||||||
|
fork, you may need to publish from the server's update loop instead
|
||||||
|
of from a separate kRPC client. The bridge's `extract` function is
|
||||||
|
the integration point — replace it with one that calls your
|
||||||
|
in-process LMP hooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License / Attribution
|
||||||
|
|
||||||
|
kRPC is BSD-licensed (https://github.com/krpc/krpc). The schema in
|
||||||
|
`packages/krpc-client/src/schema.ts` is adapted from
|
||||||
|
https://github.com/krpc/krpc/blob/main/protobuf/krpc.proto, which
|
||||||
|
is also BSD-licensed. The kRPC mod itself is not bundled with this
|
||||||
|
project — you install it via CKAN as described above.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@kerbal-rt/krpc-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "TypeScript client for the kRPC protobuf protocol (KSP telemetry bridge)",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "echo 'no linter yet'",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"protobufjs": "^7.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.5.0",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vitest": "^2.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* High-level kRPC client. Wraps the low-level connection/protocol
|
||||||
|
* to provide typed procedure calls and stream subscriptions.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* const client = new KRPCClient({ host, rpcPort, streamPort });
|
||||||
|
* await client.connect(); // handshake on both ports
|
||||||
|
* const ut = await client.invoke({ service: 'SpaceCenter',
|
||||||
|
* procedure: 'GetUT' });
|
||||||
|
* const streamId = await client.addStream({ service: 'SpaceCenter',
|
||||||
|
* procedure: 'GetUT' });
|
||||||
|
* client.onStreamUpdate((upd) => { ... });
|
||||||
|
* await client.close();
|
||||||
|
*/
|
||||||
|
import * as net from 'node:net';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { KRPC, decodeMessage } from './schema.js';
|
||||||
|
import { sendMessage, recvRawMessage, tcpConnect } from './connection.js';
|
||||||
|
|
||||||
|
export interface KRPCClientOptions {
|
||||||
|
host?: string;
|
||||||
|
rpcPort?: number;
|
||||||
|
streamPort?: number;
|
||||||
|
clientName?: string;
|
||||||
|
connectTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcedureCallRequest {
|
||||||
|
service: string;
|
||||||
|
procedure: string;
|
||||||
|
/** Optional argument values; position inferred from array order. */
|
||||||
|
args?: unknown[];
|
||||||
|
/** When set, used as service_id/procedure_id (saves a string lookup). */
|
||||||
|
serviceId?: number;
|
||||||
|
procedureId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamHandler = (streamId: number, result: Uint8Array) => void;
|
||||||
|
export type { StreamHandler };
|
||||||
|
|
||||||
|
export class KRPCClient {
|
||||||
|
private opts: Required<KRPCClientOptions>;
|
||||||
|
private rpcSocket: net.Socket | null = null;
|
||||||
|
private streamSocket: net.Socket | null = null;
|
||||||
|
private clientIdentifier: Buffer = Buffer.alloc(0);
|
||||||
|
private streamHandlers = new Set<StreamHandler>();
|
||||||
|
private streamReadChain: Promise<void> = Promise.resolve();
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(opts: KRPCClientOptions = {}) {
|
||||||
|
this.opts = {
|
||||||
|
host: opts.host ?? '127.0.0.1',
|
||||||
|
rpcPort: opts.rpcPort ?? 50000,
|
||||||
|
streamPort: opts.streamPort ?? 50001,
|
||||||
|
clientName: opts.clientName ?? 'kerbal-rt-bridge',
|
||||||
|
connectTimeoutMs: opts.connectTimeoutMs ?? 5000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
// RPC handshake
|
||||||
|
this.rpcSocket = await tcpConnect(
|
||||||
|
this.opts.host,
|
||||||
|
this.opts.rpcPort,
|
||||||
|
this.opts.connectTimeoutMs,
|
||||||
|
);
|
||||||
|
sendMessage(this.rpcSocket, KRPC.ConnectionRequest, {
|
||||||
|
type: 'RPC',
|
||||||
|
clientName: this.opts.clientName,
|
||||||
|
});
|
||||||
|
const resp = decodeMessage<{
|
||||||
|
status: number | string;
|
||||||
|
message: string;
|
||||||
|
clientIdentifier: Uint8Array;
|
||||||
|
}>(KRPC.ConnectionResponse, await recvRawMessage(this.rpcSocket));
|
||||||
|
// protobufjs decodes enums to numbers by default; OK == 0
|
||||||
|
if (resp.status !== 'OK' && resp.status !== 0) {
|
||||||
|
throw new Error(`RPC handshake failed: ${resp.status} ${resp.message}`);
|
||||||
|
}
|
||||||
|
this.clientIdentifier = Buffer.from(resp.clientIdentifier);
|
||||||
|
|
||||||
|
// Stream handshake
|
||||||
|
this.streamSocket = await tcpConnect(
|
||||||
|
this.opts.host,
|
||||||
|
this.opts.streamPort,
|
||||||
|
this.opts.connectTimeoutMs,
|
||||||
|
);
|
||||||
|
sendMessage(this.streamSocket, KRPC.ConnectionRequest, {
|
||||||
|
type: 'STREAM',
|
||||||
|
clientIdentifier: this.clientIdentifier,
|
||||||
|
});
|
||||||
|
const streamResp = decodeMessage<{ status: number | string; message: string }>(
|
||||||
|
KRPC.ConnectionResponse,
|
||||||
|
await recvRawMessage(this.streamSocket),
|
||||||
|
);
|
||||||
|
if (streamResp.status !== 'OK' && streamResp.status !== 0) {
|
||||||
|
throw new Error(`Stream handshake failed: ${streamResp.status} ${streamResp.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the stream read loop
|
||||||
|
this.streamReadChain = this.readStreamLoop().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[krpc-client] stream loop error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.rpcSocket !== null && this.streamSocket !== null && !this.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke a single procedure. Returns the raw return-value bytes.
|
||||||
|
* Use the .proto schema to decode it.
|
||||||
|
*/
|
||||||
|
async invoke(req: ProcedureCallRequest): Promise<Uint8Array> {
|
||||||
|
if (!this.rpcSocket) throw new Error('not connected');
|
||||||
|
const call: Record<string, unknown> = {
|
||||||
|
service: req.service,
|
||||||
|
procedure: req.procedure,
|
||||||
|
};
|
||||||
|
if (req.serviceId !== undefined) call.serviceId = req.serviceId;
|
||||||
|
if (req.procedureId !== undefined) call.procedureId = req.procedureId;
|
||||||
|
if (req.args && req.args.length > 0) {
|
||||||
|
// Each argument must be a serialized protobuf value. For simple
|
||||||
|
// scalar types (double, float, string, bool, int), protobufjs
|
||||||
|
// can encode them with a wrapper type — but typically kRPC
|
||||||
|
// arguments are more complex (Class references, Tuples, etc.)
|
||||||
|
// and the caller must serialize them.
|
||||||
|
// For convenience, we accept already-encoded bytes here.
|
||||||
|
call.arguments = req.args.map((value, i) => {
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
return { position: i, value };
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
return { position: i, value };
|
||||||
|
}
|
||||||
|
// Last resort: try to encode as a string, since most basic
|
||||||
|
// kRPC args in our use case are simple types
|
||||||
|
return { position: i, value: Buffer.from(String(value)) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendMessage(this.rpcSocket, KRPC.Request, { calls: [call] });
|
||||||
|
const raw = await recvRawMessage(this.rpcSocket);
|
||||||
|
const response = decodeMessage<{
|
||||||
|
error?: { service: string; name: string; description: string };
|
||||||
|
results: {
|
||||||
|
error?: { service: string; name: string; description: string };
|
||||||
|
value: Uint8Array;
|
||||||
|
}[];
|
||||||
|
}>(KRPC.Response, raw);
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(
|
||||||
|
`RPC error: ${response.error.service}.${response.error.name}: ${response.error.description}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.results.length === 0) {
|
||||||
|
throw new Error('empty response');
|
||||||
|
}
|
||||||
|
const r = response.results[0];
|
||||||
|
if (!r) {
|
||||||
|
throw new Error('empty response result');
|
||||||
|
}
|
||||||
|
if (r.error) {
|
||||||
|
throw new Error(
|
||||||
|
`RPC result error: ${r.error.service}.${r.error.name}: ${r.error.description}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return r.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a procedure. Returns the stream id; updates are
|
||||||
|
* delivered via the onStreamUpdate callback.
|
||||||
|
*/
|
||||||
|
async addStream(req: ProcedureCallRequest): Promise<number> {
|
||||||
|
if (!this.rpcSocket) throw new Error('not connected');
|
||||||
|
// The argument to AddStream is a ProcedureCall describing the
|
||||||
|
// procedure to stream.
|
||||||
|
const innerCall: Record<string, unknown> = {
|
||||||
|
service: req.service,
|
||||||
|
procedure: req.procedure,
|
||||||
|
};
|
||||||
|
if (req.serviceId !== undefined) innerCall.serviceId = req.serviceId;
|
||||||
|
if (req.procedureId !== undefined) innerCall.procedureId = req.procedureId;
|
||||||
|
const innerCallBytes = Buffer.from(
|
||||||
|
KRPC.ProcedureCall.encode(KRPC.ProcedureCall.create(innerCall)).finish(),
|
||||||
|
);
|
||||||
|
const addCall = {
|
||||||
|
service: 'KRPC',
|
||||||
|
procedure: 'AddStream',
|
||||||
|
arguments: [{ position: 0, value: innerCallBytes }],
|
||||||
|
};
|
||||||
|
sendMessage(this.rpcSocket, KRPC.Request, { calls: [addCall] });
|
||||||
|
const response = decodeMessage<{
|
||||||
|
results: { value: Uint8Array; error?: { name: string; description: string } }[];
|
||||||
|
}>(KRPC.Response, await recvRawMessage(this.rpcSocket));
|
||||||
|
if (response.results.length === 0) throw new Error('empty AddStream response');
|
||||||
|
const r0 = response.results[0];
|
||||||
|
if (!r0) throw new Error('empty AddStream result');
|
||||||
|
if (r0.error) {
|
||||||
|
throw new Error(`AddStream error: ${r0.error.name}: ${r0.error.description}`);
|
||||||
|
}
|
||||||
|
const stream = decodeMessage<{ id: number }>(KRPC.Stream, r0.value);
|
||||||
|
return stream.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a previously added stream. */
|
||||||
|
async removeStream(streamId: number): Promise<void> {
|
||||||
|
if (!this.rpcSocket) throw new Error('not connected');
|
||||||
|
const streamMsg = KRPC.Stream.create({ id: streamId });
|
||||||
|
const streamBytes = Buffer.from(KRPC.Stream.encode(streamMsg).finish());
|
||||||
|
const call = {
|
||||||
|
service: 'KRPC',
|
||||||
|
procedure: 'RemoveStream',
|
||||||
|
arguments: [{ position: 0, value: streamBytes }],
|
||||||
|
};
|
||||||
|
sendMessage(this.rpcSocket, KRPC.Request, { calls: [call] });
|
||||||
|
await recvRawMessage(this.rpcSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback invoked for every stream update. */
|
||||||
|
onStreamUpdate(handler: StreamHandler): () => void {
|
||||||
|
this.streamHandlers.add(handler);
|
||||||
|
return () => this.streamHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
if (this.rpcSocket) {
|
||||||
|
this.rpcSocket.destroy();
|
||||||
|
this.rpcSocket = null;
|
||||||
|
}
|
||||||
|
if (this.streamSocket) {
|
||||||
|
this.streamSocket.destroy();
|
||||||
|
this.streamSocket = null;
|
||||||
|
}
|
||||||
|
await this.streamReadChain.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── private ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async readStreamLoop(): Promise<void> {
|
||||||
|
if (!this.streamSocket) return;
|
||||||
|
while (!this.closed) {
|
||||||
|
try {
|
||||||
|
const raw = await recvRawMessage(this.streamSocket);
|
||||||
|
const update = decodeMessage<{
|
||||||
|
results: { id: number; result: { value: Uint8Array } }[];
|
||||||
|
}>(KRPC.StreamUpdate, raw);
|
||||||
|
for (const r of update.results) {
|
||||||
|
for (const h of this.streamHandlers) h(r.id, r.result.value);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (this.closed) return;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* KRPC TCP connection — length-prefixed protobuf framing.
|
||||||
|
*
|
||||||
|
* Wire format (from krpc docs):
|
||||||
|
* - Each message is encoded as: [varint length][protobuf payload]
|
||||||
|
* - varint is the standard protobuf base-128 varint
|
||||||
|
*
|
||||||
|
* Uses a per-socket reader that buffers incoming data and fulfills
|
||||||
|
* pending read requests, avoiding race conditions when multiple
|
||||||
|
* read promises are in flight.
|
||||||
|
*/
|
||||||
|
import * as net from 'node:net';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import protobuf from 'protobufjs';
|
||||||
|
|
||||||
|
/** Encode an unsigned integer as a protobuf varint. */
|
||||||
|
export function encodeVarint(value: number): Buffer {
|
||||||
|
if (value < 0) {
|
||||||
|
throw new Error('varint cannot encode negative numbers');
|
||||||
|
}
|
||||||
|
const bytes: number[] = [];
|
||||||
|
let v = value;
|
||||||
|
while (v >= 0x80) {
|
||||||
|
bytes.push((v & 0x7f) | 0x80);
|
||||||
|
v = Math.floor(v / 0x80);
|
||||||
|
}
|
||||||
|
bytes.push(v & 0x7f);
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a protobuf varint from the front of a buffer. Returns [value, bytesConsumed]. */
|
||||||
|
export function decodeVarint(buf: Buffer): [number, number] {
|
||||||
|
let result = 0;
|
||||||
|
let shift = 0;
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < buf.length) {
|
||||||
|
const b = buf[pos++];
|
||||||
|
// Use multiplication by powers of 2 instead of `<<` because
|
||||||
|
// JavaScript's left-shift operator truncates to 32 bits, which
|
||||||
|
// would corrupt values ≥ 2^32 (e.g. uint64 stream ids).
|
||||||
|
result += (b & 0x7f) * Math.pow(2, shift);
|
||||||
|
if ((b & 0x80) === 0) {
|
||||||
|
return [result, pos];
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
|
if (shift > 63) {
|
||||||
|
throw new Error('varint too long');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('varint truncated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a length-prefixed protobuf message. */
|
||||||
|
export function sendMessage(
|
||||||
|
socket: net.Socket,
|
||||||
|
type: protobuf.Type,
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const payload = Buffer.from(type.encode(type.create(value)).finish());
|
||||||
|
const prefix = encodeVarint(payload.length);
|
||||||
|
socket.write(Buffer.concat([prefix, payload]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-socket reader. Buffers incoming chunks and resolves pending
|
||||||
|
* read requests. Prevents race conditions when multiple read promises
|
||||||
|
* are pending on the same socket.
|
||||||
|
*/
|
||||||
|
class SocketReader {
|
||||||
|
private buf: Buffer = Buffer.alloc(0);
|
||||||
|
private waiting: Array<{ n: number; resolve: (b: Buffer) => void; reject: (e: Error) => void }> =
|
||||||
|
[];
|
||||||
|
private closed = false;
|
||||||
|
private closeReason: Error | null = null;
|
||||||
|
|
||||||
|
constructor(socket: net.Socket) {
|
||||||
|
socket.on('data', (chunk: Buffer) => this.onData(chunk));
|
||||||
|
socket.on('error', (err) => this.onClose(err));
|
||||||
|
socket.on('close', () => this.onClose(new Error('socket closed')));
|
||||||
|
}
|
||||||
|
|
||||||
|
read(n: number): Promise<Buffer> {
|
||||||
|
if (this.closed) {
|
||||||
|
return Promise.reject(this.closeReason ?? new Error('socket closed'));
|
||||||
|
}
|
||||||
|
if (this.buf.length >= n) {
|
||||||
|
const out = this.buf.subarray(0, n);
|
||||||
|
this.buf = this.buf.subarray(n);
|
||||||
|
return Promise.resolve(Buffer.from(out));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.waiting.push({ n, resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onData(chunk: Buffer): void {
|
||||||
|
this.buf = Buffer.concat([this.buf, chunk]);
|
||||||
|
// Try to satisfy pending reads (in order)
|
||||||
|
let i = 0;
|
||||||
|
while (i < this.waiting.length) {
|
||||||
|
const w = this.waiting[i]!;
|
||||||
|
if (this.buf.length >= w.n) {
|
||||||
|
const out = this.buf.subarray(0, w.n);
|
||||||
|
this.buf = this.buf.subarray(w.n);
|
||||||
|
w.resolve(Buffer.from(out));
|
||||||
|
this.waiting.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onClose(err: Error): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
this.closeReason = err;
|
||||||
|
while (this.waiting.length > 0) {
|
||||||
|
const w = this.waiting.shift()!;
|
||||||
|
w.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readers = new WeakMap<net.Socket, SocketReader>();
|
||||||
|
|
||||||
|
function readerFor(socket: net.Socket): SocketReader {
|
||||||
|
let r = readers.get(socket);
|
||||||
|
if (!r) {
|
||||||
|
r = new SocketReader(socket);
|
||||||
|
readers.set(socket, r);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receive a single length-prefixed message and return the raw bytes. */
|
||||||
|
export async function recvRawMessage(socket: net.Socket): Promise<Uint8Array> {
|
||||||
|
const reader = readerFor(socket);
|
||||||
|
// Read varint length one byte at a time
|
||||||
|
let lenBuf = Buffer.alloc(0);
|
||||||
|
while (true) {
|
||||||
|
const b = await reader.read(1);
|
||||||
|
lenBuf = Buffer.concat([lenBuf, b]);
|
||||||
|
try {
|
||||||
|
const [length, _consumed] = decodeVarint(lenBuf);
|
||||||
|
if (length > 16 * 1024 * 1024) {
|
||||||
|
throw new Error('message too large');
|
||||||
|
}
|
||||||
|
return await reader.read(length);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).message === 'varint truncated') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receive a single length-prefixed message and decode it. */
|
||||||
|
export async function recvMessage<T>(socket: net.Socket, type: protobuf.Type): Promise<T> {
|
||||||
|
const payload = await recvRawMessage(socket);
|
||||||
|
return type.decode(payload) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a TCP connection to a host:port. */
|
||||||
|
export function tcpConnect(host: string, port: number, timeoutMs = 5000): Promise<net.Socket> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.createConnection({ host, port });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error(`connect timeout after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
socket.once('connect', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.setNoDelay(true);
|
||||||
|
resolve(socket);
|
||||||
|
});
|
||||||
|
socket.once('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @kerbal-rt/krpc-client — TypeScript client for the kRPC protobuf
|
||||||
|
* protocol used by the Kerbal Space Program kRPC mod.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - KRPCClient: high-level wrapper with connect/invoke/addStream/close
|
||||||
|
* - sendMessage / recvMessage / recvRawMessage / encodeVarint / decodeVarint:
|
||||||
|
* low-level wire-format helpers
|
||||||
|
* - KRPC namespace: protobufjs types for the kRPC meta-protocol
|
||||||
|
*
|
||||||
|
* See ./schema.ts for the meta schema. The service-specific types
|
||||||
|
* (SpaceCenter.Vessel, Orbit, etc.) need to be loaded from the kRPC
|
||||||
|
* mod's .proto files at runtime when running against a real KSP.
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
KRPCClient,
|
||||||
|
type ProcedureCallRequest,
|
||||||
|
type KRPCClientOptions,
|
||||||
|
type StreamHandler,
|
||||||
|
} from './client.js';
|
||||||
|
export {
|
||||||
|
sendMessage,
|
||||||
|
recvMessage,
|
||||||
|
recvRawMessage,
|
||||||
|
encodeVarint,
|
||||||
|
decodeVarint,
|
||||||
|
tcpConnect,
|
||||||
|
} from './connection.js';
|
||||||
|
export { KRPC, encodeMessage, decodeMessage } from './schema.js';
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* kRPC meta-protocol schema (krpc.proto).
|
||||||
|
*
|
||||||
|
* This file contains the protobufjs JSON representation of the kRPC
|
||||||
|
* meta-protocol messages used to:
|
||||||
|
* - Connect (ConnectionRequest, ConnectionResponse)
|
||||||
|
* - Call procedures (Request, ProcedureCall, Argument, Response,
|
||||||
|
* ProcedureResult, Error)
|
||||||
|
* - Stream (StreamUpdate, StreamResult, Stream, Status, etc.)
|
||||||
|
*
|
||||||
|
* For SpaceCenter.Vessel/Orbit/CelestialBody types, see ./spacecenter.ts
|
||||||
|
* — those are loaded dynamically from the kRPC mod's .proto files
|
||||||
|
* when running against a real KSP instance.
|
||||||
|
*/
|
||||||
|
import protobuf from 'protobufjs';
|
||||||
|
|
||||||
|
// We hand-write the JSON descriptor here so the package has no runtime
|
||||||
|
// dependency on the .proto files (which ship inside the kRPC mod).
|
||||||
|
// This is the minimum subset we need for the bridge to function.
|
||||||
|
const schemaJson = {
|
||||||
|
nested: {
|
||||||
|
krpc: {
|
||||||
|
nested: {
|
||||||
|
schema: {
|
||||||
|
nested: {
|
||||||
|
ConnectionRequest: {
|
||||||
|
fields: {
|
||||||
|
type: { type: 'ConnectionRequest.Type', id: 1 },
|
||||||
|
clientName: { type: 'string', id: 2 },
|
||||||
|
clientIdentifier: { type: 'bytes', id: 3 },
|
||||||
|
},
|
||||||
|
nested: {
|
||||||
|
Type: {
|
||||||
|
values: { RPC: 0, STREAM: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ConnectionResponse: {
|
||||||
|
fields: {
|
||||||
|
status: { type: 'ConnectionResponse.Status', id: 1 },
|
||||||
|
message: { type: 'string', id: 2 },
|
||||||
|
clientIdentifier: { type: 'bytes', id: 3 },
|
||||||
|
},
|
||||||
|
nested: {
|
||||||
|
Status: {
|
||||||
|
values: {
|
||||||
|
OK: 0,
|
||||||
|
MALFORMED_MESSAGE: 1,
|
||||||
|
TIMEOUT: 2,
|
||||||
|
WRONG_TYPE: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Request: {
|
||||||
|
fields: {
|
||||||
|
calls: { rule: 'repeated', type: 'ProcedureCall', id: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Response: {
|
||||||
|
fields: {
|
||||||
|
error: { type: 'Error', id: 1 },
|
||||||
|
results: { rule: 'repeated', type: 'ProcedureResult', id: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ProcedureCall: {
|
||||||
|
fields: {
|
||||||
|
service: { type: 'string', id: 1 },
|
||||||
|
procedure: { type: 'string', id: 2 },
|
||||||
|
arguments: { rule: 'repeated', type: 'Argument', id: 3 },
|
||||||
|
serviceId: { type: 'uint32', id: 4 },
|
||||||
|
procedureId: { type: 'uint32', id: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Argument: {
|
||||||
|
fields: {
|
||||||
|
position: { type: 'uint32', id: 1 },
|
||||||
|
value: { type: 'bytes', id: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ProcedureResult: {
|
||||||
|
fields: {
|
||||||
|
error: { type: 'Error', id: 1 },
|
||||||
|
value: { type: 'bytes', id: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Error: {
|
||||||
|
fields: {
|
||||||
|
service: { type: 'string', id: 1 },
|
||||||
|
name: { type: 'string', id: 2 },
|
||||||
|
description: { type: 'string', id: 3 },
|
||||||
|
stackTrace: { type: 'string', id: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StreamUpdate: {
|
||||||
|
fields: {
|
||||||
|
results: { rule: 'repeated', type: 'StreamResult', id: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StreamResult: {
|
||||||
|
fields: {
|
||||||
|
id: { type: 'uint64', id: 1 },
|
||||||
|
result: { type: 'ProcedureResult', id: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: {
|
||||||
|
fields: { id: { type: 'uint64', id: 1 } },
|
||||||
|
},
|
||||||
|
Status: {
|
||||||
|
fields: {
|
||||||
|
version: { type: 'string', id: 1 },
|
||||||
|
bytesRead: { type: 'uint64', id: 2 },
|
||||||
|
bytesWritten: { type: 'uint64', id: 3 },
|
||||||
|
bytesReadRate: { type: 'float', id: 4 },
|
||||||
|
bytesWrittenRate: { type: 'float', id: 5 },
|
||||||
|
rpcsExecuted: { type: 'uint64', id: 6 },
|
||||||
|
rpcRate: { type: 'float', id: 7 },
|
||||||
|
oneRpcPerUpdate: { type: 'bool', id: 8 },
|
||||||
|
maxTimePerUpdate: { type: 'uint32', id: 9 },
|
||||||
|
adaptiveRateControl: { type: 'bool', id: 10 },
|
||||||
|
blockingRecv: { type: 'bool', id: 11 },
|
||||||
|
recvTimeout: { type: 'uint32', id: 12 },
|
||||||
|
timePerRpcUpdate: { type: 'float', id: 13 },
|
||||||
|
pollTimePerRpcUpdate: { type: 'float', id: 14 },
|
||||||
|
execTimePerRpcUpdate: { type: 'float', id: 15 },
|
||||||
|
streamRpcs: { type: 'uint32', id: 16 },
|
||||||
|
streamRpcsExecuted: { type: 'uint64', id: 17 },
|
||||||
|
streamRpcRate: { type: 'float', id: 18 },
|
||||||
|
timePerStreamUpdate: { type: 'float', id: 19 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Services: {
|
||||||
|
fields: {
|
||||||
|
services: { rule: 'repeated', type: 'Service', id: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Service: {
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', id: 1 },
|
||||||
|
procedures: { rule: 'repeated', type: 'Procedure', id: 2 },
|
||||||
|
classes: { rule: 'repeated', type: 'Class', id: 3 },
|
||||||
|
enumerations: { rule: 'repeated', type: 'Enumeration', id: 4 },
|
||||||
|
exceptions: { rule: 'repeated', type: 'Exception', id: 5 },
|
||||||
|
documentation: { type: 'string', id: 6 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Procedure: {
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', id: 1 },
|
||||||
|
parameters: { rule: 'repeated', type: 'Parameter', id: 2 },
|
||||||
|
returnType: { type: 'Type', id: 3 },
|
||||||
|
returnIsNullable: { type: 'bool', id: 4 },
|
||||||
|
documentation: { type: 'string', id: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Parameter: {
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', id: 1 },
|
||||||
|
type: { type: 'Type', id: 2 },
|
||||||
|
defaultValue: { type: 'bytes', id: 3 },
|
||||||
|
nullable: { type: 'bool', id: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Class: {
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', id: 1 },
|
||||||
|
documentation: { type: 'string', id: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Enumeration: {
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', id: 1 },
|
||||||
|
values: { rule: 'repeated', type: 'EnumerationValue', id: 2 },
|
||||||
|
documentation: { type: 'string', id: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EnumerationValue: {
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', id: 1 },
|
||||||
|
value: { type: 'int32', id: 2 },
|
||||||
|
documentation: { type: 'string', id: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Exception: {
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', id: 1 },
|
||||||
|
documentation: { type: 'string', id: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: {
|
||||||
|
fields: {
|
||||||
|
code: { type: 'Type.TypeCode', id: 1 },
|
||||||
|
service: { type: 'string', id: 2 },
|
||||||
|
name: { type: 'string', id: 3 },
|
||||||
|
types: { rule: 'repeated', type: 'Type', id: 4 },
|
||||||
|
},
|
||||||
|
nested: {
|
||||||
|
TypeCode: {
|
||||||
|
values: {
|
||||||
|
NONE: 0,
|
||||||
|
DOUBLE: 1,
|
||||||
|
FLOAT: 2,
|
||||||
|
SINT32: 3,
|
||||||
|
SINT64: 4,
|
||||||
|
UINT32: 5,
|
||||||
|
UINT64: 6,
|
||||||
|
BOOL: 7,
|
||||||
|
STRING: 8,
|
||||||
|
BYTES: 9,
|
||||||
|
CLASS: 100,
|
||||||
|
ENUMERATION: 101,
|
||||||
|
EVENT: 200,
|
||||||
|
PROCEDURE_CALL: 201,
|
||||||
|
STREAM: 202,
|
||||||
|
STATUS: 203,
|
||||||
|
SERVICES: 204,
|
||||||
|
TUPLE: 300,
|
||||||
|
LIST: 301,
|
||||||
|
SET: 302,
|
||||||
|
DICTIONARY: 303,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tuple: {
|
||||||
|
fields: { items: { rule: 'repeated', type: 'bytes', id: 1 } },
|
||||||
|
},
|
||||||
|
List: {
|
||||||
|
fields: { items: { rule: 'repeated', type: 'bytes', id: 1 } },
|
||||||
|
},
|
||||||
|
Set: {
|
||||||
|
fields: { items: { rule: 'repeated', type: 'bytes', id: 1 } },
|
||||||
|
},
|
||||||
|
Dictionary: {
|
||||||
|
fields: {
|
||||||
|
entries: { rule: 'repeated', type: 'DictionaryEntry', id: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DictionaryEntry: {
|
||||||
|
fields: {
|
||||||
|
key: { type: 'bytes', id: 1 },
|
||||||
|
value: { type: 'bytes', id: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event: {
|
||||||
|
fields: { stream: { type: 'Stream', id: 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = protobuf.Root.fromJSON(schemaJson as protobuf.INamespace);
|
||||||
|
// Cache the resolved types for fast lookup
|
||||||
|
const ns = root.lookup('krpc.schema') as protobuf.Namespace;
|
||||||
|
// Suppress "type X is not used" warnings for the namespace
|
||||||
|
void ns;
|
||||||
|
void root;
|
||||||
|
export const KRPC = {
|
||||||
|
ConnectionRequest: ns.lookupType('ConnectionRequest'),
|
||||||
|
ConnectionResponse: ns.lookupType('ConnectionResponse'),
|
||||||
|
Request: ns.lookupType('Request'),
|
||||||
|
Response: ns.lookupType('Response'),
|
||||||
|
ProcedureCall: ns.lookupType('ProcedureCall'),
|
||||||
|
Argument: ns.lookupType('Argument'),
|
||||||
|
ProcedureResult: ns.lookupType('ProcedureResult'),
|
||||||
|
Error: ns.lookupType('Error'),
|
||||||
|
StreamUpdate: ns.lookupType('StreamUpdate'),
|
||||||
|
StreamResult: ns.lookupType('StreamResult'),
|
||||||
|
Stream: ns.lookupType('Stream'),
|
||||||
|
Status: ns.lookupType('Status'),
|
||||||
|
Services: ns.lookupType('Services'),
|
||||||
|
Service: ns.lookupType('Service'),
|
||||||
|
Type: ns.lookupType('Type'),
|
||||||
|
List: ns.lookupType('List'),
|
||||||
|
Tuple: ns.lookupType('Tuple'),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Encode a length-prefixed protobuf message. */
|
||||||
|
export function encodeMessage(type: protobuf.Type, value: Record<string, unknown>): Buffer {
|
||||||
|
return Buffer.from(type.encode(type.create(value)).finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a length-prefixed protobuf message. */
|
||||||
|
export function decodeMessage<T>(type: protobuf.Type, buf: Uint8Array): T {
|
||||||
|
return type.decode(buf) as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { encodeVarint, decodeVarint } from '../src/connection.js';
|
||||||
|
|
||||||
|
describe('varint encoding', () => {
|
||||||
|
it('round-trips small numbers', () => {
|
||||||
|
for (const n of [0, 1, 5, 100, 127]) {
|
||||||
|
const encoded = encodeVarint(n);
|
||||||
|
const [decoded, consumed] = decodeVarint(encoded);
|
||||||
|
expect(decoded).toBe(n);
|
||||||
|
expect(consumed).toBe(encoded.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips numbers requiring 2 bytes', () => {
|
||||||
|
for (const n of [128, 200, 16383, 16384, 100_000]) {
|
||||||
|
const encoded = encodeVarint(n);
|
||||||
|
const [decoded, consumed] = decodeVarint(encoded);
|
||||||
|
expect(decoded).toBe(n);
|
||||||
|
expect(consumed).toBe(encoded.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips numbers requiring 4-5 bytes', () => {
|
||||||
|
for (const n of [2 ** 20, 2 ** 28, 2 ** 31 - 1, 2 ** 32]) {
|
||||||
|
const encoded = encodeVarint(n);
|
||||||
|
const [decoded, consumed] = decodeVarint(encoded);
|
||||||
|
expect(decoded).toBe(n);
|
||||||
|
expect(consumed).toBe(encoded.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes correctly per the protobuf spec', () => {
|
||||||
|
// Reference values from the protobuf documentation
|
||||||
|
expect([...encodeVarint(0)]).toEqual([0x00]);
|
||||||
|
expect([...encodeVarint(1)]).toEqual([0x01]);
|
||||||
|
expect([...encodeVarint(127)]).toEqual([0x7f]);
|
||||||
|
expect([...encodeVarint(128)]).toEqual([0x80, 0x01]);
|
||||||
|
expect([...encodeVarint(300)]).toEqual([0xac, 0x02]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes from a multi-byte buffer correctly', () => {
|
||||||
|
// decodeVarint should stop at the varint boundary
|
||||||
|
const buf = Buffer.concat([encodeVarint(42), Buffer.from([0xff, 0xee])]);
|
||||||
|
const [decoded, consumed] = decodeVarint(buf);
|
||||||
|
expect(decoded).toBe(42);
|
||||||
|
expect(consumed).toBe(1); // only the first byte was the varint
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative numbers', () => {
|
||||||
|
expect(() => encodeVarint(-1)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('varint edge cases', () => {
|
||||||
|
it('decodes 0', () => {
|
||||||
|
const [v, c] = decodeVarint(Buffer.from([0]));
|
||||||
|
expect(v).toBe(0);
|
||||||
|
expect(c).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes max uint32', () => {
|
||||||
|
const n = 0xffffffff;
|
||||||
|
const [v, c] = decodeVarint(encodeVarint(n));
|
||||||
|
expect(v).toBe(n);
|
||||||
|
expect(c).toBe(encodeVarint(n).length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Integration test: drive the wire format with raw sockets to exercise
|
||||||
|
* the same code paths that KRPCClient uses internally.
|
||||||
|
*
|
||||||
|
* We don't mock a full kRPC server (that's a lot of state-machine work
|
||||||
|
* for a single test); the KRPCClient class is verified manually
|
||||||
|
* (the file imports cleanly + we can connect to a real kRPC server
|
||||||
|
* once you have KSP running).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as net from 'node:net';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { KRPC, encodeMessage, decodeMessage } from '../src/schema.js';
|
||||||
|
import { sendMessage, recvMessage, encodeVarint } from '../src/connection.js';
|
||||||
|
|
||||||
|
describe('wire format round trip with raw sockets', () => {
|
||||||
|
it('exchanges ConnectionRequest and ConnectionResponse', async () => {
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
void (async () => {
|
||||||
|
const req = await recvMessage<{ type: number; clientName: string }>(
|
||||||
|
socket,
|
||||||
|
KRPC.ConnectionRequest,
|
||||||
|
);
|
||||||
|
expect(req.type).toBe(0); // RPC
|
||||||
|
expect(req.clientName).toBe('test');
|
||||||
|
|
||||||
|
const resp = encodeMessage(KRPC.ConnectionResponse, {
|
||||||
|
status: 0,
|
||||||
|
message: '',
|
||||||
|
clientIdentifier: Buffer.from('test-client-id'),
|
||||||
|
});
|
||||||
|
socket.write(Buffer.concat([encodeVarint(resp.length), resp]));
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
socket.destroy();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = (server.address() as net.AddressInfo).port;
|
||||||
|
|
||||||
|
const client = net.createConnection({ host: '127.0.0.1', port });
|
||||||
|
await new Promise<void>((resolve) => client.once('connect', () => resolve()));
|
||||||
|
|
||||||
|
// Handshake
|
||||||
|
sendMessage(client, KRPC.ConnectionRequest, { type: 'RPC', clientName: 'test' });
|
||||||
|
const resp = await recvMessage<{ status: number; clientIdentifier: Uint8Array }>(
|
||||||
|
client,
|
||||||
|
KRPC.ConnectionResponse,
|
||||||
|
);
|
||||||
|
expect(resp.status).toBe(0);
|
||||||
|
expect(Buffer.from(resp.clientIdentifier).toString()).toBe('test-client-id');
|
||||||
|
|
||||||
|
client.destroy();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exchanges a Request with a Response carrying a Status return value', async () => {
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
void (async () => {
|
||||||
|
// Handshake first
|
||||||
|
const req = await recvMessage<{ type: number }>(socket, KRPC.ConnectionRequest);
|
||||||
|
expect(req.type).toBe(0);
|
||||||
|
const resp = encodeMessage(KRPC.ConnectionResponse, {
|
||||||
|
status: 0,
|
||||||
|
message: '',
|
||||||
|
clientIdentifier: Buffer.from('x'),
|
||||||
|
});
|
||||||
|
socket.write(Buffer.concat([encodeVarint(resp.length), resp]));
|
||||||
|
|
||||||
|
// Now handle the Request
|
||||||
|
const statusReq = await recvMessage<{ calls: { service: string; procedure: string }[] }>(
|
||||||
|
socket,
|
||||||
|
KRPC.Request,
|
||||||
|
);
|
||||||
|
expect(statusReq.calls.length).toBe(1);
|
||||||
|
expect(statusReq.calls[0]!.service).toBe('KRPC');
|
||||||
|
expect(statusReq.calls[0]!.procedure).toBe('GetStatus');
|
||||||
|
|
||||||
|
const status = encodeMessage(KRPC.Status, {
|
||||||
|
version: '0.5.0',
|
||||||
|
bytesRead: 100n,
|
||||||
|
bytesWritten: 50n,
|
||||||
|
bytesReadRate: 1.0,
|
||||||
|
bytesWrittenRate: 0.5,
|
||||||
|
});
|
||||||
|
const respMsg = encodeMessage(KRPC.Response, { results: [{ value: status }] });
|
||||||
|
socket.write(Buffer.concat([encodeVarint(respMsg.length), respMsg]));
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
socket.destroy();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = (server.address() as net.AddressInfo).port;
|
||||||
|
|
||||||
|
const client = net.createConnection({ host: '127.0.0.1', port });
|
||||||
|
await new Promise<void>((resolve) => client.once('connect', () => resolve()));
|
||||||
|
|
||||||
|
sendMessage(client, KRPC.ConnectionRequest, { type: 'RPC', clientName: 'test' });
|
||||||
|
await recvMessage(client, KRPC.ConnectionResponse);
|
||||||
|
|
||||||
|
sendMessage(client, KRPC.Request, {
|
||||||
|
calls: [{ service: 'KRPC', procedure: 'GetStatus' }],
|
||||||
|
});
|
||||||
|
const callResp = await recvMessage<{ results: { value: Uint8Array }[] }>(client, KRPC.Response);
|
||||||
|
const status = decodeMessage<{ version: string }>(KRPC.Status, callResp.results[0]!.value);
|
||||||
|
expect(status.version).toBe('0.5.0');
|
||||||
|
|
||||||
|
client.destroy();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+150
@@ -143,6 +143,28 @@ importers:
|
|||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.9(@types/node@22.19.19)
|
version: 2.1.9(@types/node@22.19.19)
|
||||||
|
|
||||||
|
apps/tools/ksp-bridge:
|
||||||
|
dependencies:
|
||||||
|
'@kerbal-rt/krpc-client':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../packages/krpc-client
|
||||||
|
'@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
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.9(@types/node@22.19.19)
|
||||||
|
|
||||||
apps/tools/mock-telemetry:
|
apps/tools/mock-telemetry:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@kerbal-rt/orbital-math':
|
'@kerbal-rt/orbital-math':
|
||||||
@@ -181,6 +203,22 @@ importers:
|
|||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.9(@types/node@22.19.19)
|
version: 2.1.9(@types/node@22.19.19)
|
||||||
|
|
||||||
|
packages/krpc-client:
|
||||||
|
dependencies:
|
||||||
|
protobufjs:
|
||||||
|
specifier: ^7.4.0
|
||||||
|
version: 7.6.2
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.5.0
|
||||||
|
version: 22.19.19
|
||||||
|
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:
|
packages/orbital-math:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@kerbal-rt/shared-types':
|
'@kerbal-rt/shared-types':
|
||||||
@@ -1084,6 +1122,66 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: '>=14' }
|
engines: { node: '>=14' }
|
||||||
|
|
||||||
|
'@protobufjs/aspromise@1.1.2':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/base64@1.1.2':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/codegen@2.0.5':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/eventemitter@1.1.1':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/fetch@1.1.1':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/float@1.0.2':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/inquire@1.1.2':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/path@1.1.2':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/pool@1.1.0':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==,
|
||||||
|
}
|
||||||
|
|
||||||
|
'@protobufjs/utf8@1.1.1':
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==,
|
||||||
|
}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -3110,6 +3208,12 @@ packages:
|
|||||||
integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==,
|
integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
long@5.3.2:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==,
|
||||||
|
}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -3456,6 +3560,13 @@ packages:
|
|||||||
integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==,
|
integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protobufjs@7.6.2:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==,
|
||||||
|
}
|
||||||
|
engines: { node: '>=12.0.0' }
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -4607,6 +4718,28 @@ snapshots:
|
|||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@protobufjs/aspromise@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/base64@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/codegen@2.0.5': {}
|
||||||
|
|
||||||
|
'@protobufjs/eventemitter@1.1.1': {}
|
||||||
|
|
||||||
|
'@protobufjs/fetch@1.1.1':
|
||||||
|
dependencies:
|
||||||
|
'@protobufjs/aspromise': 1.1.2
|
||||||
|
|
||||||
|
'@protobufjs/float@1.0.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/inquire@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/path@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/pool@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/utf8@1.1.1': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.61.0':
|
'@rollup/rollup-android-arm-eabi@4.61.0':
|
||||||
@@ -5978,6 +6111,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
long@5.3.2: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
@@ -6176,6 +6311,21 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
react-is: 16.13.1
|
react-is: 16.13.1
|
||||||
|
|
||||||
|
protobufjs@7.6.2:
|
||||||
|
dependencies:
|
||||||
|
'@protobufjs/aspromise': 1.1.2
|
||||||
|
'@protobufjs/base64': 1.1.2
|
||||||
|
'@protobufjs/codegen': 2.0.5
|
||||||
|
'@protobufjs/eventemitter': 1.1.1
|
||||||
|
'@protobufjs/fetch': 1.1.1
|
||||||
|
'@protobufjs/float': 1.0.2
|
||||||
|
'@protobufjs/inquire': 1.1.2
|
||||||
|
'@protobufjs/path': 1.1.2
|
||||||
|
'@protobufjs/pool': 1.1.0
|
||||||
|
'@protobufjs/utf8': 1.1.1
|
||||||
|
'@types/node': 22.19.19
|
||||||
|
long: 5.3.2
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user