/** * Minimal Factory REST client for the Factory Droid provider. * * Implements exactly the endpoints the stream engine needs, matching the wire * format used by @factory/droid-sdk's REST helpers (Bearer auth, JSON bodies, * `{detail,status,title}` error envelope). `fetchImpl` is injectable so tests * never hit the network. */ import { FACTORY_DROID_API_PREFIX, FACTORY_DROID_BASE, FACTORY_DROID_BASE_ENV, } from "./config.ts" import { errorMessageFromBody, isRecord } from "./converters.ts" import type { FactoryComputer, FactoryMachineTemplate, FactoryMessage, FactorySession, } from "./types.ts" export interface FactoryClientOptions { apiKey: string baseUrl?: string fetchImpl?: typeof fetch } export class FactoryApiError extends Error { readonly status: number readonly title?: string constructor(message: string, status: number, title?: string) { super(message) this.name = "FactoryApiError" this.status = status this.title = title } } function baseUrlFromEnv(env: NodeJS.ProcessEnv | undefined): string | undefined { return env?.[FACTORY_DROID_BASE_ENV] } export function createFactoryClient(options: FactoryClientOptions) { const apiKey = options.apiKey const baseUrl = options.baseUrl ?? baseUrlFromEnv(process.env) ?? FACTORY_DROID_BASE const fetchImpl = options.fetchImpl ?? fetch async function request( path: string, init?: { method?: string; body?: unknown; signal?: AbortSignal }, ): Promise { const headers: Record = { Authorization: `Bearer ${apiKey}`, Accept: "application/json", } if (init?.body !== undefined) headers["Content-Type"] = "application/json" let response: Response try { response = await fetchImpl(`${baseUrl}${FACTORY_DROID_API_PREFIX}${path}`, { method: init?.method ?? "GET", headers, ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}), signal: init?.signal, }) } catch (error) { throw new Error( `Failed to reach Factory API: ${error instanceof Error ? error.message : String(error)}`, ) } if (response.status === 204) return undefined as T let body: unknown try { body = await response.json() } catch { throw new FactoryApiError( `Factory API returned non-JSON response (status ${response.status})`, response.status, ) } if (!response.ok) { const message = errorMessageFromBody(body, response.status) const title = isRecord(body) ? String(body.title ?? "") : undefined throw new FactoryApiError(message, response.status, title) } return body as T } return { // --------------------------------------------------------------- // Sessions // --------------------------------------------------------------- createSession( body: { computerId: string cwd?: string sessionSettings?: Record }, signal?: AbortSignal, ): Promise { return request("/sessions", { method: "POST", body, signal }) }, getSession(sessionId: string, signal?: AbortSignal): Promise { return request(`/sessions/${encodeURIComponent(sessionId)}`, { signal, }) }, addMessage( sessionId: string, body: { text: string; computerId?: string }, signal?: AbortSignal, ): Promise<{ messageId: string; status: string }> { return request<{ messageId: string; status: string }>( `/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", body, signal }, ) }, getMessages( sessionId: string, query?: { role?: string; limit?: number; cursor?: string }, signal?: AbortSignal, ): Promise<{ messages: FactoryMessage[] pagination?: { hasMore: boolean; nextCursor: string | null } }> { const params = new URLSearchParams() if (query?.role) params.set("role", query.role) if (query?.limit != null) params.set("limit", String(query.limit)) if (query?.cursor) params.set("cursor", query.cursor) const qs = params.toString() return request<{ messages: FactoryMessage[] pagination?: { hasMore: boolean; nextCursor: string | null } }>( `/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ""}`, { signal }, ) }, interrupt(sessionId: string, signal?: AbortSignal): Promise<{ status: string }> { return request<{ status: string }>( `/sessions/${encodeURIComponent(sessionId)}/interrupt`, { method: "POST", signal, }, ) }, // --------------------------------------------------------------- // Computers (auto-create support) // --------------------------------------------------------------- listComputers(signal?: AbortSignal): Promise<{ computers: FactoryComputer[] }> { return request<{ computers: FactoryComputer[] }>("/computers", { signal }) }, createComputer( body: { name: string provider?: "byom" | "e2b" remoteUser?: string repos?: string[] autoInstallDeps?: boolean source?: | { kind: "template"; templateId: string } | { kind: "computer"; sourceComputerId: string } }, signal?: AbortSignal, ): Promise { return request("/computers", { method: "POST", body, signal, }) }, getComputer(computerId: string, signal?: AbortSignal): Promise { return request( `/computers/${encodeURIComponent(computerId)}`, { signal }, ) }, listMachineTemplates( signal?: AbortSignal, ): Promise<{ templates: FactoryMachineTemplate[] }> { return request<{ templates: FactoryMachineTemplate[] }>( "/machines/templates", { signal }, ) }, } } export type FactoryClient = ReturnType