/** * 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; /** 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; 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 { 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 { const url = `${this.opts.apiUrl}/api/v1/ingest`; const headers: Record = { '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 { return new Promise((r) => setTimeout(r, ms)); }