Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Build the Hono app. Exported separately from index.ts so tests can
|
||||
* import it without binding a port.
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { UniverseSnapshotSchema, type AppType as _ } from '@kerbal-rt/shared-types';
|
||||
import { z } from 'zod';
|
||||
import { state } from './state.js';
|
||||
|
||||
export function buildApp() {
|
||||
const app = new Hono();
|
||||
|
||||
app.use('*', logger());
|
||||
app.use('*', cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? '*' }));
|
||||
|
||||
// ─── health ─────────────────────────────────────────────────────────────
|
||||
app.get('/health', (c) =>
|
||||
c.json({
|
||||
ok: true,
|
||||
service: 'kerbal-rt-api',
|
||||
version: '0.1.0',
|
||||
ts: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── ingest (called by the KSP telemetry bridge) ───────────────────────
|
||||
const IngestAuth = z.object({ 'x-api-key': z.string() });
|
||||
|
||||
app.post('/api/v1/ingest', async (c) => {
|
||||
const headerParse = IngestAuth.safeParse(
|
||||
Object.fromEntries(Array.from(c.req.raw.headers.entries())),
|
||||
);
|
||||
if (!headerParse.success) {
|
||||
return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Missing API key' }, 401);
|
||||
}
|
||||
if (headerParse.data['x-api-key'] !== process.env.INGEST_API_KEY) {
|
||||
return c.json({ error: true, code: 'FORBIDDEN', message: 'Bad API key' }, 403);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const parsed = UniverseSnapshotSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{
|
||||
error: true,
|
||||
code: 'BAD_PAYLOAD',
|
||||
message: 'Invalid snapshot',
|
||||
details: parsed.error.format(),
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
state.applySnapshot(parsed.data);
|
||||
return c.json({ error: false, data: { ok: true, ts: new Date().toISOString() } });
|
||||
});
|
||||
|
||||
// ─── read-only endpoints ───────────────────────────────────────────────
|
||||
app.get('/api/v1/state', (c) => {
|
||||
const snap = state.latestSnapshot();
|
||||
if (!snap)
|
||||
return c.json({ error: true, code: 'NO_DATA', message: 'No snapshot yet' }, 503);
|
||||
return c.json({ error: false, data: snap });
|
||||
});
|
||||
|
||||
app.get('/api/v1/bodies', (c) => c.json({ error: false, data: state.latestBodies() }));
|
||||
|
||||
app.get('/api/v1/vessels', (c) => c.json({ error: false, data: state.latestVessels() }));
|
||||
|
||||
app.get('/api/v1/vessels/:id', (c) => {
|
||||
const v = state.getVessel(c.req.param('id'));
|
||||
if (!v) return c.json({ error: true, code: 'NOT_FOUND', message: 'Vessel not found' }, 404);
|
||||
return c.json({ error: false, data: v });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export type AppType = ReturnType<typeof buildApp>;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* API entrypoint.
|
||||
*
|
||||
* Stack: Hono on Node + ws for WebSockets.
|
||||
* Phase 0 scope: healthcheck, body catalog (static seed), vessel list
|
||||
* (in-memory from /ingest), and a /live WebSocket.
|
||||
*
|
||||
* Real DB (Postgres + Timescale) lands in Phase 1.
|
||||
*/
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createNodeWebSocket } from '@hono/node-ws';
|
||||
import type { ServerType } from '@hono/node-server';
|
||||
import type { LiveMessage } from '@kerbal-rt/shared-types';
|
||||
import { buildApp } from './app.js';
|
||||
import { handleLiveSocket } from './ws.js';
|
||||
|
||||
const app = buildApp();
|
||||
|
||||
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
app.get(
|
||||
'/api/v1/live',
|
||||
upgradeWebSocket(() => ({
|
||||
onOpen: (_evt, ws) => handleLiveSocket.onOpen(ws),
|
||||
onMessage: (evt, ws) => handleLiveSocket.onMessage(evt, ws),
|
||||
onClose: (_evt, ws) => handleLiveSocket.onClose(ws),
|
||||
})),
|
||||
);
|
||||
|
||||
const port = Number(process.env.PORT ?? 4000);
|
||||
|
||||
const server: ServerType = serve({ fetch: app.fetch, port }, (info) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[kerbal-rt-api] listening on http://localhost:${info.port}`);
|
||||
});
|
||||
|
||||
injectWebSocket(server);
|
||||
|
||||
// Push periodic pings to all connected WS clients
|
||||
setInterval(() => {
|
||||
const msg: LiveMessage = { type: 'ping', serverTime: new Date().toISOString() };
|
||||
handleLiveSocket.broadcast(msg);
|
||||
}, 15_000);
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* In-memory state store. Phase 0 only.
|
||||
*
|
||||
* Phase 1 swaps this for Postgres + Timescale + Redis. The interface
|
||||
* stays stable: callers ask for the latest snapshot, and the store
|
||||
* decides where it comes from.
|
||||
*/
|
||||
import type { CelestialBody, UniverseSnapshot, Vessel } from '@kerbal-rt/shared-types';
|
||||
|
||||
class StateStore {
|
||||
private snapshot: UniverseSnapshot | null = null;
|
||||
private vesselsById: Map<string, Vessel> = new Map();
|
||||
private bodiesById: Map<string, CelestialBody> = new Map();
|
||||
|
||||
applySnapshot(snap: UniverseSnapshot): void {
|
||||
this.snapshot = snap;
|
||||
this.vesselsById = new Map(snap.vessels.map((v) => [v.id, v]));
|
||||
this.bodiesById = new Map(snap.bodies.map((b) => [b.id, b]));
|
||||
}
|
||||
|
||||
latestSnapshot(): UniverseSnapshot | null {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
latestBodies(): CelestialBody[] {
|
||||
return Array.from(this.bodiesById.values());
|
||||
}
|
||||
|
||||
latestVessels(): Vessel[] {
|
||||
return Array.from(this.vesselsById.values());
|
||||
}
|
||||
|
||||
getVessel(id: string): Vessel | undefined {
|
||||
return this.vesselsById.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const state = new StateStore();
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Test-only Hono app export. Doesn't bind a port.
|
||||
*/
|
||||
import { buildApp } from './app.js';
|
||||
export const app = buildApp();
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* WebSocket fan-out for /api/v1/live.
|
||||
*
|
||||
* Phase 0 just keeps a Set of connected clients and broadcasts any
|
||||
* LiveMessage we get. Clients should handle `ping` as a heartbeat.
|
||||
*
|
||||
* Phase 1 will wire this to a Redis pubsub channel so multiple API
|
||||
* instances can share the fan-out load.
|
||||
*/
|
||||
import type { LiveMessage } from '@kerbal-rt/shared-types';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
function send(ws: WebSocket, msg: LiveMessage): void {
|
||||
if (ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
export const handleLiveSocket = {
|
||||
onOpen(ws: WebSocket): void {
|
||||
clients.add(ws);
|
||||
},
|
||||
|
||||
onMessage(_evt: { data: unknown }, _ws: WebSocket): void {
|
||||
// Phase 0: clients are read-only, ignore incoming.
|
||||
// Phase 2: accept subscriptions like { type: 'subscribe', vesselId: '...' }
|
||||
},
|
||||
|
||||
onClose(ws: WebSocket): void {
|
||||
clients.delete(ws);
|
||||
},
|
||||
|
||||
broadcast(msg: LiveMessage): void {
|
||||
for (const ws of clients) send(ws, msg);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user