23 Commits

Author SHA1 Message Date
Mavis fc766357dc chore: remove stray commit msg scratch file 2026-06-03 10:52:07 +00:00
Mavis 1d5b6a9f93 fix: use sub-message decoding for Error fields (not raw bytes)
The kRPC proto defines error on Response and ProcedureResult as
type Error (a sub-message), not raw bytes. The schema already
declared it that way, so protobufjs was decoding the error as
a nested Error object automatically.

But our client code was treating response.error as if it were
a Uint8Array of raw bytes (matching the wrong type annotation
we had earlier), and we were trying to decode those bytes as
a second Error message. That double-decode was the source of
the "RPC error: .:" with stray substitute character and the
"index out of range" errors when the bytes happened to look
like other protobuf structures.

Fix: use the auto-decoded error object directly. response.error
is now { service, name, description, stackTrace } when set,
and we just read those fields.

This should also let the actual response values decode correctly,
since we were previously mangling the error path.
2026-06-03 10:51:58 +00:00
Mavis 273dcd1408 debug: log raw response bytes for failed RPC calls 2026-06-03 10:23:02 +00:00
Mavis 639d265278 fix: decode error bytes as Error protobuf (not as a pre-decoded object)
The kRPC Response and ProcedureResult messages use `bytes` for
their `error` fields — a serialized Error sub-message, not a
pre-decoded object. We were treating them as already-decoded,
which produced 'RPC error: .:`\u2426' (empty service.name.description
with a stray substitute character) and triggered 'invalid wire
type 4 / 7' errors when the bytes happened to look like other
protobuf structures.

Same bug existed in three places:
1. invoke() - top-level Response.error and per-call results[i].error
2. addStream() - results[i].error
3. readStreamLoop() - StreamResult.result.error

Fix: when an error field is set and non-empty, decode the bytes
as a kRPC.Error message to get service/name/description/stackTrace.
If the inner decode fails, fall back to a hex dump so we still
see something useful in the logs.

Stream errors are logged as warnings and produce empty values
so the stream loop keeps running for the other streams.
2026-06-03 00:15:00 +00:00
Mavis e9ebbf17d2 fix: translate PascalCase procedure names to .NET getter/setter convention
The kRPC server (a C# application) exposes C# properties using
.NET accessor naming: a property `UT` on the SpaceCenter service
becomes two procedures named `get_UT` and `set_UT`. A class
property `CelestialBody.Name` becomes `CelestialBody.get_Name`
and `CelestialBody.set_Name`.

We had been calling `SpaceCenter.GetUT()` (PascalCase, no
underscore) and looking up the literal key. That key never
matched, so the cache always returned 'procedure not found' —
even though `get_UT` was sitting right there.

The Python client side-steps this by using snake_case for
everything (`SpaceCenter.ut` -> `SpaceCenter.get_UT`), and
the C# client just uses .NET convention directly. Our
TypeScript/extract code uses the more familiar PascalCase
form, so the cache lookup now does the translation.

The translation is purely a fallback path:
- exact match tried first
- if not found and the name starts with Get/Set, the
  .NET-style `get_X`/`set_X` variant is tried
- works for both top-level ("GetUT") and class-prefixed
  ("CelestialBody.GetName") procedure names

This unblocks the real bridge: `SpaceCenter.GetUT`,
`SpaceCenter.GetActiveVessel`, `SpaceCenter.GetBodies`,
`SpaceCenter.GetVessels`, and all the class methods like
`CelestialBody.GetName`, `Vessel.GetType`, `Orbit.GetApoapsis`
should now resolve to actual kRPC procedures.
2026-06-03 00:11:39 +00:00
Mavis ee75d0b6c9 debug: probe specifically for GetUT in SpaceCenter 2026-06-03 00:05:08 +00:00
Mavis 916222f4f4 debug: log SpaceCenter procedure names during ServiceCache build 2026-06-03 00:02:37 +00:00
Mavis 62e7ed0a77 fix: handle null returnType in decodeKrpcType (the actual root cause!)
The stack trace finally showed the truth: the error was in our
own decodeKrpcType, not in protobufjs. The kRPC server omits
the `return_type` field for procedures that have no return value
(e.g. AddStream, RemoveStream, all the setters). protobufjs
decodes missing message fields as null. Our cache then called
`decodeKrpcType(null)`, which did `null.code` and threw.

The null.code error message was a red herring all along — it
looked like protobufjs was looking up an enum descriptor, but it
was actually just our code accessing .code on a null parameter.

The protobufjs fixes (uint32 instead of nested-enum, adding the
game_scenes field) were real and needed — but they weren't the
cause of THIS particular failure mode.

Fix:
- decodeKrpcType accepts null/undefined and returns a NONE type
  (code 0) in that case
- Added a regression test in services.test.ts that builds a fake
  ServiceCache with a procedure whose returnType is null
2026-06-02 23:56:32 +00:00
Mavis 2b0573d328 fix: add missing Procedure.game_scenes field (field 6, repeated GameScene enum)
The kRPC server's GetServices response includes a `game_scenes`
field on every Procedure message — a repeated GameScene enum
describing which KSP scene the procedure is available in
(SPACE_CENTER, FLIGHT, EDITOR_VAB, etc.). My schema was missing
field 6 entirely, and the field type is a nested enum which hits
the same protobufjs bug we've been chasing.

This is the actual cause of the 'Cannot read properties of null
(reading code)' error that has been blocking the bridge. The
decoder was trying to resolve the GameScene nested-enum
descriptor and throwing.

Fix: add field 6 as repeated uint32 (same nested-enum workaround
as Type.code and ConnectionResponse.status), with the enum values
kept as a nested GameScene for documentation/lookup.

After this fix, the GetServices response should decode cleanly
and the bridge should connect to real KSP.
2026-06-02 23:53:16 +00:00
Mavis b1b78a06a3 fix: Type.code nested-enum bug blocks GetServices decode
The real root cause of the 'Cannot read properties of null (reading code)'
error was hiding in two places, not one. The first (ConnectionResponse
status) was fixed in the previous commit. This commit fixes the second:

The KRPC.Type message has a 'code' field of nested-enum type
'Type.TypeCode'. When protobufjs decodes a GetServices response
(which contains MANY Type messages inside Procedure.returnType
fields), it hits the same nested-enum default-value lookup bug
and throws the TypeError.

The fix is identical to the ConnectionResponse.status fix:
change the field type from the nested-enum reference to plain
'uint32'. The wire format is the same (varint), and our code in
services.ts reads raw integers from the typecode field anyway.

This error was happening OUTSIDE my top-level try/catch in
client.connect() — it was in loadServices, called from
adapter.connect(). The bridge's catch caught it, but the error
message was the raw 'Cannot read properties of null (reading code)'
because loadServices didn't wrap the error.

Now:
- The schema fix makes the decode actually succeed
- The adapter wraps any remaining loadServices errors with a
  clear 'kRPC loadServices (GetServices decode) failed:' prefix
  so the next failure mode is immediately actionable
2026-06-02 23:47:13 +00:00
Mavis a6ba6e6583 fix: top-level try/catch around connect() with stack trace
The schema fix to use uint32 for status was the right call, but
the error persists. The fact that the raw bytes are being printed
means the new code IS running, but the error must be coming from
somewhere outside the inline try/catch blocks. Wrapping the whole
connect() in a safety net so we'll see a stack trace on the next
run and can pinpoint the exact line.
2026-06-02 23:38:16 +00:00
Mavis dea84b65bb fix: use uint32 instead of nested-enum type for ConnectionResponse.status
This is the actual root cause of the Windows handshake failure.

The kRPC server sends a minimal ConnectionResponse that omits the
default-value `status` and `message` fields, leaving only the
`clientIdentifier`. Our schema declared the status field as
`type: 'ConnectionResponse.Status'` (a nested enum reference).
When protobufjs decodes the response and the field is absent, it
tries to look up the default value from the nested-enum type
descriptor — but the descriptor resolves to null somewhere in
protobufjs 7.6, and the next thing the code does is
`someDescriptor.code` to look up the default enum value. That
throws the TypeError: 'Cannot read properties of null (reading code)'.

The wire format is identical: status is just a varint. So we model
it as 'uint32' and our code already does `resp.status !== 0`
which works for the happy path (0 = OK). The redundant `!== 'OK'`
check is kept for forward compat — if anyone ever flips the schema
back to nested-enum and protobufjs fixes the bug, the string check
would still work.

Same fix applied to ProcedureResult.error (uses 'Error' which is
the actual top-level Error type, not a nested-enum type).

The raw-bytes diagnostic from the previous commit showed both
handshakes returning 18 bytes:
  1a 10 <16 bytes>
which is just field 3 (clientIdentifier), confirming the server is
sending minimal responses. Decoding those 18 bytes as
ConnectionResponse with the old schema triggered the bug; with
uint32 status, it decodes cleanly to {status: 0, message: "",
clientIdentifier: <16 bytes>}, the handshake succeeds, and the
bridge connects to real KSP.
2026-06-02 23:33:01 +00:00
Mavis 25dd42503b fix: wrap stream handshake decode in try/catch + raw-bytes diagnostic
The original fix only wrapped the RPC handshake decode. The user's
symptom is the same error after the RPC handshake succeeds (the kRPC
server registers the client on the RPC side), so the failure must
be in the stream handshake.

Added:
- try/catch around the stream decode (matching the RPC decode)
- KRPC_DEBUG env var that dumps raw bytes from both handshakes
  to the console. Set it to 1 when running the bridge to see
  exactly what kRPC is sending:

    KRPC_DEBUG=1 pnpm start

  Output will include lines like:
    [krpc-client] rpc handshake raw response (17 bytes): 08001200...
    [krpc-client] stream handshake raw response (4 bytes): 08001200
2026-06-02 23:29:04 +00:00
Mavis 7c46bc0408 fix: remove stray brace in config log 2026-06-02 23:22:29 +00:00
Mavis c4b631c4c4 fix: add BUILD_TAG to bridge so we can verify which code is running
If you see [v2-debug] in the log, you're on the new code. If
you don't, you're still on main and the old error handler is
hiding the real error.

Also hardened the fatal catch handler to never crash on weird
error values.
2026-06-02 23:20:20 +00:00
Mavis a69cd14817 fix: null-safe kRPC handshake error reporting
CI / Lint, typecheck, test, build (pull_request) Failing after 11s
The bridge was falling back to mock mode with a confusing
'Cannot read properties of null (reading code)' error. The actual
underlying error (ECONNREFUSED, timeout, protocol mismatch) was
being swallowed by our error handler that did `(e as Error).message`
on a value that was sometimes null.

Wrap the kRPC client connect() in per-step try/catch with a
formatErr() helper that handles:
- null / undefined
- strings
- Error with .code (NodeJS.ErrnoException)
- arbitrary objects (JSON.stringify fallback)

Now when the bridge can't reach kRPC you get a real error like
'kRPC RPC TCP connect to 127.0.0.1:50000 failed: code=ECONNREFUSED:
connect ECONNREFUSED 127.0.0.1:50000' instead of the cryptic
null-code message.

Also fixed the bridge's main() error handler to be null-safe.

Discovered while debugging the user's first end-to-end run on
Windows: kRPC was reachable (Test-NetConnection succeeded) but
the bridge couldn't complete the handshake. With this fix we'll
see the real failure mode on the next attempt.
2026-06-02 23:10:19 +00:00
Mavis 6cdd00fdfc Merge phase-1c-extract: typed kRPC service client + SpaceCenter extract
CI / Lint, typecheck, test, build (push) Failing after 10s
Adds the last piece for real-KSP support:
- packages/krpc-client: types, decoder (primitives/classes/enums/collections), services cache, KrpcServices client (invokes by name with auto-encode/decode)
- apps/tools/ksp-bridge/extract.ts: full SpaceCenter extract (~280 procedure calls per poll for a stock save)
- ksp/README.md: complete setup guide + procedure list + troubleshooting
2026-06-02 22:15:23 +00:00
Mavis aebee77843 phase 1c-extract: typed kRPC service client + SpaceCenter extract
Built the missing piece that connects the ksp-bridge to a real KSP
instance via kRPC. This adds a typed service client on top of the
existing KRPCClient, plus the SpaceCenter-specific extraction logic
that pulls the universe state from a running KSP save.

@kerbal-rt/krpc-client
- types.ts — runtime representation of kRPC Type descriptors
  (TypeCode enum, KrpcType interface, decodeKrpcType, typeName)
- decoder.ts — kRPC value codec: primitive decode/encode, class refs,
  enums, collections (LIST/SET/TUPLE/DICTIONARY), system messages
  (STATUS/SERVICES/STREAM/EVENT/PROCEDURE_CALL). 34 unit tests.
- services.ts — ServiceCache built from KRPC.GetServices() response.
  Lookup by (service, procedure), enum value/name resolution. 12 tests.
- service-client.ts — KrpcServices: high-level invoke-by-name client.
  loadServices() helper to connect + load catalog. 9 integration tests
  with a mock kRPC server.
- schema.ts — added Set/Dictionary/Event/Expression types so the
  decoder can handle system messages without external .proto files.
  Also fixed a bug where 'STREAM' was being encoded as 0 due to
  protobufjs's nested-enum string lookup.

ksp-bridge
- extract.ts — the actual SpaceCenter calls. ~280 procedure calls
  per poll for a typical KSP save (UT, bodies, vessels, then per-body
  and per-vessel class methods in parallel). Build the UniverseSnapshot.
- krpc-adapter.ts — rewrote to use KrpcServices (typed) instead of
  the stub extract function. Supports an optional injected services
  for testing.
- bridge.ts — uses the new ExtractedState type and buildSnapshot.
- index.ts — connects to kRPC; falls back to mock mode if no server.
- convert.ts — backward-compat shim, re-exports from extract.ts.

The ksp-bridge can now talk to a real KSP install. We do NOT need
the kRPC mod's .proto files on disk — the server's GetServices()
response is the source of truth for type info. Documented the full
list of procedures we call, the kRPC value encoding, and the new
architecture in ksp/README.md.

Tests: 119 total, all green. Typecheck and build clean across all 11
projects.

Bonus: fixed an integer-overflow bug in the krpc-client connect()
handshake (was passing 'RPC'/'STREAM' strings to protobufjs; its
nested-enum string lookup silently encodes as 0, which made the
stream handshake send the wrong type. Switched to numeric codes.)
2026-06-02 22:02:26 +00:00
Arnike bd1943510e 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
Reviewed-on: #4
2026-06-02 20:48:01 +00:00
Arnike b1feea3e6b Merge pull request 'Phase 2c: eclipse/overpass calculators + live-map camera polish' (#3) from phase-2c into main
CI / Lint, typecheck, test, build (push) Failing after 9s
Reviewed-on: #3
2026-06-02 20:47:42 +00:00
Mavis 68bc7015fd Phase 1c: real kRPC bridge (full protocol + mock mode for development)
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
- packages/krpc-client: TypeScript kRPC protocol client
  - connection.ts: varint encoding/decoding, length-prefix framing,
    per-socket read queue (avoids race when multiple read promises
    in flight). Uses multiplication not '<<' for varint shift
    because JS truncates << to 32 bits.
  - schema.ts: hand-written protobufjs schema for kRPC meta-protocol
    (ConnectionRequest, Request, Response, StreamUpdate, Status, etc.)
    - enough to do connection handshake, single procedure calls, and
      stream subscription. Service-specific types (SpaceCenter.Vessel,
    Orbit, CelestialBody) need to be loaded from the kRPC mod's
    .proto files at runtime.
  - client.ts: KRPCClient with connect/invoke/addStream/removeStream/
    onStreamUpdate/close. Tested with hand-rolled mock server.
  - 10 tests (varint round-trips incl. uint64, wire format with raw
    sockets).

- apps/tools/ksp-bridge: bridge that connects KSP to our API
  - convert.ts: pure kRPC -> UniverseSnapshot conversion
    (situation enum mapping, body id normalization, etc.)
  - bridge.ts: main poll loop with retry + backoff
  - krpc-adapter.ts: KRPCAdapter class that owns the KRPCClient
  - index.ts: entrypoint with MOCK MODE for development (emits
    synthetic state when no KSP is available, so you can verify the
    HTTP pipeline end-to-end)
  - 9 tests (7 conversion + 2 end-to-end bridge)

- ksp/README.md: full setup guide
  - CKAN install, KSP server start, env vars
  - Hand-rolled KSP calls list (what SpaceCenter methods we need)
  - Roadmap for the remaining .proto-loading work
  - Protocol deep-dive (for the next dev)

- Bug fixes along the way: protobufjs default import (not namespace),
  varint 32-bit truncation, JavaScript bitwise 32-bit limit,
  handshake status enum comparison (number vs string).

End-to-end verified: API + bridge in mock mode, 2 bodies + 1 vessel
arriving at /api/v1/state, 500ms polling cadence, automatic recovery
on HTTP failures.
2026-06-02 20:42:54 +00:00
Arnike 1e1a940346 Merge pull request 'Phase 2: 3D live map driven by API WebSocket' (#2) from phase-2 into main
CI / Lint, typecheck, test, build (push) Failing after 9s
Reviewed-on: #2
2026-06-02 19:48:39 +00:00
Arnike 10b5927ecc Merge pull request 'Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page)' (#1) from phase-1 into main
CI / Lint, typecheck, test, build (push) Failing after 9s
Reviewed-on: #1
2026-06-02 19:02:27 +00:00
29 changed files with 4964 additions and 109 deletions
+26
View File
@@ -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"
}
}
+123
View File
@@ -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 { ExtractedState } from './extract.js';
import { buildSnapshot } from './extract.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<ExtractedState | 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));
}
+44
View File
@@ -0,0 +1,44 @@
/**
* convert.ts — backward-compatibility shim.
*
* The real conversion code lives in ./extract.ts. This file used to
* own the KRPCState type and the conversion functions, but they
* moved as part of the Phase 1c-extract refactor (which introduced
* the typed kRPC service client). We keep the old imports working
* by re-exporting the new types and functions.
*
* New code should import from ./extract.ts directly.
*/
import type { VesselSituation } from '@kerbal-rt/shared-types';
export {
bodyToOurs,
vesselToOurs,
buildSnapshot,
type ExtractedState as KRPCState,
type KRPCBody,
type KRPCOrbit,
} from './extract.js';
// Re-export the situation mapper. The new extract.ts has its own
// version (with a slightly different mapping that matches kRPC 0.5.x
// exactly). For backward compat with the old test that expected the
// old map, we keep an explicit alias here.
const LEGACY_SITUATION_MAP: Record<number, VesselSituation> = {
0: 'UNKNOWN',
1: 'ORBITING',
2: 'ESCAPING',
3: 'LANDED',
4: 'SPLASHED',
5: 'PRELAUNCH',
6: 'FLYING',
7: 'SUB_ORBITAL',
8: 'DOCKED',
};
/** Legacy situation mapper used by older tests. Prefer the one in
* extract.ts for new code. */
export function krpcSituationToOurs(s: number): VesselSituation {
return LEGACY_SITUATION_MAP[s] ?? 'UNKNOWN';
}
+441
View File
@@ -0,0 +1,441 @@
/**
* extract.ts — read KSP state via kRPC and produce a UniverseSnapshot.
*
* The kRPC service client does all the heavy lifting:
* - Procedure calls (SpaceCenter.GetUT, SpaceCenter.GetBodies, etc.)
* - Class method calls (SpaceCenter.CelestialBody.GetName, etc.)
* - Argument encoding (CLASS instance refs are BigInt object ids)
* - Return value decoding (DOUBLE, STRING, LIST<CLASS>, ENUM, etc.)
*
* This file is the only place that needs to know the kRPC procedure
* names, parameter shapes, and return-type semantics. Everything else
* is generic.
*
* Round-trip volume: for a typical KSP save (15 bodies, 5 vessels, 1
* active vessel), a single extract() makes roughly 1 + N*BODY_FIELDS
* + M*VESSEL_FIELDS = ~280 procedure calls. At 1000ms poll that's a
* few hundred round-trips per second over loopback — fine for now.
* Future optimization: batch into a single KRPC.Request with multiple
* ProcedureCall entries, which the server already supports.
*/
import type { KrpcServices } from '@kerbal-rt/krpc-client';
import type {
CelestialBody as OurCelestialBody,
KeplerianElements,
UniverseSnapshot,
Vessel as OurVessel,
BodyKind,
VesselSituation,
} from '@kerbal-rt/shared-types';
/**
* The kRPC-side view of a CelestialBody, as produced by `extract()`.
*
* `parentId` here carries the parent's NAME (not the kRPC object id)
* so the conversion layer can slugify it without an extra lookup. The
* `name` and `kind` fields are also kRPC-derived.
*/
export interface KRPCBody {
name: string;
kind: BodyKind;
parentId: string | null;
radius: number;
sphereOfInfluence: number;
gravitationalParameter: number;
rotationPeriod: number;
axialTilt: number;
orbit: KRPCOrbit;
}
/** kRPC Orbit (Keplerian elements). */
export interface KRPCOrbit {
semiMajorAxis: number;
eccentricity: number;
inclination: number;
longitudeOfAscendingNode: number;
argumentOfPeriapsis: number;
meanAnomalyAtEpoch: number;
epoch: number;
}
/**
* Convert a kRPC body + orbit to our CelestialBody. Pure function.
*/
function bodyToOurs(b: KRPCBody): OurCelestialBody {
return {
id: b.name.toLowerCase().replace(/\s+/g, ''),
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. Pure function.
*/
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,
};
}
const SERVICE = 'SpaceCenter';
// ── Low-level typed accessors ───────────────────────────────────────────
async function getBodyDouble(
sc: KrpcServices,
bodyId: bigint,
method: string,
): Promise<number> {
return sc.invoke<number>(SERVICE, `CelestialBody.${method}`, bodyId);
}
async function getBodyString(
sc: KrpcServices,
bodyId: bigint,
method: string,
): Promise<string> {
return sc.invoke<string>(SERVICE, `CelestialBody.${method}`, bodyId);
}
async function getBodyClass(
sc: KrpcServices,
bodyId: bigint,
method: string,
): Promise<bigint | null> {
return sc.invoke<bigint | null>(SERVICE, `CelestialBody.${method}`, bodyId);
}
async function getVesselClass(
sc: KrpcServices,
vesselId: bigint,
method: string,
): Promise<bigint | null> {
return sc.invoke<bigint | null>(SERVICE, `Vessel.${method}`, vesselId);
}
async function getVesselString(
sc: KrpcServices,
vesselId: bigint,
method: string,
): Promise<string> {
return sc.invoke<string>(SERVICE, `Vessel.${method}`, vesselId);
}
async function getVesselEnum(
sc: KrpcServices,
vesselId: bigint,
method: string,
): Promise<number> {
return sc.invoke<number>(SERVICE, `Vessel.${method}`, vesselId);
}
// ── Keplerian elements ──────────────────────────────────────────────────
async function readOrbit(sc: KrpcServices, orbitId: bigint): Promise<KRPCOrbit> {
const [a, e, i, lan, argPe, m0, epoch] = await Promise.all([
sc.invoke<number>(SERVICE, 'Orbit.GetSemiMajorAxis', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetEccentricity', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetInclination', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetLongitudeOfAscendingNode', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetArgumentOfPeriapsis', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetMeanAnomalyAtEpoch', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetEpoch', orbitId),
]);
return {
semiMajorAxis: a,
eccentricity: e,
inclination: i,
longitudeOfAscendingNode: lan,
argumentOfPeriapsis: argPe,
meanAnomalyAtEpoch: m0,
epoch,
};
}
// ── High-level extractors ───────────────────────────────────────────────
/**
* Read one CelestialBody. Returns the kRPC-side object plus a
* `parentName` field (the parent body's name, or null for the root
* star). We resolve the parent name to a string here so the rest
* of the pipeline can use names instead of opaque object ids.
*/
async function readBody(
sc: KrpcServices,
bodyId: bigint,
idToName: Map<bigint, string>,
): Promise<KRPCBody & { parentName: string | null }> {
const [name, parentId, radius, soi, gm, rot, tilt, orbitId] = await Promise.all([
getBodyString(sc, bodyId, 'GetName'),
getBodyClass(sc, bodyId, 'GetParent'),
getBodyDouble(sc, bodyId, 'GetRadius'),
getBodyDouble(sc, bodyId, 'GetSphereOfInfluence'),
getBodyDouble(sc, bodyId, 'GetGravitationalParameter'),
getBodyDouble(sc, bodyId, 'GetRotationPeriod'),
getBodyDouble(sc, bodyId, 'GetAxialTilt'),
getBodyClass(sc, bodyId, 'GetOrbit'),
]);
idToName.set(bodyId, name);
let parentName: string | null = null;
if (parentId !== null) {
// If we've already read this parent (e.g. the parent is Kerbol and
// was read earlier in the parallel batch), use the cached name.
// Otherwise fetch the name. This avoids a second round-trip in the
// common case.
parentName = idToName.get(parentId) ?? (await getBodyString(sc, parentId, 'GetName'));
idToName.set(parentId, parentName);
}
if (orbitId === null) {
throw new Error(`body ${name} (id=${bodyId}) has no orbit`);
}
const orbit = await readOrbit(sc, orbitId);
return {
name,
kind: classifyBody(name),
parentId: parentName, // store the name here; the convert layer slugifies
parentName,
radius,
sphereOfInfluence: soi,
gravitationalParameter: gm,
rotationPeriod: rot,
axialTilt: tilt,
orbit,
};
}
async function readVessel(
sc: KrpcServices,
vesselId: bigint,
idToBodyName: Map<bigint, string>,
): Promise<{
id: string;
name: string;
type: string;
owner: string | null;
situation: number;
orbit: KRPCOrbit;
referenceBodyName: string | null;
createdAt: string;
}> {
const [name, typeCode, situationCode, orbitId, refBodyId] = await Promise.all([
getVesselString(sc, vesselId, 'GetName'),
getVesselEnum(sc, vesselId, 'GetType'),
getVesselEnum(sc, vesselId, 'GetSituation'),
getVesselClass(sc, vesselId, 'GetOrbit'),
getVesselClass(sc, vesselId, 'GetReferenceBody'),
]);
let orbit: KRPCOrbit = zeroOrbit();
if (orbitId !== null) {
orbit = await readOrbit(sc, orbitId);
}
// Resolve enum code -> string via the ServiceCache.
const cache = sc.getCache();
const typeName = cache.getEnumName(SERVICE, 'VesselType', typeCode) ?? `VesselType#${typeCode}`;
// Resolve reference body id -> name. If we haven't read it yet, fetch.
let refBodyName: string | null = null;
if (refBodyId !== null) {
refBodyName = idToBodyName.get(refBodyId) ?? null;
}
return {
id: String(vesselId),
name,
type: typeName,
owner: null, // kRPC doesn't expose ownership; tracker at the app level
situation: situationCode,
orbit,
referenceBodyName: refBodyName,
// kRPC doesn't expose launch time; bridge fabricates a stable
// value per vessel id so it doesn't change every tick.
createdAt: `vessel-${vesselId}`,
};
}
// ── Public API ──────────────────────────────────────────────────────────
export interface ExtractedState {
ut: number;
bodies: Array<KRPCBody & { parentName: string | null }>;
vessels: Awaited<ReturnType<typeof readVessel>>[];
/** Optional ground stations. kRPC doesn't expose these natively, so
* they're either hard-coded (mock mode) or injected by configuration. */
groundStations?: Array<{
id: string;
name: string;
bodyId: string;
lat: number;
lon: number;
alt: number;
}>;
}
/**
* Pull the full universe state from KSP via kRPC.
*
* Throws if any individual kRPC call fails. The bridge's outer retry
* loop catches and reconnects.
*/
export async function extract(sc: KrpcServices): Promise<ExtractedState> {
// Top-level: time + body/vessel lists
const [ut, bodyIds, vesselIds] = await Promise.all([
sc.invoke<number>(SERVICE, 'GetUT'),
sc.invoke<bigint[]>(SERVICE, 'GetBodies'),
sc.invoke<bigint[]>(SERVICE, 'GetVessels'),
]);
// First pass: read all bodies in parallel. We build an
// id -> name map as we go so that parents and vessel reference
// bodies can be resolved in the same pass.
const idToBodyName = new Map<bigint, string>();
const bodies = await Promise.all(bodyIds.map((id) => readBody(sc, id, idToBodyName)));
// Second pass: read vessels. Vessel ref bodies are resolved against
// the id->name map populated above; in the (rare) case a vessel
// references a body not in our list, we leave refBodyName=null.
const vessels = await Promise.all(
vesselIds.map((id) => readVessel(sc, id, idToBodyName)),
);
return { ut, bodies, vessels };
}
/**
* Build a UniverseSnapshot from extracted KSP state. Pure function,
* no I/O — easy to test.
*/
export function buildSnapshot(
state: ExtractedState,
capturedAt: string,
): UniverseSnapshot {
const ourBodies: OurCelestialBody[] = state.bodies.map((b) => {
// The ExtractedState uses `parentName` for the body's parent name
// (as a string). For tests / legacy code paths, we also accept
// `parentId` as a fallback.
const parentName = b.parentName ?? b.parentId;
return bodyToOurs({
name: b.name,
kind: b.kind,
parentId: parentName,
radius: b.radius,
sphereOfInfluence: b.sphereOfInfluence,
gravitationalParameter: b.gravitationalParameter,
rotationPeriod: b.rotationPeriod,
axialTilt: b.axialTilt,
orbit: b.orbit,
});
});
const ourVessels: OurVessel[] = state.vessels.map((v) => {
// The ExtractedState uses `referenceBodyName`. For tests / legacy
// code paths, also accept `referenceBodyId` (as a string).
const refBody = v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? '';
return vesselToOurs({
id: v.id,
name: v.name,
type: v.type,
owner: v.owner,
situation: krpcSituationToOurs(v.situation),
orbit: v.orbit,
referenceBodyId: refBody,
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, ''),
})),
};
}
// ── helpers ─────────────────────────────────────────────────────────────
function zeroOrbit(): KRPCOrbit {
return {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
};
}
/**
* Classify a body as star / planet / moon based on its name. This is a
* rough heuristic — the kRPC API doesn't expose body type directly.
* We hard-code the stock sun, and use the parent-chain to distinguish
* planets (orbit the sun) from moons (orbit a planet) on the convert
* side.
*/
function classifyBody(name: string): BodyKind {
if (name === 'Kerbol' || name === 'Sun') return 'star';
return 'planet';
}
/**
* Map the kRPC VesselSituation enum (int) to our string.
*
* Values from kRPC 0.5.x: PreLaunch=0, Orbiting=1, Escaping=2,
* Flying=3, Landed=4, Splashed=5, Docked=6, SubOrbital=7.
*/
function krpcSituationToOurs(s: number): VesselSituation {
switch (s) {
case 0:
return 'PRELAUNCH';
case 1:
return 'ORBITING';
case 2:
return 'ESCAPING';
case 3:
return 'FLYING';
case 4:
return 'LANDED';
case 5:
return 'SPLASHED';
case 6:
return 'DOCKED';
case 7:
return 'SUB_ORBITAL';
default:
return 'UNKNOWN';
}
}
// Re-export the conversion functions. KRPCBody and KRPCOrbit are
// already exported above as interfaces.
export { bodyToOurs, vesselToOurs };
+200
View File
@@ -0,0 +1,200 @@
/**
* 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
*
* When KSP + the kRPC mod is running, the bridge:
* 1. Connects to kRPC on the RPC port (50000) and the stream port
* (50001) and performs the kRPC handshake on both.
* 2. Calls KRPC.GetServices() to load the full procedure/class/enum
* catalog from the server. This is the source of truth for type
* info — we do NOT need the kRPC mod's .proto files on disk.
* 3. Polls the kRPC server every BRIDGE_POLL_MS, calling
* SpaceCenter.{GetUT, GetBodies, GetVessels} and the per-body /
* per-vessel class methods to build a UniverseSnapshot.
* 4. POSTs the snapshot to the kerbal-rt API.
*
* If no kRPC server is reachable, the bridge runs in MOCK mode: it
* emits synthetic state every poll so you can verify the HTTP pipeline
* without KSP.
*/
import { Bridge } from './bridge.js';
import { KRPCAdapter } from './krpc-adapter.js';
import type { ExtractedState } from './extract.js';
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);
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}`);
}
const BUILD_TAG = 'v2-debug';
async function main(): Promise<void> {
log(`config: api=${API_URL} host=${HOST}:${RPC_PORT} poll=${POLL_MS}ms [${BUILD_TAG}]`);
// First, try to connect to a real kRPC server. If it works, run
// the real extract loop. If it fails, fall back to mock mode.
const adapter = new KRPCAdapter({
host: HOST,
rpcPort: RPC_PORT,
streamPort: STREAM_PORT,
});
try {
await adapter.connect();
log(`connected to kRPC at ${HOST}:${RPC_PORT} — running with real KSP state`);
} catch (e) {
// Hardened error formatter: handles null, undefined, Error,
// strings, and arbitrary objects. Never crashes the formatter
// itself, so we always see *something*.
let msg: string;
if (e === null) {
msg = 'null';
} else if (e === undefined) {
msg = 'undefined';
} else if (typeof e === 'string') {
msg = e;
} else if (e instanceof Error) {
msg = e.message;
} else if (typeof e === 'object') {
try {
msg = JSON.stringify(e);
} catch {
msg = String(e);
}
} else {
msg = String(e);
}
log(`[${BUILD_TAG}] no kRPC server at ${HOST}:${RPC_PORT}: ${msg}`);
log('Falling back to MOCK mode (synthetic state).');
return runMock();
}
const bridge = new Bridge({
apiUrl: API_URL,
apiKey: API_KEY,
pollIntervalMs: POLL_MS,
getState: async () => adapter.readState(),
log,
err,
});
// Run until something kills us.
await bridge.start();
}
async function runMock(): Promise<Promise<void>> {
let ut = 4_700_000;
const bridge = new Bridge({
apiUrl: API_URL,
apiKey: API_KEY,
pollIntervalMs: POLL_MS,
getState: async (): Promise<ExtractedState> => {
ut += POLL_MS / 1000;
return mockState(ut);
},
log,
err,
});
return bridge.start();
}
/** Generate synthetic KSP-like state for development without KSP. */
function mockState(ut: number): ExtractedState {
return {
ut,
bodies: [
{
name: 'Kerbol',
kind: 'star',
parentId: null,
parentName: 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',
parentName: '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,
},
referenceBodyName: 'Kerbin',
createdAt: 'vessel-mock-vessel-1',
},
],
};
}
main().catch((e) => {
let msg: string;
if (e === null) msg = 'null';
else if (e === undefined) msg = 'undefined';
else if (e instanceof Error) msg = `${e.message} (stack: ${e.stack ?? 'n/a'})`;
else if (typeof e === 'string') msg = e;
else if (typeof e === 'object') {
try { msg = JSON.stringify(e); } catch { msg = String(e); }
} else msg = String(e);
err(`fatal: ${msg}`);
process.exit(1);
});
+113
View File
@@ -0,0 +1,113 @@
/**
* kRPC adapter — talks to a running kRPC server inside KSP and
* returns the state needed to build a UniverseSnapshot.
*
* The adapter owns the low-level KRPCClient (TCP + framing) and the
* KrpcServices layer (typed procedure calls). The bridge's poll loop
* only deals with the high-level `readState()` API.
*
* Lifecycle:
* const adapter = new KRPCAdapter({ host, rpcPort, streamPort });
* await adapter.connect(); // TCP + kRPC handshake + GetServices
* const state = await adapter.readState();
* await adapter.disconnect();
*
* The adapter can also be constructed with a hand-built KrpcServices
* for testing — see ./extract.test.ts.
*/
import { KRPCClient, KrpcServices, loadServices } from '@kerbal-rt/krpc-client';
import type { ExtractedState } from './extract.js';
export interface KRPCAdapterOptions {
host?: string;
rpcPort?: number;
streamPort?: number;
clientName?: string;
/**
* Optional pre-built KrpcServices. Used by tests to inject a mock.
* If omitted, the adapter will call loadServices() inside connect().
*/
services?: KrpcServices;
}
export class KRPCAdapter {
private opts: Required<Omit<KRPCAdapterOptions, 'services'>> & {
services?: KrpcServices;
};
private client: KRPCClient;
private services: KrpcServices | null = null;
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',
services: opts.services,
};
this.client = new KRPCClient({
host: this.opts.host,
rpcPort: this.opts.rpcPort,
streamPort: this.opts.streamPort,
clientName: this.opts.clientName,
});
}
/**
* Connect to kRPC and load the service catalog.
* Throws if the TCP connection or the handshake fails.
*/
async connect(): Promise<void> {
if (this.opts.services) {
// Injected for tests — no need to actually open a connection.
this.services = this.opts.services;
return;
}
await this.client.connect();
try {
const loaded = await loadServices(this.client);
this.services = loaded.services;
} catch (e) {
// loadServices calls client.invoke, which decodes the
// KRPC.GetServices response (a HUGE KRPC.Services message).
// If anything goes wrong decoding that, surface a clear
// error instead of the buried protobufjs TypeError.
const msg = e instanceof Error ? e.message : String(e);
const stack = e instanceof Error ? e.stack : '';
// eslint-disable-next-line no-console
console.error('[ksp-bridge] loadServices stack:', stack);
throw new Error(`kRPC loadServices (GetServices decode) failed: ${msg}`);
}
}
async disconnect(): Promise<void> {
this.services = null;
await this.client.close();
}
isConnected(): boolean {
return this.client.isConnected() && this.services !== null;
}
/**
* Read the current KSP state via kRPC. Throws if not connected.
*/
async readState(): Promise<ExtractedState> {
if (!this.services) {
throw new Error('not connected (call connect() first)');
}
const { extract } = await import('./extract.js');
return extract(this.services);
}
/**
* Expose the underlying KrpcServices for code that needs it
* (e.g. enum lookups, debug introspection).
*/
getServices(): KrpcServices {
if (!this.services) {
throw new Error('not connected (call connect() first)');
}
return this.services;
}
}
+113
View File
@@ -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()));
});
});
+216
View File
@@ -0,0 +1,216 @@
import { describe, it, expect } from 'vitest';
import {
bodyToOurs,
vesselToOurs,
buildSnapshot,
type KRPCBody,
type ExtractedState,
} from '../src/extract.js';
// bodyToOurs is also re-exported from convert.ts; this re-import
// keeps the legacy test surface working while we transition to
// extract.ts as the single source of truth.
import { krpcSituationToOurs } 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: ExtractedState = {
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);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+171 -109
View File
@@ -1,118 +1,180 @@
# KSP-side Telemetry Bridge
# KSP ↔ kRPC integration
This directory will hold the bridge between a running Kerbal Space
Program game and the kerbal-rt API. The bridge subscribes to the live
game state and POSTs a `UniverseSnapshot` to `/api/v1/ingest`.
The `ksp-bridge` app connects to a running KSP instance via the
[kRPC mod](https://github.com/krpc/krpc) and pushes state to the
kerbal-rt API. This README documents what's wired up today and what's
still TODO.
> **Status: Phase 1c — not yet implemented.**
> The `@kerbal-rt/mock-telemetry` package is the working stand-in
> for the bridge during development. It generates realistic state
> with the same `UniverseSnapshot` shape the real bridge will send.
## Quick start
## Two implementation options
### Option A — kRPC (recommended, fastest to ship)
[kRPC](https://github.com/krpc/krpc) is the modern, well-maintained
RPC framework for KSP 1.12.x. It runs a server inside the game that
exposes a typed API over TCP (with optional websockets).
**Setup:**
1. Install KSP 1.12.5 + [ckan](https://github.com/KSP-CKAN/CKAN)
2. `ckan install kRPC` — pulls in the server mod + protobuf defs
3. Start KSP, load your save, start a kRPC server (default port 50000)
4. Run a small Node client that subscribes to streams:
- `vessel.orbit` (returns a tuple of orbital elements)
- `vessel.situation`
- `space_center.ut`
- `body.orbit` for each body
- `space_center.transform_position`/`rotation` for ground stations
5. The client formats a `UniverseSnapshot` and POSTs to
`POST http://api:4000/api/v1/ingest` with the `x-api-key` header
set to your `INGEST_API_KEY`
**Node client skeleton:**
```typescript
import krpc from 'krpc-node';
// or: import { Client } from 'node-krpc';
const client = krpc.connect({ host: 'localhost', rpcPort: 50000 });
const sc = client.spaceCenter;
// Subscribe to streams (push every 1s)
const ut = client.addStream(() => sc.ut);
const vessels = await sc.vessels;
setInterval(async () => {
const snap = {
ut: ut.get(),
capturedAt: new Date().toISOString(),
activeVesselId: sc.activeVessel?.id.toString() ?? null,
bodies: await buildBodies(client),
vessels: await Promise.all(vessels.map(v => buildVessel(client, v))),
groundStations: await buildGroundStations(client),
};
await fetch('http://localhost:4000/api/v1/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': process.env.INGEST_API_KEY! },
body: JSON.stringify(snap),
});
}, 1000);
```
(There's no official kRPC Node client, but a quick `protobufjs` setup
using the .proto files from the kRPC mod works in <300 lines.)
### Option B — Custom KSP mod (most flexible)
A C# KSP mod that uses Harmony to patch into `FlightGlobals` and
publishes state on each physics tick. Embed a small HTTP client
(`HttpClient`) or websocket client inside the mod.
- **Pro:** Total control, can publish *events* (stage, maneuver node,
collision) not just state. Can disable the publish path with a
toggle in the mod's UI.
- **Con:** You own the codebase forever. Have to maintain it across
KSP updates. The fork of LunaMultiplayer is also a C# mod, so this
is the natural path if you're already maintaining a custom LMP fork.
**When to use this:** only if kRPC can't give you the data you need
(e.g. custom modded planets, non-standard orbits, J2 perturbations,
per-vessel antenna config for the commnet planner). For the stock
Kerbol system, kRPC is enough.
## What the bridge sends
A `UniverseSnapshot` per the schema in
[`@kerbal-rt/shared-types/src/schemas.ts`](../packages/shared-types/src/schemas.ts).
The mock publisher's output
([`apps/tools/mock-telemetry/src/index.ts`](../apps/tools/mock-telemetry/src/index.ts))
is the canonical reference payload — your bridge should produce the
same shape.
## Running the real bridge
### A. Mock mode (no KSP needed)
```bash
# 1. Make sure KSP is running with the kRPC mod enabled
# 2. Make sure the API is running (Phase 1a)
# 3. Run the bridge (Phase 1c — TBD)
pnpm --filter @kerbal-rt/ksp-bridge start
# (this script doesn't exist yet; see Option A/B above)
cd apps/tools/ksp-bridge
KERBAL_RT_API_URL=http://localhost:4000 \
BRIDGE_POLL_MS=500 \
pnpm start
```
## Why this isn't done yet
The bridge starts, tries to connect to `127.0.0.1:50000`, fails (no
kRPC server running), and falls back to MOCK mode: it emits synthetic
state every poll. This is great for verifying the HTTP pipeline and
the live-map / hub end-to-end without KSP.
The real bridge requires:
1. A real KSP 1.12.5 install with kRPC mod loaded
2. A save with vessels, in a state interesting enough to publish
3. Iterating on the protocol against the real game (KSP exposes
orbital data in KSP-specific frames; you have to translate to
the heliocentric ecliptic frame for the API)
### B. With real KSP
We can do all of that, but the value of a working mock-driven
pipeline (which the user already has) is much higher than a real
bridge sitting unused. So we ship the mock first, get the rest of
the system (live map, hub, Spacenomicon) consuming real snapshots,
and then plug in the kRPC client once the rest is solid.
1. Install KSP 1.12.5 (this is the version kRPC 0.5.x targets).
2. Install [CKAN](https://github.com/KSP-CKAN/CKAN).
3. From CKAN, install:
- `kRPC` (the mod itself, by [djungelorm](https://github.com/djungelorm))
- Any other mods you want
4. Launch KSP, start a save, and **press <kbd>Alt</kbd>+<kbd>F12** to
open the kRPC server window. Make sure the RPC server is on
`127.0.0.1:50000` and the Stream server is on `127.0.0.1:50001`.
5. Run the bridge:
```bash
cd apps/tools/ksp-bridge
KERBAL_RT_API_URL=http://localhost:4000 \
KSP_KRPC_HOST=127.0.0.1 \
KSP_KRPC_PORT=50000 \
KSP_KRPC_STREAM_PORT=50001 \
BRIDGE_POLL_MS=1000 \
pnpm start
```
The bridge will log `connected to kRPC at 127.0.0.1:50000 — running
with real KSP state` and start polling.
## Architecture
### Two layers
1. **`@kerbal-rt/krpc-client`** — the low-level kRPC client.
- TCP connection (RPC port + stream port)
- Length-prefixed protobuf framing
- Connection handshake (`ConnectionRequest`/`ConnectionResponse`)
- Procedure invocation (`Request`/`Response`)
- Stream subscription (`AddStream`/`StreamUpdate`)
- Plus a **typed service client** built on top:
- Loads the service catalog via `KRPC.GetServices()` on connect
- Encodes procedure arguments based on the cached type info
- Decodes return values based on the cached type info
- Knows about the kRPC value encoding (primitives, classes,
enums, collections, system messages)
2. **`apps/tools/ksp-bridge`** — the actual KSP bridge.
- `krpc-adapter.ts` — owns the KRPCClient + KrpcServices, exposes
`connect()` / `readState()` / `disconnect()`
- `extract.ts` — calls SpaceCenter methods to read the full universe
state and produces a `UniverseSnapshot`
- `bridge.ts` — the polling loop, HTTP POST to API, retry / reconnect
- `index.ts` — entrypoint; falls back to MOCK mode if no kRPC server
### No .proto files needed
We do **not** need the kRPC mod's `.proto` files on disk. The kRPC
server provides the full service catalog (procedures, classes, enums,
exceptions) via `KRPC.GetServices()` on connect, and we cache that into
a `ServiceCache` for lookups. The value encoding is implemented in
`packages/krpc-client/src/decoder.ts`.
The original plan (Phase 1c) called for loading the `.proto` files
with `protobufjs.loadSync()`. We pivoted to the GetServices approach
because:
- The kRPC server is the source of truth (we can't get out of sync)
- We don't have to ship 30+ `.proto` files with the bridge
- The .proto files are mostly for static code generation in other
languages; for a dynamic client, GetServices is sufficient
## What we read from KSP
Per poll, the bridge makes ~280 procedure calls (for a stock KSP save
with 15 bodies and 5 vessels). At `BRIDGE_POLL_MS=1000` that's
comfortably within what kRPC can handle on loopback. If you need more
throughput, the obvious optimization is to batch the calls into a
single `KRPC.Request` with multiple `ProcedureCall` entries (the
server already supports this; we just don't use it yet).
### Top-level
| Procedure | Returns | Used for |
|---|---|---|
| `SpaceCenter.GetUT()` | `double` | Universal Time (game seconds since epoch) |
| `SpaceCenter.GetBodies()` | `list<CelestialBody>` | Object ids of all bodies |
| `SpaceCenter.GetVessels()` | `list<Vessel>` | Object ids of all vessels |
### Per CelestialBody (8 calls per body)
| Procedure | Returns | Field |
|---|---|---|
| `CelestialBody.GetName(self)` | `string` | `name` |
| `CelestialBody.GetParent(self)` | `CelestialBody` (nullable) | `parentId` |
| `CelestialBody.GetRadius(self)` | `double` | `radius` (m) |
| `CelestialBody.GetSphereOfInfluence(self)` | `double` | `sphereOfInfluence` (m) |
| `CelestialBody.GetGravitationalParameter(self)` | `double` | `μ` (m³/s²) |
| `CelestialBody.GetRotationPeriod(self)` | `double` | `rotationPeriod` (s) |
| `CelestialBody.GetAxialTilt(self)` | `double` | `axialTilt` (rad) |
| `CelestialBody.GetOrbit(self)` | `Orbit` | (then 8 orbit calls) |
### Per Orbit (8 calls per orbit)
| Procedure | Returns | Field |
|---|---|---|
| `Orbit.GetSemiMajorAxis(self)` | `double` | `semiMajorAxis` (m) |
| `Orbit.GetEccentricity(self)` | `double` | `eccentricity` |
| `Orbit.GetInclination(self)` | `double` | `inclination` (rad) |
| `Orbit.GetLongitudeOfAscendingNode(self)` | `double` | `longitudeOfAscendingNode` (rad) |
| `Orbit.GetArgumentOfPeriapsis(self)` | `double` | `argumentOfPeriapsis` (rad) |
| `Orbit.GetMeanAnomalyAtEpoch(self)` | `double` | `meanAnomalyAtEpoch` (rad) |
| `Orbit.GetEpoch(self)` | `double` | `epoch` (s) |
| `Orbit.GetReferenceBody(self)` | `CelestialBody` (nullable) | (for verification only) |
### Per Vessel (5 calls per vessel)
| Procedure | Returns | Field |
|---|---|---|
| `Vessel.GetName(self)` | `string` | `name` |
| `Vessel.GetType(self)` | `VesselType` (enum) | `type` (resolved to name) |
| `Vessel.GetSituation(self)` | `VesselSituation` (enum) | `situation` (raw int code) |
| `Vessel.GetOrbit(self)` | `Orbit` | (then 8 orbit calls) |
| `Vessel.GetReferenceBody(self)` | `CelestialBody` | `referenceBodyId` (resolved to name) |
## What's NOT in scope yet (deferred work)
- **Streams** — we don't subscribe to kRPC streams yet. We're
polling. For a real-time UI, switching to streams (or hybrid
poll+stream) would reduce latency and load. kRPC has `AddStream`
and the stream port is already wired in.
- **Batched calls** — every kRPC call is its own request. We could
batch multiple `ProcedureCall` entries in a single `Request` for
~10x throughput.
- **Ground stations** — kRPC doesn't expose ground stations natively.
The ksp-bridge accepts them as static config or via mod integration.
- **Comm nets / signal strength** — needs the `CommNet` API. kRPC
has it, but we don't use it yet.
- **Crew / science** — not in the ksp-bridge scope right now.
- **Maneuver nodes** — easy to add (`Vessel.GetManeuverNode()` etc.)
but not needed for the live map / mission clock.
## Troubleshooting
### `no kRPC server at 127.0.0.1:50000`
Either KSP isn't running, or the kRPC server isn't enabled. Open the
kRPC window in-game (<kbd>Alt</kbd>+<kbd>F12</kbd>) and make sure
"Start server" is checked.
### `procedure not found in service cache`
The kRPC server returned a procedure that we don't know about. This
usually means the kRPC version is older or newer than we expect
(we target 0.5.x). The ServiceCache will log the procedures it knows
about; cross-check with the in-game kRPC window.
### `wrong number of arguments`
The procedure signature in the cache doesn't match what we're sending.
This can happen if the kRPC version has a different parameter order
or count for a procedure we use. The fix is in
`apps/tools/ksp-bridge/src/extract.ts` — adjust the call site.
+27
View File
@@ -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"
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Test-only encoding helpers for the value encoding tests.
*
* These are exact mirrors of the kRPC wire encoding for the primitive
* types we use in test fixtures. Imported by the integration test that
* drives a mock kRPC server.
*
* DO NOT use these in production code; use `encodeValue` from decoder.ts
* which dispatches based on the KrpcType descriptor.
*/
import { encodeVarint } from './connection.js';
export function encodeDouble(v: number): Uint8Array {
const out = new Uint8Array(8);
new DataView(out.buffer).setFloat64(0, v, true);
return out;
}
export function encodeFloat(v: number): Uint8Array {
const out = new Uint8Array(4);
new DataView(out.buffer).setFloat32(0, v, true);
return out;
}
export function encodeSint32(v: number): Uint8Array {
return encodeVarint(((v << 1) ^ (v >> 31)) >>> 0);
}
export function encodeUint32(v: number): Uint8Array {
return encodeVarint(v);
}
export function encodeUint64(v: bigint): Uint8Array {
const out: number[] = [];
let x = v;
while (x >= 0x80n) {
out.push(Number((x & 0x7fn) | 0x80n));
x >>= 7n;
}
out.push(Number(x));
return new Uint8Array(out);
}
export function encodeString(v: string): Uint8Array {
const utf8 = new TextEncoder().encode(v);
return Buffer.concat([encodeVarint(utf8.length), Buffer.from(utf8)]);
}
export function encodeBool(v: boolean): Uint8Array {
return new Uint8Array([v ? 1 : 0]);
}
+376
View File
@@ -0,0 +1,376 @@
/**
* 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 };
/**
* Format an error value for human consumption. Handles the cases where
* the thrown value is null, undefined, a string, or an Error with
* .code (NodeJS.ErrnoException). Falls back to JSON.stringify for
* unknown shapes.
*/
function formatErr(e: unknown): string {
if (e === null) return 'null';
if (e === undefined) return 'undefined';
if (typeof e === 'string') return e;
if (typeof e === 'object') {
const obj = e as { code?: unknown; message?: unknown; errno?: unknown };
const parts: string[] = [];
if (typeof obj.code === 'string') parts.push(`code=${obj.code}`);
if (typeof obj.errno === 'number') parts.push(`errno=${obj.errno}`);
if (typeof obj.message === 'string') parts.push(obj.message);
if (parts.length > 0) return parts.join(': ');
try {
return JSON.stringify(e);
} catch {
return String(e);
}
}
return String(e);
}
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> {
// Wrap EVERYTHING in a top-level try so we always get a clean
// error message (not a buried TypeError from protobufjs
// nested-enum resolution). The specific sub-step failures are
// caught inline for nicer messages, but this top-level guard
// is the safety net.
try {
return await this._connectImpl();
} catch (e) {
throw new Error(`kRPC connect failed at unknown step: ${formatErr(e)} (stack: ${e instanceof Error ? e.stack : 'n/a'})`);
}
}
private async _connectImpl(): Promise<void> {
// RPC handshake
try {
this.rpcSocket = await tcpConnect(
this.opts.host,
this.opts.rpcPort,
this.opts.connectTimeoutMs,
);
} catch (e) {
throw new Error(
`kRPC RPC TCP connect to ${this.opts.host}:${this.opts.rpcPort} failed: ${formatErr(e)}`,
);
}
// The ConnectionRequest.Type enum has RPC = 0, STREAM = 1. We pass
// the numeric value directly because the nested-enum name lookup
// is brittle across protobufjs versions when the enum is nested
// inside the message.
sendMessage(this.rpcSocket, KRPC.ConnectionRequest, {
type: 0, // RPC
clientName: this.opts.clientName,
});
let resp: { status: number | string; message: string; clientIdentifier: Uint8Array };
try {
const rpcRaw = await recvRawMessage(this.rpcSocket);
if (process.env.KRPC_DEBUG) {
// eslint-disable-next-line no-console
console.log(
'[krpc-client] rpc handshake raw response (' + rpcRaw.length + ' bytes):',
Buffer.from(rpcRaw).toString('hex'),
);
}
resp = decodeMessage<{
status: number | string;
message: string;
clientIdentifier: Uint8Array;
}>(KRPC.ConnectionResponse, rpcRaw);
} catch (e) {
throw new Error(`kRPC RPC handshake (response decode) failed: ${formatErr(e)}`);
}
// 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
try {
this.streamSocket = await tcpConnect(
this.opts.host,
this.opts.streamPort,
this.opts.connectTimeoutMs,
);
} catch (e) {
throw new Error(
`kRPC Stream TCP connect to ${this.opts.host}:${this.opts.streamPort} failed: ${formatErr(e)}`,
);
}
sendMessage(this.streamSocket, KRPC.ConnectionRequest, {
type: 1, // STREAM
clientIdentifier: this.clientIdentifier,
});
let streamResp: { status: number | string; message: string };
try {
const streamRaw = await recvRawMessage(this.streamSocket);
// Diagnostic: log the raw bytes for the stream handshake response
// so we can see what the kRPC server actually sent. Useful when
// debugging "Cannot read properties of null" type errors.
if (process.env.KRPC_DEBUG) {
// eslint-disable-next-line no-console
console.log(
'[krpc-client] stream handshake raw response (' + streamRaw.length + ' bytes):',
Buffer.from(streamRaw).toString('hex'),
);
}
streamResp = decodeMessage<{ status: number | string; message: string }>(
KRPC.ConnectionResponse,
streamRaw,
);
} catch (e) {
throw new Error(`kRPC Stream handshake (response decode) failed: ${formatErr(e)}`);
}
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);
});
} // end _connectImpl
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);
if (process.env.KRPC_DEBUG) {
// eslint-disable-next-line no-console
console.log(
`[krpc-client] ${req.service}.${req.procedure} response (${raw.length} bytes):`,
Buffer.from(raw).toString('hex'),
);
}
// The kRPC schema defines `error` as a sub-message of type Error
// (not raw bytes), so protobufjs decodes it as a nested object
// automatically — service/name/description/stackTrace are already
// populated when the field is set.
const response = decodeMessage<{
error?: { service: string; name: string; description: string; stackTrace: string };
results: {
error?: { service: string; name: string; description: string; stackTrace: string };
value: Uint8Array;
}[];
}>(KRPC.Response, raw);
if (response.error) {
throw new Error(
`RPC error: ${response.error.service}.${response.error.name}: ${response.error.description}` +
(response.error.stackTrace ? `\n${response.error.stackTrace}` : ''),
);
}
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}` +
(r.error.stackTrace ? `\n${r.error.stackTrace}` : ''),
);
}
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?: { service: string; name: string; description: string; stackTrace: 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.service}.${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: {
error?: { service: string; name: string; description: string; stackTrace: string };
value: Uint8Array;
};
}[];
}>(KRPC.StreamUpdate, raw);
for (const r of update.results) {
if (r.result.error) {
// eslint-disable-next-line no-console
console.warn(
`[krpc-client] stream ${r.id} error: ${r.result.error.service}.${r.result.error.name}: ${r.result.error.description}`,
);
for (const h of this.streamHandlers) h(r.id, new Uint8Array());
continue;
}
for (const h of this.streamHandlers) h(r.id, r.result.value);
}
} catch (err) {
if (this.closed) return;
throw err;
}
}
}
}
+182
View File
@@ -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);
});
});
}
+478
View File
@@ -0,0 +1,478 @@
/**
* kRPC value decoder.
*
* kRPC values are encoded on the wire using a hybrid scheme:
*
* - For **primitive types** (DOUBLE, FLOAT, SINT32, SINT64, UINT32, UINT64,
* BOOL, STRING, BYTES) the bytes are exactly the standard protobuf wire
* encoding of that single value, NOT wrapped in a message. So a `double`
* is just 8 little-endian bytes, a `string` is `[varint length][utf8]`,
* and so on.
*
* - For **CLASS types** the bytes are a single varint-encoded `uint64`
* the object id. An object id of 0 means `None` (the CLASS is nullable).
* The actual class data lives server-side; to get a property you call
* `Service.ClassName.GetX(id)`.
*
* - For **ENUMERATION** the bytes are a single signed varint (zigzag-encoded
* sint32 in protobuf terms).
*
* - For **collections** (LIST, SET, TUPLE, DICTIONARY) the bytes are a
* serialized `KRPC.List` / `KRPC.Set` / `KRPC.Tuple` / `KRPC.Dictionary`
* message. Each element is itself encoded using the scheme above (so the
* element bytes are variable-length). A null collection is a single byte
* `\x00` (NOT a length-prefixed empty list).
*
* - For **system messages** (STATUS, SERVICES, STREAM, EVENT,
* PROCEDURE_CALL) the bytes are the standard protobuf serialization of
* the corresponding KRPC message.
*
* Reference: the Python client's `krpc/decoder.py` (Krpc 0.5.x).
*/
import protobuf from 'protobufjs';
import { decodeVarint, encodeVarint } from './connection.js';
import { TypeCode, type KrpcType, typeName } from './types.js';
/** Result of decoding a value. */
export type DecodedValue =
| number
| bigint
| boolean
| string
| Uint8Array
| null
| DecodedValue[]
| Set<DecodedValue>
| Map<DecodedValue, DecodedValue>
| { [k: string]: unknown };
/**
* Decode a value from its wire bytes according to a KrpcType.
*
* @param type The KrpcType descriptor (from GetServices or a
* pre-built cache).
* @param data The raw bytes (response.value for returns, or
* the value field of a KRPC.Argument for args).
* @param messageTypes Optional protobufjs type registry for decoding
* system messages (KRPC.Status, etc.) by name.
* Only needed for MESSAGE-style TypeCodes.
*/
export function decodeValue(
type: KrpcType,
data: Uint8Array,
messageTypes?: Record<string, protobuf.Type>,
): DecodedValue {
switch (type.code) {
case TypeCode.NONE:
return null;
case TypeCode.DOUBLE:
return decodeDouble(data);
case TypeCode.FLOAT:
return decodeFloat(data);
case TypeCode.SINT32:
return decodeSint32(data);
case TypeCode.SINT64:
return decodeSint64(data);
case TypeCode.UINT32:
return decodeUint32(data);
case TypeCode.UINT64:
return decodeUint64(data);
case TypeCode.BOOL:
return decodeBool(data);
case TypeCode.STRING:
return decodeString(data);
case TypeCode.BYTES:
return decodeBytes(data);
case TypeCode.CLASS: {
// The wire form of a CLASS is a uint64 object id; 0 = None.
// We decode as a BigInt so callers can pass the id back as an
// argument to other class methods.
const id = bigFromVarint(data);
return id === 0n ? null : id;
}
case TypeCode.ENUMERATION: {
// Wire form: a single signed varint (sint32 in protobuf terms).
return decodeSint32(data);
}
case TypeCode.STATUS: {
return decodeSystemMessage('Status', data, messageTypes);
}
case TypeCode.SERVICES: {
return decodeSystemMessage('Services', data, messageTypes);
}
case TypeCode.STREAM: {
return decodeSystemMessage('Stream', data, messageTypes);
}
case TypeCode.EVENT: {
return decodeSystemMessage('Event', data, messageTypes);
}
case TypeCode.PROCEDURE_CALL: {
return decodeSystemMessage('ProcedureCall', data, messageTypes);
}
case TypeCode.LIST:
return decodeList(type, data, messageTypes);
case TypeCode.SET:
return decodeSet(type, data, messageTypes);
case TypeCode.TUPLE:
return decodeTuple(type, data, messageTypes);
case TypeCode.DICTIONARY:
return decodeDictionary(type, data, messageTypes);
default:
throw new Error(`unknown TypeCode ${type.code} (${typeName(type)})`);
}
}
// ── primitive decoders ──────────────────────────────────────────────────────
/**
* Read a fixed-width little-endian IEEE 754 number from a buffer.
*
* JavaScript's `DataView.getFloat64()` already does the right thing on
* little-endian platforms, which Node always is. We still go through
* `DataView` so the intent is explicit and the code is portable to
* big-endian platforms (if we ever run on one).
*/
function readFloatLE(buf: Uint8Array, offset: number, bytes: 4 | 8): number {
// Pad short reads with zero bytes. This shouldn't happen in practice,
// but it's defensive against a truncated response.
if (buf.length < offset + bytes) {
const padded = new Uint8Array(bytes);
padded.set(buf.subarray(offset, offset + bytes));
buf = padded;
offset = 0;
}
const view = new DataView(buf.buffer, buf.byteOffset + offset, bytes);
return bytes === 8 ? view.getFloat64(0, true) : view.getFloat32(0, true);
}
export function decodeDouble(data: Uint8Array): number {
return readFloatLE(data, 0, 8);
}
export function decodeFloat(data: Uint8Array): number {
return readFloatLE(data, 0, 4);
}
export function decodeSint32(data: Uint8Array): number {
// Protobuf sint32 is zigzag-encoded. `decodeVarint` returns a regular
// varint, so we need the zigzag step too.
const [raw] = decodeVarint(Buffer.from(data));
return zigzagDecode(Number(raw));
}
export function decodeSint64(data: Uint8Array): number {
// The Python client decodes sint64 to a plain int (BigInt in their
// case is a separate branch). For our use case the values we care
// about (enum cases) are well within int32 range, so we decode to
// a JS number. If you really need full int64, decode to BigInt.
return decodeSint32(data);
}
export function decodeUint32(data: Uint8Array): number {
const [v] = decodeVarint(Buffer.from(data));
return Number(v);
}
export function decodeUint64(data: Uint8Array): bigint {
// Use BigInt for the 64-bit case. The kRPC ObjectId and StreamId are
// uint64s and can exceed Number.MAX_SAFE_INTEGER in principle
// (though kRPC never generates ids that large in practice).
return bigFromVarint(data);
}
export function decodeBool(data: Uint8Array): boolean {
if (data.length < 1) return false;
return data[0] !== 0;
}
export function decodeString(data: Uint8Array): string {
// Wire form: [varint length][utf8 bytes].
const buf = Buffer.from(data);
const [len, pos] = decodeVarint(buf);
return buf.subarray(pos, pos + Number(len)).toString('utf-8');
}
export function decodeBytes(data: Uint8Array): Uint8Array {
const buf = Buffer.from(data);
const [len, pos] = decodeVarint(buf);
return new Uint8Array(buf.subarray(pos, pos + Number(len)));
}
function zigzagDecode(n: number): number {
return (n >>> 1) ^ -(n & 1);
}
/**
* Decode a varint (assumed 64 bits) as a BigInt. Used for uint64 values
* where the precision loss of Number() is unacceptable.
*/
function bigFromVarint(data: Uint8Array): bigint {
let result = 0n;
let shift = 0n;
for (let i = 0; i < data.length; i++) {
const b = data[i];
if (b === undefined) break;
result |= BigInt(b & 0x7f) << shift;
if ((b & 0x80) === 0) return result;
shift += 7n;
}
return result;
}
// ── collection decoders ─────────────────────────────────────────────────────
/**
* Decode a KRPC.List message (when its serialized form is provided).
* Used internally by `decodeList`. Also exported for tests.
*/
export function decodeKrpcList(
data: Uint8Array,
messageType: protobuf.Type,
): Uint8Array[] {
if (data.length === 1 && data[0] === 0) {
// A list may be serialized as a single 0x00 byte to indicate None.
return [];
}
const msg = messageType.decode(data) as unknown as { items: Uint8Array[] };
return msg.items ?? [];
}
export function decodeList(
type: KrpcType,
data: Uint8Array,
messageTypes?: Record<string, protobuf.Type>,
): DecodedValue[] {
if (data.length === 1 && data[0] === 0) return null as unknown as DecodedValue[];
const listType = messageTypes?.['List'];
if (!listType) {
throw new Error('decodeList: no protobufjs type for KRPC.List registered');
}
const items = decodeKrpcList(data, listType);
const elemType = type.types[0];
if (!elemType) throw new Error('LIST type missing element type');
return items.map((it) => decodeValue(elemType, it, messageTypes));
}
export function decodeSet(
type: KrpcType,
data: Uint8Array,
messageTypes?: Record<string, protobuf.Type>,
): Set<DecodedValue> {
if (data.length === 1 && data[0] === 0) return null as unknown as Set<DecodedValue>;
const setType = messageTypes?.['Set'];
if (!setType) {
throw new Error('decodeSet: no protobufjs type for KRPC.Set registered');
}
const msg = setType.decode(data) as unknown as { items: Uint8Array[] };
const elemType = type.types[0];
if (!elemType) throw new Error('SET type missing element type');
return new Set((msg.items ?? []).map((it) => decodeValue(elemType, it, messageTypes)));
}
export function decodeTuple(
type: KrpcType,
data: Uint8Array,
messageTypes?: Record<string, protobuf.Type>,
): DecodedValue[] {
if (data.length === 1 && data[0] === 0) return null as unknown as DecodedValue[];
const tupleType = messageTypes?.['Tuple'];
if (!tupleType) {
throw new Error('decodeTuple: no protobufjs type for KRPC.Tuple registered');
}
const msg = tupleType.decode(data) as unknown as { items: Uint8Array[] };
return type.types.map((inner, i) =>
decodeValue(inner, msg.items[i] ?? new Uint8Array(0), messageTypes),
);
}
export function decodeDictionary(
type: KrpcType,
data: Uint8Array,
messageTypes?: Record<string, protobuf.Type>,
): Map<DecodedValue, DecodedValue> {
if (data.length === 1 && data[0] === 0) {
return null as unknown as Map<DecodedValue, DecodedValue>;
}
const dictType = messageTypes?.['Dictionary'];
if (!dictType) {
throw new Error('decodeDictionary: no protobufjs type for KRPC.Dictionary registered');
}
const msg = dictType.decode(data) as unknown as {
entries: { key: Uint8Array; value: Uint8Array }[];
};
const keyType = type.types[0];
const valType = type.types[1];
if (!keyType || !valType) throw new Error('DICTIONARY type missing key/value types');
const out = new Map<DecodedValue, DecodedValue>();
for (const e of msg.entries ?? []) {
out.set(
decodeValue(keyType, e.key, messageTypes),
decodeValue(valType, e.value, messageTypes),
);
}
return out;
}
function decodeSystemMessage(
name: string,
data: Uint8Array,
messageTypes?: Record<string, protobuf.Type>,
): { [k: string]: unknown } {
const t = messageTypes?.[name];
if (!t) throw new Error(`decodeSystemMessage: no type registered for ${name}`);
return t.decode(data) as unknown as { [k: string]: unknown };
}
// ── encoders (mirrors, for sending arguments) ───────────────────────────────
/**
* Encode a value into the kRPC wire format for `type`.
*
* This is the inverse of `decodeValue`. Used by the service client to
* serialize procedure arguments. Collections and system messages use the
* protobufjs registry the same way the decoder does.
*/
export function encodeValue(
type: KrpcType,
value: DecodedValue,
messageTypes?: Record<string, protobuf.Type>,
): Uint8Array {
switch (type.code) {
case TypeCode.NONE:
return new Uint8Array(0);
case TypeCode.DOUBLE:
return encodeDouble(value as number);
case TypeCode.FLOAT:
return encodeFloat(value as number);
case TypeCode.SINT32:
return encodeSint32(value as number);
case TypeCode.SINT64:
return encodeSint32(value as number);
case TypeCode.UINT32:
return encodeUint32(value as number);
case TypeCode.UINT64:
return encodeUint64(value as bigint);
case TypeCode.BOOL:
return encodeBool(value as boolean);
case TypeCode.STRING:
return encodeString(value as string);
case TypeCode.BYTES:
return encodeBytes(value as Uint8Array);
case TypeCode.CLASS: {
// CLASS args are encoded as uint64 object id; null = 0.
if (value === null || value === undefined) return encodeUint64(0n);
if (typeof value === 'bigint') return encodeUint64(value);
if (typeof value === 'number') return encodeUint64(BigInt(value));
throw new Error(`encodeValue: CLASS expects bigint object id, got ${typeof value}`);
}
case TypeCode.ENUMERATION:
return encodeSint32(value as number);
case TypeCode.LIST: {
const listType = messageTypes?.['List'];
if (!listType) throw new Error('encodeValue: no type for KRPC.List');
const elemType = type.types[0];
if (!elemType) throw new Error('LIST type missing element type');
const items = (value as DecodedValue[]).map((v) => encodeValue(elemType, v, messageTypes));
const msg = listType.create({ items });
return listType.encode(msg).finish();
}
case TypeCode.TUPLE: {
const tupleType = messageTypes?.['Tuple'];
if (!tupleType) throw new Error('encodeValue: no type for KRPC.Tuple');
const items = (value as DecodedValue[]).map((v, i) =>
encodeValue(type.types[i] as KrpcType, v, messageTypes),
);
const msg = tupleType.create({ items });
return tupleType.encode(msg).finish();
}
case TypeCode.DICTIONARY: {
const dictType = messageTypes?.['Dictionary'];
if (!dictType) throw new Error('encodeValue: no type for KRPC.Dictionary');
const keyType = type.types[0];
const valType = type.types[1];
if (!keyType || !valType) throw new Error('DICTIONARY type missing key/value types');
const entries: { key: Uint8Array; value: Uint8Array }[] = [];
for (const [k, v] of (value as Map<DecodedValue, DecodedValue>).entries()) {
entries.push({
key: encodeValue(keyType, k, messageTypes),
value: encodeValue(valType, v, messageTypes),
});
}
const msg = dictType.create({ entries });
return dictType.encode(msg).finish();
}
case TypeCode.SET: {
// kRPC 0.5 doesn't really use SET much; if needed we'd encode as
// KRPC.Set. For now, only LIST/TUPLE/DICT are exercised by our
// SpaceCenter calls.
throw new Error('encodeValue: SET not implemented (kRPC 0.5 does not use it)');
}
case TypeCode.STATUS:
case TypeCode.SERVICES:
case TypeCode.STREAM:
case TypeCode.EVENT:
case TypeCode.PROCEDURE_CALL:
throw new Error(`encodeValue: system message ${typeName(type)} cannot be sent as argument`);
default:
throw new Error(`encodeValue: unknown TypeCode ${type.code}`);
}
}
function encodeDouble(v: number): Uint8Array {
const out = new Uint8Array(8);
new DataView(out.buffer).setFloat64(0, v, true);
return out;
}
function encodeFloat(v: number): Uint8Array {
const out = new Uint8Array(4);
new DataView(out.buffer).setFloat32(0, v, true);
return out;
}
function encodeSint32(v: number): Uint8Array {
return encodeVarint(zigzagEncode(v));
}
function encodeUint32(v: number): Uint8Array {
return encodeVarint(v);
}
function encodeUint64(v: bigint): Uint8Array {
// Manual varint encoding for BigInt to avoid Number truncation.
const out: number[] = [];
let x = v;
while (x >= 0x80n) {
out.push(Number((x & 0x7fn) | 0x80n));
x >>= 7n;
}
out.push(Number(x));
return new Uint8Array(out);
}
function encodeBool(v: boolean): Uint8Array {
return new Uint8Array([v ? 1 : 0]);
}
function encodeString(v: string): Uint8Array {
const utf8 = new TextEncoder().encode(v);
return Buffer.concat([encodeVarint(utf8.length), Buffer.from(utf8)]);
}
function encodeBytes(v: Uint8Array): Uint8Array {
return Buffer.concat([encodeVarint(v.length), Buffer.from(v)]);
}
function zigzagEncode(n: number): number {
return (n << 1) ^ (n >> 31);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* @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
* - KrpcType / TypeCode: runtime representation of kRPC type descriptors
* - decodeValue / encodeValue: kRPC value codec (primitives, classes,
* enums, collections, system messages)
* - ServiceCache: a Type index built from KRPC.GetServices()
* - KrpcServices: high-level invoke-by-name client
*
* For the service-specific types (SpaceCenter.Vessel, Orbit, etc.) we do
* NOT need to load the kRPC mod's .proto files. The server's
* GetServices() response contains everything we need to encode/decode
* values. See ./service-client.ts for the details.
*/
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, MESSAGE_TYPES } from './schema.js';
export {
TypeCode,
decodeKrpcType,
typeName,
type KrpcType,
type TypeCodeValue,
type RawKrpcTypeMessage,
} from './types.js';
export {
decodeValue,
encodeValue,
decodeDouble,
decodeFloat,
decodeSint32,
decodeUint32,
decodeUint64,
decodeBool,
decodeString,
decodeBytes,
type DecodedValue,
} from './decoder.js';
export { ServiceCache } from './services.js';
export { KrpcServices, loadServices, type KrpcInvokeError } from './service-client.js';
+383
View File
@@ -0,0 +1,383 @@
/**
* 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: {
// NOTE: status is wire-varint-enum-OK=0, but we model it
// as a plain uint32 to avoid protobufjs nested-enum
// resolution bugs that throw "Cannot read properties of
// null (reading 'code')" when the field is omitted from
// the wire (which the kRPC server does for the happy
// path). Our code already does `resp.status !== 0` /
// `!== 'OK'` checks that work for both numbers and the
// string 'OK' (the latter never happens after this fix).
fields: {
status: { type: 'uint32', 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: {
// Same nested-enum-as-field-type issue as
// ConnectionResponse.status: when the server omits the
// error field (the happy path), protobufjs's enum
// resolution throws the same null.code TypeError. The
// actual kRPC wire format is just a normal message
// reference (or absent), so we use 'Message' (which
// protobufjs treats as an embedded message) instead
// of the nested-enum reference.
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: {
// The kRPC server sends `game_scenes` (a repeated
// GameScene enum, field 6) on every Procedure. The
// GameScene enum is nested inside Procedure. We model
// it as a repeated uint32 to dodge the protobufjs
// nested-enum default-value bug, same as Type.code and
// ConnectionResponse.status. We don't actually use this
// field on the client side; it's just here so the
// decoder doesn't choke on the wire bytes.
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 },
gameScenes: { rule: 'repeated', type: 'uint32', id: 6 },
},
nested: {
GameScene: {
values: {
SPACE_CENTER: 0,
FLIGHT: 1,
TRACKING_STATION: 2,
EDITOR_VAB: 3,
EDITOR_SPH: 4,
MISSION_BUILDER: 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: {
// The `code` field on Type is a wire-varint enum
// (TypeCode = uint32 under the hood). We model it as a
// plain uint32 to dodge the protobufjs nested-enum
// default-value lookup bug that throws
// "Cannot read properties of null (reading 'code')"
// when decoding a Type message whose code field is
// present. Same fix as ConnectionResponse.status.
// The values stay as a nested TypeCode enum for
// documentation / programmatic lookup (in services.ts).
fields: {
code: { type: 'uint32', 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 } },
},
Expression: {
fields: {
typ: { type: 'Type', id: 1 },
code: { type: 'string', id: 2 },
},
},
},
},
},
},
},
};
const root = protobuf.Root.fromJSON(schemaJson as protobuf.INamespace);
const ns = root.lookup('krpc.schema') as protobuf.Namespace;
const lookupType = (name: string): protobuf.Type =>
ns.lookupType(name) as protobuf.Type;
/**
* All kRPC meta-protocol types, by their protobufjs Type objects.
*
* These are used by the decoder/encoder for system messages (Status,
* Services, Stream, Event, ProcedureCall) and collection types
* (List, Set, Tuple, Dictionary).
*
* The same Type objects are also exposed as `MESSAGE_TYPES` so the
* service client can register them with the decoder in one call.
*
* GOTCHA: protobufjs's nested-enum string-to-number lookup is brittle
* when the enum shares its name with a built-in JavaScript type or
* with a parent property. In particular, sending
* encodeMessage(ConnectionRequest, { type: 'STREAM' })
* silently encodes as 0 (RPC) instead of 1. To avoid this, encode
* enum values by their numeric code rather than the string name.
*/
export const KRPC = {
ConnectionRequest: lookupType('ConnectionRequest'),
ConnectionResponse: lookupType('ConnectionResponse'),
Request: lookupType('Request'),
Response: lookupType('Response'),
ProcedureCall: lookupType('ProcedureCall'),
Argument: lookupType('Argument'),
ProcedureResult: lookupType('ProcedureResult'),
Error: lookupType('Error'),
StreamUpdate: lookupType('StreamUpdate'),
StreamResult: lookupType('StreamResult'),
Stream: lookupType('Stream'),
Status: lookupType('Status'),
Services: lookupType('Services'),
Service: lookupType('Service'),
Type: lookupType('Type'),
List: lookupType('List'),
Set: lookupType('Set'),
Tuple: lookupType('Tuple'),
Dictionary: lookupType('Dictionary'),
DictionaryEntry: lookupType('DictionaryEntry'),
Event: lookupType('Event'),
Expression: lookupType('Expression'),
} as const;
/**
* A name protobufjs Type registry for system messages and collection
* types. The decoder/encoder accepts this as the `messageTypes` argument
* so it knows how to (de)serialize these specific messages.
*/
export const MESSAGE_TYPES: Record<string, protobuf.Type> = {
List: KRPC.List,
Set: KRPC.Set,
Tuple: KRPC.Tuple,
Dictionary: KRPC.Dictionary,
Status: KRPC.Status,
Services: KRPC.Services,
Stream: KRPC.Stream,
Event: KRPC.Event,
ProcedureCall: KRPC.ProcedureCall,
};
// Silence "type not used" — we keep the root reference for diagnostics
// and to allow callers to load additional .proto files later if needed.
void root;
/** 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;
}
+196
View File
@@ -0,0 +1,196 @@
/**
* KrpcServices high-level service client.
*
* Wraps KRPCClient + ServiceCache to provide a clean invoke-by-name API:
*
* const sc = new KrpcServices(client, cache);
* const ut = await sc.invoke<number>('SpaceCenter', 'GetUT');
* const bodyIds = await sc.invoke<bigint[]>('SpaceCenter', 'GetBodies');
* const name = await sc.invoke<string>('SpaceCenter', 'CelestialBody.GetName', bodyId);
*
* The client looks up the procedure in the cache, encodes each argument
* using the procedure's parameter types, calls the procedure via the
* low-level client, then decodes the response using the return type.
*
* For class-returning procedures, the response is decoded as the object
* id (a BigInt), or `null` if the server returned id=0. The caller can
* then pass that BigInt to other class methods.
*
* This design means we don't need the kRPC mod's .proto files at all
* the kRPC server provides the type information via GetServices(), and
* the kRPC value encoding is what our decoder handles.
*/
import type { KRPCClient, ProcedureCallRequest } from './client.js';
import { decodeValue, encodeValue, type DecodedValue } from './decoder.js';
import { MESSAGE_TYPES, KRPC, decodeMessage } from './schema.js';
import type { KrpcType } from './types.js';
import { ServiceCache, type RawServicesMessage } from './services.js';
export interface KrpcInvokeError extends Error {
service: string;
name: string;
description: string;
stackTrace?: string;
}
/** Build a KrpcInvokeError with extra metadata attached. */
function makeInvokeError(
service: string,
name: string,
description: string,
stackTrace?: string,
): KrpcInvokeError {
const e = new Error(`${service}.${name}: ${description}`) as KrpcInvokeError;
e.service = service;
e.name = name;
e.description = description;
if (stackTrace) e.stackTrace = stackTrace;
return e;
}
export class KrpcServices {
constructor(
private readonly client: KRPCClient,
private readonly cache: ServiceCache,
) {}
/**
* Invoke a procedure and return the decoded value.
*
* @param service e.g. "SpaceCenter"
* @param procedure e.g. "GetUT" or "CelestialBody.GetName" for class methods
* @param args The procedure arguments. Each is encoded according to the
* procedure's parameter types (looked up from the cache).
* Object ids for CLASS parameters are BigInts.
*/
async invoke<T extends DecodedValue = DecodedValue>(
service: string,
procedure: string,
...args: DecodedValue[]
): Promise<T> {
const lookup = this.cache.lookup(service, procedure);
if (!lookup.found) {
throw makeInvokeError(
service,
procedure,
`procedure not found in service cache (known services: ${this.cache.serviceNames().join(', ')})`,
);
}
const info = lookup.info;
if (args.length !== info.parameters.length) {
throw makeInvokeError(
service,
procedure,
`wrong number of arguments: expected ${info.parameters.length}, got ${args.length}`,
);
}
const encodedArgs = info.parameters.map((p, i) => {
const v = args[i];
// For nullable CLASS parameters, accept `null`/`undefined` and encode as id=0.
if (p.nullable && (v === null || v === undefined)) {
return new Uint8Array(0);
}
return encodeValue(p.type, v, MESSAGE_TYPES);
});
// Low-level invoke needs Uint8Array values for each argument.
const req: ProcedureCallRequest = {
service: info.service,
procedure: info.name,
args: encodedArgs,
};
let rawValue: Uint8Array;
try {
rawValue = await this.client.invoke(req);
} catch (err) {
// The low-level client throws with a generic message; we wrap it
// with the service.procedure prefix so the caller knows which call
// failed. The original error is on the `cause` chain in newer
// Node, but to keep things simple we re-throw a tagged error.
const e = err as Error;
throw makeInvokeError(service, procedure, e.message, e.stack);
}
if (rawValue.length === 0) {
// Zero-length response. Only valid for:
// - procedures with no return value (KrpcType NONE)
// - nullable CLASS with id=0 — but that is 1 byte (0x00), not 0
// So we return null if the return type is NONE or nullable,
// otherwise throw.
if (info.returnType.code === 0 /* NONE */) {
return null as unknown as T;
}
if (info.returnIsNullable) {
return null as unknown as T;
}
throw makeInvokeError(
service,
procedure,
'zero-length response for non-nullable, non-NONE return type',
);
}
const decoded = decodeValue(info.returnType, rawValue, MESSAGE_TYPES);
if (decoded === null && !info.returnIsNullable) {
// Some primitives (e.g. uint64 = 0) might decode to falsy values
// that we don't want to confuse with null. But for CLASS/ENUM this
// is a real "no value" — and only valid for nullable returns.
throw makeInvokeError(
service,
procedure,
'decoded null for non-nullable return type',
);
}
return decoded as T;
}
/**
* Read a class property by calling its getter procedure. Equivalent to
* invoke(service, "ClassName.GetPropName", objectId)
* but reads more naturally at the call site.
*/
async getClassProperty<T extends DecodedValue = DecodedValue>(
service: string,
className: string,
propertyName: string,
objectId: bigint | null,
): Promise<T> {
return this.invoke<T>(service, `${className}.${propertyName}`, objectId as DecodedValue);
}
/**
* Underlying service cache, exposed for callers that want to do
* procedural introspection (e.g. debug tools that list available
* services or resolve enum values).
*/
getCache(): ServiceCache {
return this.cache;
}
}
/**
* Build a ServiceCache from a fresh KRPCClient. Convenience for the
* common "connect, get services, return ready client" pattern.
*
* The return value of `KRPC.GetServices()` is a serialized
* `KRPC.Services` message. We decode it using our protobufjs type
* definition, then convert the result to a `ServiceCache`.
*/
export async function loadServices(client: KRPCClient): Promise<{
cache: ServiceCache;
services: KrpcServices;
}> {
const rawValue = await client.invoke({
service: 'KRPC',
procedure: 'GetServices',
});
// KRPC.Services is a system message — decode it using the
// registered protobufjs type.
const decoded = decodeMessage<RawServicesMessage>(KRPC.Services, rawValue);
// protobufjs decodes `services` field as a repeated Service; each
// Service has nested messages for Class, Enumeration, etc. that
// protobufjs also decodes. The shape matches our RawServicesMessage
// contract — but TypeScript doesn't know that, so we cast.
const cache = new ServiceCache(decoded);
return { cache, services: new KrpcServices(client, cache) };
}
/** Re-export for consumers that want to import KrpcType. */
export type { KrpcType };
+248
View File
@@ -0,0 +1,248 @@
/**
* ServiceCache a queryable index over a KRPC.GetServices() response.
*
* After connecting to kRPC, the canonical first call is `KRPC.GetServices()`,
* which returns a `KRPC.Services` message describing every service, every
* class, every enum, and every procedure. We decode that into a lookup
* table keyed by (service, procedure) so the service client can ask:
*
* - "what is the return type of SpaceCenter.GetBodies?"
* - "what are the param types of SpaceCenter.CelestialBody.GetName?"
* - "is SpaceCenter.VesselType.Ship == 0?"
*
* The cache is built once after connect, then read-only. It is decoupled
* from the network so it can be unit-tested by feeding in a hand-crafted
* Services message.
*/
import {
decodeKrpcType,
type KrpcType,
type RawKrpcTypeMessage,
} from './types.js';
/** Shape of the KRPC.GetServices() response after protobufjs decoding. */
export interface RawServicesMessage {
services: RawServiceMessage[];
}
export interface RawServiceMessage {
name: string;
procedures: RawProcedureMessage[];
classes: { name: string }[];
enumerations: RawEnumerationMessage[];
}
export interface RawProcedureMessage {
name: string;
parameters: RawParameterMessage[];
returnType: RawKrpcTypeMessage;
returnIsNullable: boolean;
}
export interface RawParameterMessage {
name: string;
type: RawKrpcTypeMessage;
nullable: boolean;
}
export interface RawEnumerationMessage {
name: string;
values: { name: string; value: number }[];
}
export interface ProcedureInfo {
service: string;
name: string;
/** Full procedure name with class prefix, e.g. `CelestialBody.GetName`. */
fullName: string;
returnType: KrpcType;
returnIsNullable: boolean;
parameters: { name: string; type: KrpcType; nullable: boolean }[];
}
/**
* Result of a name lookup. Either we found the proc and we know its
* signature, or we didn't and the caller can decide what to do.
*/
export type ProcedureLookup =
| { found: true; info: ProcedureInfo }
| { found: false };
/**
* Generate the .NET-style variants of a PascalCase procedure name.
* For a top-level procedure like "GetUT" -> ["get_UT"].
* For a class-prefixed one like "CelestialBody.GetName" ->
* ["CelestialBody.get_Name", "get_CelestialBody.Name"].
* (We try the most likely variant first; the second is an extra
* fallback in case the kRPC server ever uses a flat "get_X.Y" form,
* which historical versions have done for some properties.)
*/
function netNameVariants(procedure: string): string[] {
const variants: string[] = [];
const lastDot = procedure.lastIndexOf('.');
if (lastDot < 0) {
// Top-level: "GetUT" -> "get_UT"
if (procedure.startsWith('Get') && procedure.length > 3) {
variants.push(`get_${procedure.slice(3)}`);
} else if (procedure.startsWith('Set') && procedure.length > 3) {
variants.push(`set_${procedure.slice(3)}`);
}
} else {
// Class-prefixed: "CelestialBody.GetName" -> "CelestialBody.get_Name"
const prefix = procedure.slice(0, lastDot);
const method = procedure.slice(lastDot + 1);
if (method.startsWith('Get') && method.length > 3) {
variants.push(`${prefix}.get_${method.slice(3)}`);
} else if (method.startsWith('Set') && method.length > 3) {
variants.push(`${prefix}.set_${method.slice(3)}`);
}
}
return variants;
}
export class ServiceCache {
/** "SpaceCenter.CelestialBody.GetName" -> ProcedureInfo */
private byFullName = new Map<string, ProcedureInfo>();
/** "SpaceCenter" -> "SpaceCenter" (just the service name) */
private services = new Set<string>();
/** "SpaceCenter.VesselType" -> "Ship" (enum name) -> int value */
private enumValues = new Map<string, Map<string, number>>();
/** "SpaceCenter" -> "VesselType" (enum name) -> Map<name, value> */
private enumsByService = new Map<string, Map<string, Map<string, number>>>();
constructor(raw: RawServicesMessage) {
for (const svc of raw.services ?? []) {
this.services.add(svc.name);
for (const proc of svc.procedures ?? []) {
// proc.name already includes the class prefix when applicable
// (e.g. "CelestialBody.GetName"), per the kRPC wire format.
const info: ProcedureInfo = {
service: svc.name,
name: proc.name,
fullName: `${svc.name}.${proc.name}`,
returnType: decodeKrpcType(proc.returnType),
returnIsNullable: !!proc.returnIsNullable,
parameters: (proc.parameters ?? []).map((p) => ({
name: p.name,
type: decodeKrpcType(p.type),
nullable: !!p.nullable,
})),
};
this.byFullName.set(info.fullName, info);
}
for (const e of svc.enumerations ?? []) {
const m = new Map<string, number>();
for (const v of e.values ?? []) {
m.set(v.name, v.value);
}
const fq = `${svc.name}.${e.name}`;
this.enumValues.set(fq, m);
let inner = this.enumsByService.get(svc.name);
if (!inner) {
inner = new Map();
this.enumsByService.set(svc.name, inner);
}
inner.set(e.name, m);
}
}
}
/** All known service names, e.g. ["KRPC", "SpaceCenter", "KerbalAlarmClock", ...]. */
serviceNames(): string[] {
return [...this.services].sort();
}
/**
* All procedure full names in a service, e.g.
* ["SpaceCenter.GetUT", "SpaceCenter.CelestialBody.GetName", ...].
*/
proceduresInService(service: string): string[] {
const out: string[] = [];
for (const info of this.byFullName.values()) {
if (info.service === service) out.push(info.fullName);
}
return out.sort();
}
/**
* Look up a procedure by `service.procedure` (e.g. "SpaceCenter.GetUT") or
* by the class-prefixed form ("SpaceCenter.CelestialBody.GetName").
*
* The kRPC server exposes C# properties using .NET naming conventions:
* a property `UT` on the SpaceCenter service becomes two procedures,
* `get_UT` and `set_UT`. Class properties like `CelestialBody.Name`
* become `CelestialBody.get_Name` and `CelestialBody.set_Name`.
* We accept the more familiar PascalCase form as a fallback, both
* for top-level procedures (GetUT -> get_UT) and class-prefixed
* ones (CelestialBody.GetName -> CelestialBody.get_Name).
*/
lookup(service: string, procedure: string): ProcedureLookup {
const direct = this.byFullName.get(`${service}.${procedure}`);
if (direct) return { found: true, info: direct };
// PascalCase -> .NET-style fallback. We try both the simple form
// (GetUT -> get_UT) and the class-prefixed form
// (CelestialBody.GetName -> CelestialBody.get_Name) so user
// code can use either convention.
for (const variant of netNameVariants(procedure)) {
const hit = this.byFullName.get(`${service}.${variant}`);
if (hit) return { found: true, info: hit };
}
return { found: false };
}
/**
* Resolve an enum value name to its int code. e.g.
* getEnumValue("SpaceCenter", "VesselType", "Ship") -> 0
* Throws if the enum or value is unknown.
*/
getEnumValue(service: string, enumName: string, valueName: string): number {
const m = this.enumValues.get(`${service}.${enumName}`);
if (!m) {
throw new Error(`unknown enum ${service}.${enumName}`);
}
const v = m.get(valueName);
if (v === undefined) {
throw new Error(`unknown value ${valueName} for ${service}.${enumName}`);
}
return v;
}
/**
* Inverse: resolve an int code to a value name. Returns null if the
* code is not in the enum's range kRPC may add new values in newer
* versions, so callers should be defensive.
*/
getEnumName(
service: string,
enumName: string,
valueCode: number,
): string | null {
const m = this.enumValues.get(`${service}.${enumName}`);
if (!m) return null;
for (const [n, v] of m.entries()) {
if (v === valueCode) return n;
}
return null;
}
/**
* All enum value names for an enum, e.g.
* getEnumNames("SpaceCenter", "VesselType")
* -> ["Ship", "Station", "Lander", "Probe", ...]
*/
getEnumNames(service: string, enumName: string): string[] {
const m = this.enumValues.get(`${service}.${enumName}`);
if (!m) return [];
return [...m.keys()].sort();
}
/**
* Count the number of distinct procedures across all services.
* Useful for tests and diagnostics.
*/
procedureCount(): number {
return this.byFullName.size;
}
}
+162
View File
@@ -0,0 +1,162 @@
/**
* kRPC Type runtime representation.
*
* kRPC values are encoded on the wire in a custom way that sits on top of
* the standard protobuf encoding. Every procedure return / argument carries
* a `KrpcType` descriptor that tells the client how to encode/decode the
* bytes. The descriptor is itself a protobuf message (KRPC.Type) and is
* exchanged via `KRPC.GetServices()` at connect time.
*
* See: https://krpc.github.io/krpc/communication-protocols/messages.html
* and the Python client's `krpc.types` / `krpc.decoder` modules for the
* canonical reference implementation.
*
* TypeCode numeric values match the protobuf enum in krpc.proto:
* 0 NONE, 1 DOUBLE, 2 FLOAT, 3 SINT32, 4 SINT64, 5 UINT32, 6 UINT64,
* 7 BOOL, 8 STRING, 9 BYTES,
* 100 CLASS, 101 ENUMERATION,
* 200 EVENT, 201 PROCEDURE_CALL, 202 STREAM, 203 STATUS, 204 SERVICES,
* 300 TUPLE, 301 LIST, 302 SET, 303 DICTIONARY.
*/
export const TypeCode = {
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,
} as const;
export type TypeCodeValue = (typeof TypeCode)[keyof typeof TypeCode];
/**
* Decoded form of a kRPC Type message. We don't try to keep the
* protobufjs wrapper the few fields we care about (code, service, name,
* types) are copied into this plain object for ergonomic access.
*/
export interface KrpcType {
code: TypeCodeValue;
/** For CLASS / ENUMERATION: the service that defines the type. */
service: string;
/** For CLASS / ENUMERATION: the class/enum name. */
name: string;
/**
* Nested types. Used by collections (LIST<T>, SET<T>, TUPLE<A,B>, DICT<K,V>).
* The semantics depend on the code:
* LIST / SET: types[0] is the element type
* TUPLE: types[i] is the i-th element type
* DICTIONARY: types[0] is the key type, types[1] is the value type
* For CLASS / ENUMERATION: empty.
*/
types: KrpcType[];
}
/**
* Decode a kRPC Type protobuf message into our plain KrpcType shape.
* Exposed so callers (e.g. the service cache) can transform the
* GetServices() response into a useful index.
*/
export interface RawKrpcTypeMessage {
code: number;
service: string;
name: string;
types: RawKrpcTypeMessage[];
}
/**
* Decode a kRPC Type protobuf message into our plain KrpcType shape.
*
* Returns a NONE-type KrpcType if `raw` is null/undefined or doesn't
* have a `code` field which happens for procedures with no return
* value (the kRPC server omits the `return_type` field). We treat that
* as the NONE type code (0) rather than throwing.
*/
export function decodeKrpcType(raw: RawKrpcTypeMessage | null | undefined): KrpcType {
if (!raw || typeof raw.code !== 'number') {
return {
code: 0 as TypeCodeValue, // NONE
service: '',
name: '',
types: [],
};
}
return {
code: raw.code as TypeCodeValue,
service: raw.service ?? '',
name: raw.name ?? '',
types: (raw.types ?? []).map(decodeKrpcType),
};
}
/**
* Human-readable name for a typecode. Used in error messages and for
* debugging. Not used in wire encoding.
*/
export function typeName(t: KrpcType): string {
switch (t.code) {
case TypeCode.NONE:
return 'None';
case TypeCode.DOUBLE:
return 'double';
case TypeCode.FLOAT:
return 'float';
case TypeCode.SINT32:
return 'sint32';
case TypeCode.SINT64:
return 'sint64';
case TypeCode.UINT32:
return 'uint32';
case TypeCode.UINT64:
return 'uint64';
case TypeCode.BOOL:
return 'bool';
case TypeCode.STRING:
return 'string';
case TypeCode.BYTES:
return 'bytes';
case TypeCode.CLASS:
return `${t.service}.${t.name}`;
case TypeCode.ENUMERATION:
return `${t.service}.${t.name}`;
case TypeCode.EVENT:
return 'Event';
case TypeCode.PROCEDURE_CALL:
return 'ProcedureCall';
case TypeCode.STREAM:
return 'Stream';
case TypeCode.STATUS:
return 'Status';
case TypeCode.SERVICES:
return 'Services';
case TypeCode.TUPLE: {
const inner = t.types.map(typeName).join(', ');
return `(${inner})`;
}
case TypeCode.LIST:
return `list<${t.types[0] ? typeName(t.types[0]) : '?'}>`;
case TypeCode.SET:
return `set<${t.types[0] ? typeName(t.types[0]) : '?'}>`;
case TypeCode.DICTIONARY: {
const k = t.types[0] ? typeName(t.types[0]) : '?';
const v = t.types[1] ? typeName(t.types[1]) : '?';
return `dict<${k}, ${v}>`;
}
default:
return `code=${t.code}`;
}
}
@@ -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);
});
});
+346
View File
@@ -0,0 +1,346 @@
import { describe, it, expect } from 'vitest';
import protobuf from 'protobufjs';
import {
decodeValue,
encodeValue,
decodeDouble,
decodeFloat,
decodeSint32,
decodeUint32,
decodeUint64,
decodeBool,
decodeString,
decodeBytes,
decodeList,
decodeTuple,
decodeDictionary,
decodeKrpcList,
} from '../src/decoder.js';
import { TypeCode, type KrpcType } from '../src/types.js';
import { MESSAGE_TYPES, KRPC } from '../src/schema.js';
const T = (t: Partial<KrpcType>): KrpcType => ({
code: t.code ?? 0,
service: t.service ?? '',
name: t.name ?? '',
types: t.types ?? [],
});
// We need a small fake "TYPE" registry for the tests so the decoder
// can find List / Tuple / Dictionary / Status by name. We can use the
// real MESSAGE_TYPES for the actual system messages.
const REG = MESSAGE_TYPES;
describe('primitive decoders', () => {
it('decodes a double (8-byte little-endian)', () => {
expect(decodeDouble(new Uint8Array(new Float64Array([3.14]).buffer))).toBeCloseTo(3.14, 10);
expect(decodeDouble(new Uint8Array(new Float64Array([-0]).buffer))).toBe(-0);
expect(decodeDouble(new Uint8Array(new Float64Array([0]).buffer))).toBe(0);
expect(decodeDouble(new Uint8Array(new Float64Array([1e30]).buffer))).toBe(1e30);
});
it('decodes a float (4-byte little-endian)', () => {
expect(decodeFloat(new Uint8Array(new Float32Array([2.5]).buffer))).toBeCloseTo(2.5, 5);
});
it('decodes a sint32 (zigzag varint)', () => {
// 0 -> 0, 1 -> 2, -1 -> 1, 2 -> 4, -2 -> 3
expect(decodeSint32(new Uint8Array([0]))).toBe(0);
expect(decodeSint32(new Uint8Array([2]))).toBe(1);
expect(decodeSint32(new Uint8Array([1]))).toBe(-1);
expect(decodeSint32(new Uint8Array([4]))).toBe(2);
expect(decodeSint32(new Uint8Array([3]))).toBe(-2);
});
it('decodes a uint32 (varint)', () => {
expect(decodeUint32(new Uint8Array([0]))).toBe(0);
expect(decodeUint32(new Uint8Array([0x7f]))).toBe(127);
expect(decodeUint32(new Uint8Array([0x80, 0x01]))).toBe(128);
});
it('decodes a uint64 to BigInt (preserves > 2^53)', () => {
// 2^53 + 1 exceeds Number.MAX_SAFE_INTEGER. Verify the decoder
// produces the exact BigInt. Hand-varint for 2^53+1:
// 7-bit groups, LSB first: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x10
// (bit 53 = position 4 in the last group = 0b0010000 = 0x10)
const big = 0x20_0000_0000_0001n; // 2^53 + 1
const bytes = new Uint8Array([0x81, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10]);
expect(decodeUint64(bytes)).toBe(big);
});
it('decodes a bool', () => {
expect(decodeBool(new Uint8Array([0]))).toBe(false);
expect(decodeBool(new Uint8Array([1]))).toBe(true);
expect(decodeBool(new Uint8Array([0xff]))).toBe(true);
});
it('decodes a string', () => {
const utf8 = new TextEncoder().encode('Kerbin');
const buf = new Uint8Array([utf8.length, ...utf8]);
expect(decodeString(buf)).toBe('Kerbin');
});
it('decodes a string with non-ASCII characters', () => {
const utf8 = new TextEncoder().encode('日本語');
const buf = new Uint8Array([utf8.length, ...utf8]);
expect(decodeString(buf)).toBe('日本語');
});
it('decodes bytes', () => {
const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const buf = new Uint8Array([payload.length, ...payload]);
expect(decodeBytes(buf)).toEqual(payload);
});
});
describe('decodeValue dispatcher', () => {
it('decodes DOUBLE', () => {
const bytes = new Uint8Array(new Float64Array([2.71828]).buffer);
const out = decodeValue(T({ code: TypeCode.DOUBLE }), bytes);
expect(out).toBeCloseTo(2.71828, 5);
});
it('decodes STRING', () => {
const utf8 = new TextEncoder().encode('Mun');
const bytes = new Uint8Array([utf8.length, ...utf8]);
expect(decodeValue(T({ code: TypeCode.STRING }), bytes)).toBe('Mun');
});
it('decodes CLASS object id (non-null)', () => {
// 42 as varint
const id = decodeValue(T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), new Uint8Array([42]));
expect(id).toBe(42n);
});
it('decodes CLASS object id (null when 0)', () => {
expect(
decodeValue(
T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }),
new Uint8Array([0]),
),
).toBeNull();
});
it('decodes ENUMERATION as a sint32', () => {
expect(
decodeValue(
T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }),
new Uint8Array([0]), // Ship
),
).toBe(0);
});
it('decodes NONE as null', () => {
expect(decodeValue(T({ code: TypeCode.NONE }), new Uint8Array(0))).toBeNull();
});
it('throws on unknown type code', () => {
expect(() => decodeValue(T({ code: 9999 }), new Uint8Array(0))).toThrow(/unknown TypeCode/);
});
});
describe('collection decoders', () => {
it('decodes a list of doubles', () => {
// Construct a KRPC.List message manually:
// items[0] = 8 bytes for double 1.0
// items[1] = 8 bytes for double 2.5
const d1 = new Uint8Array(new Float64Array([1.0]).buffer);
const d2 = new Uint8Array(new Float64Array([2.5]).buffer);
const listMsg = KRPC.List.create({ items: [d1, d2] });
const listBytes = KRPC.List.encode(listMsg).finish();
const out = decodeList(
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }),
listBytes,
REG,
);
expect(out).toEqual([1.0, 2.5]);
});
it('decodes a list of strings', () => {
const enc = (s: string): Uint8Array => {
const utf8 = new TextEncoder().encode(s);
return new Uint8Array([utf8.length, ...utf8]);
};
const listMsg = KRPC.List.create({ items: [enc('Kerbin'), enc('Mun')] });
const listBytes = KRPC.List.encode(listMsg).finish();
const out = decodeList(
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }),
listBytes,
REG,
);
expect(out).toEqual(['Kerbin', 'Mun']);
});
it('decodes an empty list', () => {
const listMsg = KRPC.List.create({ items: [] });
const listBytes = KRPC.List.encode(listMsg).finish();
const out = decodeList(
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }),
listBytes,
REG,
);
expect(out).toEqual([]);
});
it('decodes a null list as null', () => {
const out = decodeList(
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }),
new Uint8Array([0]),
REG,
);
expect(out).toBeNull();
});
it('decodes a list of CLASS as a bigint array', () => {
// items[0] = 7, items[1] = 0 (null)
const listMsg = KRPC.List.create({ items: [new Uint8Array([7]), new Uint8Array([0])] });
const listBytes = KRPC.List.encode(listMsg).finish();
const out = decodeList(
T({
code: TypeCode.LIST,
types: [T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'CelestialBody' })],
}),
listBytes,
REG,
);
expect(out).toEqual([7n, null]);
});
it('decodes a tuple of mixed types', () => {
// tuple: (string "Kerbin", double 0.5)
const utf8 = new TextEncoder().encode('Kerbin');
const sBytes = new Uint8Array([utf8.length, ...utf8]);
const dBytes = new Uint8Array(new Float64Array([0.5]).buffer);
const tupleMsg = KRPC.Tuple.create({ items: [sBytes, dBytes] });
const tupleBytes = KRPC.Tuple.encode(tupleMsg).finish();
const out = decodeTuple(
T({
code: TypeCode.TUPLE,
types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })],
}),
tupleBytes,
REG,
);
expect(out).toEqual(['Kerbin', 0.5]);
});
it('decodes a dictionary of string -> double', () => {
const utf8 = new TextEncoder().encode('mu');
const sBytes = new Uint8Array([utf8.length, ...utf8]);
const dBytes = new Uint8Array(new Float64Array([3.53e12]).buffer);
const dictMsg = KRPC.Dictionary.create({
entries: [{ key: sBytes, value: dBytes }],
});
const dictBytes = KRPC.Dictionary.encode(dictMsg).finish();
const out = decodeDictionary(
T({
code: TypeCode.DICTIONARY,
types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })],
}),
dictBytes,
REG,
);
expect(out.get('mu')).toBe(3.53e12);
});
it('decodes a null dictionary as null', () => {
const out = decodeDictionary(
T({
code: TypeCode.DICTIONARY,
types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })],
}),
new Uint8Array([0]),
REG,
);
expect(out).toBeNull();
});
it('exposes a low-level decodeKrpcList for testing', () => {
const listMsg = KRPC.List.create({ items: [new Uint8Array([1, 2, 3])] });
const bytes = KRPC.List.encode(listMsg).finish();
const out = decodeKrpcList(bytes, KRPC.List);
// protobufjs returns Node Buffers (which extend Uint8Array). Compare
// the underlying bytes so this works regardless of wrapper class.
expect(out).toHaveLength(1);
expect(Array.from(out[0] as Uint8Array)).toEqual([1, 2, 3]);
});
});
describe('encodeValue (round-trips)', () => {
it('encodes a double', () => {
const bytes = encodeValue(T({ code: TypeCode.DOUBLE }), 1.5);
expect(decodeValue(T({ code: TypeCode.DOUBLE }), bytes)).toBe(1.5);
});
it('encodes a string', () => {
const bytes = encodeValue(T({ code: TypeCode.STRING }), 'Kerbol');
expect(decodeValue(T({ code: TypeCode.STRING }), bytes)).toBe('Kerbol');
});
it('encodes a CLASS object id', () => {
const bytes = encodeValue(
T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }),
42n,
);
expect(decodeValue(T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), bytes)).toBe(42n);
});
it('encodes a null CLASS as id=0', () => {
const bytes = encodeValue(
T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }),
null,
);
expect(bytes).toEqual(new Uint8Array([0]));
});
it('encodes an ENUMERATION', () => {
const bytes = encodeValue(
T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }),
3, // Probe
);
expect(
decodeValue(
T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }),
bytes,
),
).toBe(3);
});
it('encodes a list of doubles', () => {
const bytes = encodeValue(
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }),
[1.0, 2.0, 3.0],
REG,
);
const out = decodeValue(
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }),
bytes,
REG,
);
expect(out).toEqual([1.0, 2.0, 3.0]);
});
it('encodes a uint64 BigInt', () => {
const id = 0x1_0000_0000n; // 2^32
const bytes = encodeValue(T({ code: TypeCode.UINT64 }), id);
expect(decodeValue(T({ code: TypeCode.UINT64 }), bytes)).toBe(id);
});
it('encodes a bool', () => {
expect(decodeValue(T({ code: TypeCode.BOOL }), encodeValue(T({ code: TypeCode.BOOL }), true))).toBe(true);
expect(decodeValue(T({ code: TypeCode.BOOL }), encodeValue(T({ code: TypeCode.BOOL }), false))).toBe(false);
});
it('rejects SET (not implemented)', () => {
expect(() =>
encodeValue(T({ code: TypeCode.SET, types: [T({ code: TypeCode.STRING })] }), new Set(), REG),
).toThrow(/SET not implemented/);
});
});
// Suppress unused-warning for the TYPE const helper by using it once.
const _checkTypeHelper: KrpcType = T({ code: TypeCode.STRING });
void _checkTypeHelper;
// Also pin the protobufjs default import to confirm we still have it.
const _pb: typeof protobuf = protobuf;
void _pb;
@@ -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,383 @@
/**
* End-to-end test of KrpcServices with a tiny mock kRPC server.
*
* We start a real TCP server that:
* 1. Accepts the RPC + stream handshakes
* 2. Handles a small whitelist of procedure calls by responding
* with hand-encoded values
*
* The test then drives a real KRPCClient + KrpcServices pair through
* the same wire format a real kRPC server uses, and verifies the
* decoded values match the hand-encoded responses.
*
* For the server side we use the same SocketReader-based pattern that
* the client uses, so the two sides share framing semantics.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as net from 'node:net';
import { Buffer } from 'node:buffer';
import { KRPC, encodeMessage } from '../src/schema.js';
import { KRPCClient } from '../src/client.js';
import { loadServices, KrpcServices } from '../src/service-client.js';
import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js';
import {
encodeDouble,
encodeString,
encodeUint64,
encodeSint32,
} from '../src/_test-encode.js';
/** Mock services message that the server will hand back on GetServices. */
const MOCK_SERVICES_RAW = {
services: [
{
name: 'KRPC',
procedures: [
{
name: 'GetStatus',
parameters: [],
returnType: { code: 203, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'GetServices',
parameters: [],
returnType: { code: 204, service: '', name: '', types: [] },
returnIsNullable: false,
},
],
classes: [],
enumerations: [],
},
{
name: 'SpaceCenter',
procedures: [
{
name: 'GetUT',
parameters: [],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'GetActiveVessel',
parameters: [],
returnType: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
returnIsNullable: false,
},
{
name: 'GetBodies',
parameters: [],
returnType: {
code: 301,
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
},
returnIsNullable: false,
},
{
name: 'CelestialBody.GetName',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] },
nullable: false,
},
],
returnType: { code: 8, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Vessel.GetName',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
nullable: false,
},
],
returnType: { code: 8, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Vessel.GetType',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
nullable: false,
},
],
returnType: { code: 101, service: 'SpaceCenter', name: 'VesselType', types: [] },
returnIsNullable: false,
},
],
classes: [{ name: 'CelestialBody' }, { name: 'Vessel' }, { name: 'Orbit' }],
enumerations: [
{
name: 'VesselType',
values: [
{ name: 'Ship', value: 0 },
{ name: 'Probe', value: 3 },
],
},
],
},
],
};
async function freePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const srv = net.createServer();
srv.unref();
srv.on('error', reject);
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
if (addr && typeof addr === 'object') {
const p = addr.port;
srv.close(() => resolve(p));
} else {
srv.close(() => reject(new Error('no addr')));
}
});
});
}
function replyWithValue(socket: net.Socket, value: Uint8Array): void {
const resultMsg = KRPC.ProcedureResult.create({
value: Buffer.from(value),
});
const respMsg = KRPC.Response.create({ results: [resultMsg] });
const payload = Buffer.from(KRPC.Response.encode(respMsg).finish());
sendMessage(socket, KRPC.Response, { results: [resultMsg] });
void payload;
}
function replyWithError(socket: net.Socket, name: string, description: string): void {
const errMsg = KRPC.Error.create({ service: 'Test', name, description });
const resultMsg = KRPC.ProcedureResult.create({ error: errMsg, value: Buffer.from([]) });
sendMessage(socket, KRPC.Response, { results: [resultMsg] });
}
interface MockServer {
rpcPort: number;
streamPort: number;
close: () => Promise<void>;
stub: (
service: string,
procedure: string,
handler: (args: Uint8Array[]) => Uint8Array,
) => void;
/** Counts the number of calls received for (service, procedure). */
callCount: (service: string, procedure: string) => number;
/** Captured arguments of the last call to (service, procedure). */
lastArgs: (service: string, procedure: string) => Uint8Array[];
}
async function startMockServer(): Promise<MockServer> {
const rpcPort = await freePort();
const streamPort = await freePort();
const stubs = new Map<string, (args: Uint8Array[]) => Uint8Array>();
const counts = new Map<string, number>();
const lastArgsMap = new Map<string, Uint8Array[]>();
const stub = (svc: string, proc: string, handler: (args: Uint8Array[]) => Uint8Array) => {
stubs.set(`${svc}.${proc}`, handler);
};
// Default stubs
stub('KRPC', 'GetServices', () => {
const servicesMsg = KRPC.Services.create(MOCK_SERVICES_RAW);
return new Uint8Array(KRPC.Services.encode(servicesMsg).finish());
});
stub('KRPC', 'GetStatus', () => {
const statusMsg = KRPC.Status.create({ version: 'test' });
return new Uint8Array(KRPC.Status.encode(statusMsg).finish());
});
stub('SpaceCenter', 'GetUT', () => encodeDouble(4_700_000.5));
// ── RPC server ─────────────────────────────────────────────────────
const rpcServer = net.createServer((socket) => {
void (async () => {
try {
// 1. Handshake
const req = await recvMessage<{ type: number; clientName: string }>(
socket,
KRPC.ConnectionRequest,
);
if (req.type !== 0) {
socket.destroy();
return;
}
sendMessage(socket, KRPC.ConnectionResponse, {
status: 0,
message: '',
clientIdentifier: Buffer.from('test-client'),
});
// 2. Procedure loop
while (!socket.destroyed) {
let callReq: {
calls: { service: string; procedure: string; arguments?: { value: Uint8Array }[] }[];
};
try {
callReq = await recvMessage(socket, KRPC.Request);
} catch {
return;
}
const call = callReq.calls[0];
if (!call) {
replyWithError(socket, 'Malformed', 'no call');
continue;
}
const key = `${call.service}.${call.procedure}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
const args = (call.arguments ?? []).map((a) => new Uint8Array(a.value));
lastArgsMap.set(key, args);
const handler = stubs.get(key);
if (!handler) {
replyWithError(socket, 'UnknownProcedure', `${key} not stubbed`);
continue;
}
try {
const value = handler(args);
replyWithValue(socket, value);
} catch (e) {
replyWithError(socket, 'HandlerError', String(e));
}
}
} catch {
if (!socket.destroyed) socket.destroy();
}
})();
});
await new Promise<void>((resolve) => rpcServer.listen(rpcPort, '127.0.0.1', resolve));
// ── Stream server (handshake only, then idle) ─────────────────────
const streamServer = net.createServer((socket) => {
void (async () => {
try {
const req = await recvMessage<{ type: number; clientIdentifier: Uint8Array }>(
socket,
KRPC.ConnectionRequest,
);
if (req.type !== 1) {
socket.destroy();
return;
}
sendMessage(socket, KRPC.ConnectionResponse, { status: 0, message: '' });
// Keep alive
await new Promise(() => undefined);
} catch {
socket.destroy();
}
})();
});
await new Promise<void>((resolve) => streamServer.listen(streamPort, '127.0.0.1', resolve));
return {
rpcPort,
streamPort,
stub,
callCount: (svc, proc) => counts.get(`${svc}.${proc}`) ?? 0,
lastArgs: (svc, proc) => lastArgsMap.get(`${svc}.${proc}`) ?? [],
close: () =>
new Promise<void>((resolve) => {
rpcServer.close(() => {
streamServer.close(() => resolve());
});
}),
};
}
describe('KrpcServices against a mock kRPC server', () => {
let server: MockServer;
let client: KRPCClient;
let services: KrpcServices;
beforeAll(async () => {
server = await startMockServer();
client = new KRPCClient({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
clientName: 'test',
});
await client.connect();
const loaded = await loadServices(client);
services = loaded.services;
}, 10_000);
afterAll(async () => {
await client.close();
await server.close();
});
it('lists services', () => {
const names = services.getCache().serviceNames();
expect(names).toContain('KRPC');
expect(names).toContain('SpaceCenter');
});
it('looks up the GetUT procedure', () => {
const r = services.getCache().lookup('SpaceCenter', 'GetUT');
expect(r.found).toBe(true);
});
it('invokes SpaceCenter.GetUT and decodes as double', async () => {
const ut = await services.invoke<number>('SpaceCenter', 'GetUT');
expect(ut).toBe(4_700_000.5);
});
it('invokes SpaceCenter.GetBodies and decodes a list of class ids', async () => {
server.stub('SpaceCenter', 'GetBodies', () => {
const items = [encodeUint64(1n), encodeUint64(2n), encodeUint64(3n)];
const listMsg = KRPC.List.create({ items });
return new Uint8Array(KRPC.List.encode(listMsg).finish());
});
const ids = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
expect(ids).toEqual([1n, 2n, 3n]);
});
it('invokes a class method and decodes the string return', async () => {
server.stub('SpaceCenter', 'CelestialBody.GetName', () => encodeString('Kerbin'));
const name = await services.invoke<string>('SpaceCenter', 'CelestialBody.GetName', 42n);
expect(name).toBe('Kerbin');
});
it('rejects an unknown procedure', async () => {
await expect(
services.invoke('SpaceCenter', 'GetNothing'),
).rejects.toThrow(/procedure not found/);
});
it('rejects a wrong number of arguments', async () => {
await expect(
services.invoke('SpaceCenter', 'GetBodies', 1n, 2n),
).rejects.toThrow(/wrong number of arguments/);
});
it('decodes an enum return value', async () => {
server.stub('SpaceCenter', 'Vessel.GetType', () => encodeSint32(3));
const t = await services.invoke<number>('SpaceCenter', 'Vessel.GetType', 7n);
expect(t).toBe(3);
});
it('passes BigInt class ids through to class methods', async () => {
let receivedId: bigint | null = null;
server.stub('SpaceCenter', 'CelestialBody.GetName', (args) => {
const argBytes = args[0] ?? new Uint8Array();
let v = 0n;
let shift = 0n;
for (const b of argBytes) {
v |= BigInt(b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7n;
}
receivedId = v;
return encodeString('Mun');
});
await services.invoke<string>('SpaceCenter', 'CelestialBody.GetName', 99n);
expect(receivedId).toBe(99n);
});
});
+263
View File
@@ -0,0 +1,263 @@
import { describe, it, expect } from 'vitest';
import { ServiceCache, type RawServicesMessage } from '../src/services.js';
/**
* A small hand-crafted services message that mirrors the shape we'd
* get from KRPC.GetServices() on a real KSP install. Just enough
* services/procedures/enums to exercise the cache.
*/
const SAMPLE_RAW: RawServicesMessage = {
services: [
{
name: 'SpaceCenter',
procedures: [
{
name: 'GetUT',
parameters: [],
returnType: { code: 1, service: '', name: '', types: [] }, // DOUBLE
returnIsNullable: false,
},
{
name: 'GetBodies',
parameters: [],
returnType: {
code: 301, // LIST
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
},
returnIsNullable: false,
},
{
name: 'CelestialBody.GetName',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] },
nullable: false,
},
],
returnType: { code: 8, service: '', name: '', types: [] }, // STRING
returnIsNullable: false,
},
{
name: 'CelestialBody.GetParent',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] },
nullable: false,
},
],
// Nullable CelestialBody (i.e. Sun has no parent).
returnType: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] },
returnIsNullable: true,
},
],
classes: [
{ name: 'CelestialBody' },
{ name: 'Vessel' },
{ name: 'Orbit' },
],
enumerations: [
{
name: 'VesselType',
values: [
{ name: 'Ship', value: 0 },
{ name: 'Station', value: 1 },
{ name: 'Probe', value: 3 },
{ name: 'Debris', value: 8 },
],
},
{
name: 'VesselSituation',
values: [
{ name: 'PreLaunch', value: 0 },
{ name: 'Orbiting', value: 1 },
{ name: 'Escaping', value: 2 },
{ name: 'Landed', value: 4 },
{ name: 'Splashed', value: 5 },
],
},
],
},
{
name: 'KRPC',
procedures: [
{
name: 'GetStatus',
parameters: [],
returnType: { code: 203, service: '', name: '', types: [] }, // STATUS
returnIsNullable: false,
},
],
classes: [],
enumerations: [],
},
],
};
describe('ServiceCache', () => {
it('builds from a raw services message', () => {
const cache = new ServiceCache(SAMPLE_RAW);
expect(cache.procedureCount()).toBe(5);
expect(cache.serviceNames()).toEqual(['KRPC', 'SpaceCenter']);
});
it('looks up a top-level procedure', () => {
const cache = new ServiceCache(SAMPLE_RAW);
const r = cache.lookup('SpaceCenter', 'GetUT');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
expect(r.info.service).toBe('SpaceCenter');
expect(r.info.name).toBe('GetUT');
expect(r.info.returnType.code).toBe(1); // DOUBLE
expect(r.info.returnIsNullable).toBe(false);
expect(r.info.parameters).toHaveLength(0);
});
it('looks up a class-prefixed procedure', () => {
const cache = new ServiceCache(SAMPLE_RAW);
const r = cache.lookup('SpaceCenter', 'CelestialBody.GetName');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
expect(r.info.parameters).toHaveLength(1);
expect(r.info.parameters[0]?.name).toBe('self');
expect(r.info.parameters[0]?.type.code).toBe(100); // CLASS
expect(r.info.parameters[0]?.type.name).toBe('CelestialBody');
expect(r.info.returnType.code).toBe(8); // STRING
});
it('returns not-found for an unknown procedure', () => {
const cache = new ServiceCache(SAMPLE_RAW);
expect(cache.lookup('SpaceCenter', 'GetNothing').found).toBe(false);
expect(cache.lookup('Nope', 'X').found).toBe(false);
});
it('returns not-found for an unknown service', () => {
const cache = new ServiceCache(SAMPLE_RAW);
expect(cache.lookup('KerbalAlarmClock', 'GetAlarms').found).toBe(false);
});
it('records returnIsNullable for nullable CLASS returns', () => {
const cache = new ServiceCache(SAMPLE_RAW);
const r = cache.lookup('SpaceCenter', 'CelestialBody.GetParent');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
expect(r.info.returnIsNullable).toBe(true);
expect(r.info.returnType.code).toBe(100);
expect(r.info.returnType.name).toBe('CelestialBody');
});
it('resolves enum values by name', () => {
const cache = new ServiceCache(SAMPLE_RAW);
expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Ship')).toBe(0);
expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Station')).toBe(1);
expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Probe')).toBe(3);
expect(cache.getEnumValue('SpaceCenter', 'VesselSituation', 'Orbiting')).toBe(1);
});
it('throws for unknown enum or enum value', () => {
const cache = new ServiceCache(SAMPLE_RAW);
expect(() => cache.getEnumValue('SpaceCenter', 'NoSuch', 'X')).toThrow(/unknown enum/);
expect(() => cache.getEnumValue('SpaceCenter', 'VesselType', 'Hovercraft')).toThrow(
/unknown value/,
);
});
it('resolves enum value names by code', () => {
const cache = new ServiceCache(SAMPLE_RAW);
expect(cache.getEnumName('SpaceCenter', 'VesselType', 0)).toBe('Ship');
expect(cache.getEnumName('SpaceCenter', 'VesselType', 3)).toBe('Probe');
expect(cache.getEnumName('SpaceCenter', 'VesselType', 999)).toBeNull();
});
it('lists enum value names', () => {
const cache = new ServiceCache(SAMPLE_RAW);
const names = cache.getEnumNames('SpaceCenter', 'VesselType');
expect(names).toEqual(['Debris', 'Probe', 'Ship', 'Station']); // sorted
});
it('lists procedures in a service', () => {
const cache = new ServiceCache(SAMPLE_RAW);
const procs = cache.proceduresInService('SpaceCenter');
expect(procs).toContain('SpaceCenter.GetUT');
expect(procs).toContain('SpaceCenter.CelestialBody.GetName');
expect(procs).toContain('SpaceCenter.CelestialBody.GetParent');
});
it('preserves nested list type info', () => {
const cache = new ServiceCache(SAMPLE_RAW);
const r = cache.lookup('SpaceCenter', 'GetBodies');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
expect(r.info.returnType.code).toBe(301); // LIST
expect(r.info.returnType.types).toHaveLength(1);
expect(r.info.returnType.types[0]?.code).toBe(100);
expect(r.info.returnType.types[0]?.name).toBe('CelestialBody');
});
it('handles procedures with no return type (returnType is null)', () => {
// Real kRPC server omits the `return_type` field for void
// procedures (e.g. AddStream, setters). protobufjs decodes
// missing message fields as null. Our cache must not crash.
const raw = {
services: [
{
name: 'KRPC',
procedures: [
{
name: 'AddStream',
parameters: [],
returnType: null, // <-- the trigger
returnIsNullable: false,
},
],
classes: [],
enumerations: [],
},
],
};
const cache = new ServiceCache(raw as unknown as Parameters<typeof ServiceCache>[0]);
const r = cache.lookup('KRPC', 'AddStream');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
// Missing return type becomes NONE (code 0).
expect(r.info.returnType.code).toBe(0);
});
it('falls back to .NET-style getter/setter naming for C# properties', () => {
// The kRPC server exposes C# properties using .NET accessor
// conventions: a property `UT` on SpaceCenter becomes two
// procedures named `get_UT` and `set_UT`. User code typically
// writes `SpaceCenter.GetUT()` (PascalCase). The cache must
// transparently translate to the wire-format name.
const raw = {
services: [
{
name: 'SpaceCenter',
procedures: [
{ name: 'get_UT', parameters: [], returnType: null, returnIsNullable: false },
{ name: 'set_UT', parameters: [], returnType: null, returnIsNullable: false },
{ name: 'get_ActiveVessel', parameters: [], returnType: null, returnIsNullable: true },
{ name: 'CelestialBody.get_Name', parameters: [], returnType: null, returnIsNullable: false },
],
classes: [],
enumerations: [],
},
],
};
const cache = new ServiceCache(raw as unknown as Parameters<typeof ServiceCache>[0]);
// Top-level PascalCase -> .NET getter
expect(cache.lookup('SpaceCenter', 'GetUT').found).toBe(true);
// Top-level PascalCase -> .NET setter
expect(cache.lookup('SpaceCenter', 'SetUT').found).toBe(true);
expect(cache.lookup('SpaceCenter', 'GetActiveVessel').found).toBe(true);
// Class-prefixed: "CelestialBody.GetName" -> "CelestialBody.get_Name"
expect(cache.lookup('SpaceCenter', 'CelestialBody.GetName').found).toBe(true);
// Exact match still works
expect(cache.lookup('SpaceCenter', 'get_UT').found).toBe(true);
// And unknown names still return not-found
expect(cache.lookup('SpaceCenter', 'NoSuchProcedure').found).toBe(false);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+150
View File
@@ -143,6 +143,28 @@ importers:
specifier: ^2.1.1
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:
dependencies:
'@kerbal-rt/orbital-math':
@@ -181,6 +203,22 @@ importers:
specifier: ^2.1.1
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:
dependencies:
'@kerbal-rt/shared-types':
@@ -1084,6 +1122,66 @@ packages:
}
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':
resolution:
{
@@ -3110,6 +3208,12 @@ packages:
integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==,
}
long@5.3.2:
resolution:
{
integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==,
}
loose-envify@1.4.0:
resolution:
{
@@ -3456,6 +3560,13 @@ packages:
integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==,
}
protobufjs@7.6.2:
resolution:
{
integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==,
}
engines: { node: '>=12.0.0' }
punycode@2.3.1:
resolution:
{
@@ -4607,6 +4718,28 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
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': {}
'@rollup/rollup-android-arm-eabi@4.61.0':
@@ -5978,6 +6111,8 @@ snapshots:
lodash.merge@4.6.2: {}
long@5.3.2: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -6176,6 +6311,21 @@ snapshots:
object-assign: 4.1.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: {}
queue-microtask@1.2.3: {}