flybrain/services/bridge/src/sim.ts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

127 lines
4.7 KiB
TypeScript

/**
* 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<T> =
| { 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<SimResult<StatusResponse>>;
stimulate(request: StimulateRequest): Promise<SimResult<StimulateResponse>>;
/**
* `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<SimResult<ChatResponse>>;
events(since?: number, limit?: number): Promise<SimResult<EventsResponse>>;
healthz(): Promise<SimResult<HealthzResponse>>;
}
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<SimResult<StatusResponse>> {
return this.request<StatusResponse>('GET', '/status');
}
async stimulate(request: StimulateRequest): Promise<SimResult<StimulateResponse>> {
return this.request<StimulateResponse>('POST', '/stimulate', request);
}
async chat(request: ChatRequest): Promise<SimResult<ChatResponse>> {
return this.request<ChatResponse>('POST', '/chat', request);
}
async events(since?: number, limit?: number): Promise<SimResult<EventsResponse>> {
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<EventsResponse>('GET', query ? `/events?${query}` : '/events');
}
async healthz(): Promise<SimResult<HealthzResponse>> {
return this.request<HealthzResponse>('GET', '/healthz');
}
private async request<T>(method: string, path: string, body?: unknown): Promise<SimResult<T>> {
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<StimulateRateLimitedResponse> | undefined;
return { ok: false, kind: 'rate_limited', retryAfterMs: body429?.retryAfterMs ?? 0 };
}
if (response.status === 403) {
const body403 = (await safeJson(response)) as Partial<ErrorResponse> | undefined;
return { ok: false, kind: 'forbidden', error: body403?.error ?? 'forbidden' };
}
if (!response.ok) {
const errorBody = (await safeJson(response)) as Partial<ErrorResponse> | 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<unknown> {
try {
return await response.json();
} catch {
return undefined;
}
}