/** * Typed client for flysim's localhost control API (`docs/control-api.md`). * * Every method returns a discriminated `SimResult` rather than throwing, so callers (chat * commands, redemption handling, `/health`) can handle "the sim said no" and "the sim didn't * answer" as ordinary control flow instead of catch blocks. Timeouts use `AbortSignal.timeout`; * the request/response shapes are imported from `@flybrain/feed`, never redeclared. */ import type { ChatRequest, ChatResponse, ErrorResponse, EventsResponse, HealthzResponse, StatusResponse, StimulateRateLimitedResponse, StimulateRequest, StimulateResponse, } from '@flybrain/feed'; export type SimResult = | { ok: true; data: T } | { ok: false; kind: 'rate_limited'; retryAfterMs: number } | { ok: false; kind: 'forbidden'; error: string } | { ok: false; kind: 'timeout' } | { ok: false; kind: 'http_error'; status: number; error?: string } | { ok: false; kind: 'network_error'; error: string }; export interface SimClientOptions { baseUrl: string; timeoutMs: number; /** Injectable for tests; defaults to the global `fetch`. */ fetchImpl?: typeof fetch; } /** Client interface (rather than a concrete class) so tests can substitute a fake without HTTP. */ export interface SimClient { status(): Promise>; stimulate(request: StimulateRequest): Promise>; /** * `POST /chat`: one line for the on-screen chat ring. The 422 a refused line gets arrives as * `{ kind: 'http_error', status: 422 }`, which is the honest mapping — it is the service saying * no, not a transport failure. See `src/onscreen-chat.ts`, the only caller. */ chat(request: ChatRequest): Promise>; events(since?: number, limit?: number): Promise>; healthz(): Promise>; } export class HttpSimClient implements SimClient { private readonly baseUrl: string; private readonly timeoutMs: number; private readonly fetchImpl: typeof fetch; constructor(options: SimClientOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/, ''); this.timeoutMs = options.timeoutMs; this.fetchImpl = options.fetchImpl ?? fetch; } async status(): Promise> { return this.request('GET', '/status'); } async stimulate(request: StimulateRequest): Promise> { return this.request('POST', '/stimulate', request); } async chat(request: ChatRequest): Promise> { return this.request('POST', '/chat', request); } async events(since?: number, limit?: number): Promise> { const params = new URLSearchParams(); if (since !== undefined) params.set('since', String(since)); if (limit !== undefined) params.set('limit', String(limit)); const query = params.toString(); return this.request('GET', query ? `/events?${query}` : '/events'); } async healthz(): Promise> { return this.request('GET', '/healthz'); } private async request(method: string, path: string, body?: unknown): Promise> { let response: Response; try { response = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers: body === undefined ? undefined : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs), }); } catch (cause) { if (cause instanceof Error && (cause.name === 'TimeoutError' || cause.name === 'AbortError')) { return { ok: false, kind: 'timeout' }; } return { ok: false, kind: 'network_error', error: (cause as Error).message }; } if (response.status === 429) { const body429 = (await safeJson(response)) as Partial | undefined; return { ok: false, kind: 'rate_limited', retryAfterMs: body429?.retryAfterMs ?? 0 }; } if (response.status === 403) { const body403 = (await safeJson(response)) as Partial | undefined; return { ok: false, kind: 'forbidden', error: body403?.error ?? 'forbidden' }; } if (!response.ok) { const errorBody = (await safeJson(response)) as Partial | undefined; return { ok: false, kind: 'http_error', status: response.status, error: errorBody?.error }; } const data = (await safeJson(response)) as T; return { ok: true, data }; } } async function safeJson(response: Response): Promise { try { return await response.json(); } catch { return undefined; } }