// SDK client wrapper for React Native // We create our own lightweight client that mirrors the opencode SDK patterns // but works in React Native environment // expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE import { fetch as expoFetch } from "expo/fetch" import { buildRequestHeaders } from "./headers" import { SSEParser } from "./sse" import { apiErrorFor } from "./api-error" import { loadSessionList } from "./session-list" import type { FileRoot } from "./file-roots" export { ApiAuthError, isAuthError } from "./api-error" export interface ClientConfig { baseUrl: string directory?: string auth?: { username: string password: string } } export interface Session { id: string slug: string projectID: string directory: string parentID?: string title: string version: string share?: { url: string } time: { created: number updated: number compacting?: number archived?: number } summary?: { additions: number deletions: number files: number } // Present while a message (and everything after it) is pending revert — // the server keeps the underlying messages until the next prompt/summarize // call runs cleanup (or the revert is undone via session.unrevert). revert?: { messageID: string partID?: string } } export interface Message { id: string sessionID: string role: "user" | "assistant" parentID?: string time: { created: number completed?: number } // User message fields agent?: string model?: { providerID: string; modelID: string } // Assistant message fields modelID?: string providerID?: string cost?: number tokens?: { input: number output: number reasoning?: number cache?: { read: number; write: number } } error?: { message: string } finish?: string } // API returns messages with parts embedded export interface MessageWithParts { info: Message parts: Part[] } export interface Part { id: string sessionID?: string messageID: string type: | "text" | "reasoning" | "tool" | "file" | "snapshot" | "patch" | "step-start" | "step-finish" | "subtask" | "retry" | "compaction" | "agent" // Text / reasoning part text?: string // Tool part tool?: string callID?: string state?: { status: "pending" | "running" | "completed" | "error" input?: unknown output?: unknown title?: string error?: { message: string } time?: { start?: number; end?: number } } // Timing time?: { start?: number; end?: number } // File part mime?: string url?: string filename?: string } export interface Agent { name: string description?: string mode: "subagent" | "primary" | "all" native?: boolean hidden?: boolean topP?: number temperature?: number color?: string model?: { modelID: string; providerID: string } prompt?: string options: Record steps?: number } export interface Command { name: string description?: string agent?: string model?: string mcp?: boolean template: string subtask?: boolean hints: string[] } export interface Project { id: string name?: string path: { cwd: string root: string absolute: string } } export interface FileEntry { name: string path: string absolute: string type: "file" | "directory" ignored: boolean } export interface Event { type: string properties: Record } export interface HealthResponse { healthy: boolean version: string } const REQUEST_TIMEOUT_MS = 30_000 // Thrown by request() on a non-2xx response. Carries the HTTP status so // callers can distinguish e.g. 404 (older server, endpoint missing) from // other failures without parsing the message string. export class ApiError extends Error { status: number constructor(status: number, body: string) { super(`API Error: ${status} - ${body}`) this.name = "ApiError" this.status = status } } function createHeaders(config: ClientConfig): HeadersInit { return buildRequestHeaders(config) } // `timeoutMs` lets specific callers (e.g. the onboarding health-check) fail // faster than the general REQUEST_TIMEOUT_MS used by real session calls. // Leave it unset to get the default. async function request( config: ClientConfig, path: string, options: RequestInit = {}, timeoutMs?: number, ): Promise { const url = `${config.baseUrl}${path}` const headers = { ...createHeaders(config), ...options.headers } const response = await fetchWithTimeout( url, { ...options, headers, }, timeoutMs, ) if (!response.ok) { const error = await response.text() throw apiErrorFor(response.status, `API Error: ${response.status} - ${error}`) } return response.json() } async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { const parentSignal = options.signal if (parentSignal?.aborted) throw new Error("Request aborted") const controller = new AbortController() let timedOut = false const timeout = setTimeout(() => { timedOut = true controller.abort() }, timeoutMs) const onParentAbort = () => controller.abort() parentSignal?.addEventListener("abort", onParentAbort) try { return await fetch(url, { ...options, signal: controller.signal }) } catch (error) { if (timedOut) { throw new Error(`Request timed out after ${timeoutMs}ms`) } throw error } finally { clearTimeout(timeout) parentSignal?.removeEventListener("abort", onParentAbort) } } export function createClient(config: ClientConfig) { // Normalize once: a trailing slash on baseUrl (e.g. pasted into Advanced // mode or the Edit screen) would otherwise survive into every // `${config.baseUrl}${path}` concatenation below as a double slash, which // every request then fails against (while the diagnostics probe, which // reconstructs a clean URL, reports "works now"). A bare URL with no // trailing slash is untouched. config = { ...config, baseUrl: config.baseUrl.replace(/\/+$/, "") } return { global: { // `timeoutMs` overrides the default REQUEST_TIMEOUT_MS — used by the // onboarding connection test to fail fast on a bad/unreachable IP // instead of hanging for the full 30s (issue: first-run bounce). health: (timeoutMs?: number) => request(config, "/global/health", {}, timeoutMs), // SSE event stream - returns async iterator // Pass an AbortSignal to cancel the connection async *events(signal?: AbortSignal): AsyncGenerator { const url = `${config.baseUrl}/global/event` const headers = createHeaders(config) // Remove Content-Type for SSE (it's text/event-stream) delete (headers as Record)["Content-Type"] // Must use expo/fetch for ReadableStream support on native const response = await expoFetch(url, { headers, signal }) if (!response.ok || !response.body) { throw apiErrorFor(response.status, `Failed to connect to event stream: ${response.status}`) } const reader = response.body.getReader() const decoder = new TextDecoder() const parser = new SSEParser() let receivedFirstByte = false try { while (true) { const { done, value } = await reader.read() if (done) { console.log("[SSE] stream ended") break } if (!receivedFirstByte) { receivedFirstByte = true console.log(`[SSE] first byte received (${value?.byteLength ?? 0} bytes)`) } for (const data of parser.push(decoder.decode(value, { stream: true }))) { try { yield JSON.parse(data) } catch (err) { console.warn("[SSE] Failed to parse event", { length: data.length, error: err instanceof Error ? err.message : String(err), }) } } } } finally { reader.releaseLock() } }, }, project: { list: () => request(config, "/project"), current: () => request(config, "/project/current"), }, // Server-side filesystem browsing, scoped to this client's directory // (see ClientConfig.directory / x-opencode-directory header). Use // clientForDirectory(dir) to get a client rooted at a specific folder, // then list("." ) to enumerate its immediate children. file: { list: (params: { path?: string } = {}) => { const query = new URLSearchParams({ path: params.path ?? "." }) return request(config, `/file?${query.toString()}`) }, // Enumerate the server's filesystem roots (mounted drives, home dir) // to seed the directory browser's pinned top-level entries. Resolves // to null on servers that don't yet expose GET /file/roots (older // opencode builds) so callers fall back to manual path entry instead // of crashing; other errors propagate like any other request. roots: async (): Promise => { try { return await request(config, "/file/roots") } catch (err) { if (err instanceof ApiError && err.status === 404) return null throw err } }, }, path: { get: () => request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"), }, session: { // Prefer the GLOBAL experimental endpoint (all sessions across every // directory) so the Recent Sessions list works without the user first // picking a folder — a directory-less GET /session is directory-scoped // and returns [] on servers whose active dir has no sessions. Shaping // (roots filter, search, sort-by-updated, limit) happens client-side in // loadSessionList; we fetch /experimental/session with no query params // because the server applies `limit` before we can filter to roots. // Falls back to the legacy /session path only on 404 (older servers). list: (params?: { roots?: boolean; limit?: number; search?: string }): Promise => loadSessionList( { getExperimental: async (): Promise => { const response = await fetchWithTimeout(`${config.baseUrl}/experimental/session`, { headers: createHeaders(config), }) // Older servers lack this route — signal fallback to legacy /session. if (response.status === 404) return null if (!response.ok) { const body = await response.text() throw apiErrorFor(response.status, `API Error: ${response.status} - ${body}`) } return response.json() }, getLegacy: (query) => request(config, `/session${query}`), }, params, ), get: (sessionID: string) => request(config, `/session/${sessionID}`), create: (params?: { title?: string }) => request(config, "/session", { method: "POST", body: JSON.stringify(params || {}), }), delete: (sessionID: string) => request(config, `/session/${sessionID}`, { method: "DELETE" }), update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) => request(config, `/session/${sessionID}`, { method: "PATCH", body: JSON.stringify(params), }), messages: (sessionID: string, params?: { limit?: number }) => { const query = new URLSearchParams() if (params?.limit) query.set("limit", String(params.limit)) const qs = query.toString() return request(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`) }, // Sends a message and returns the response // Fire-and-forget async prompt - SSE events drive all real-time updates prompt: async ( sessionID: string, params: { parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }> model?: { providerID: string; modelID: string } agent?: string variant?: string }, ): Promise => { const url = `${config.baseUrl}/session/${sessionID}/prompt_async` const headers = createHeaders(config) const body = JSON.stringify(params) const response = await fetchWithTimeout(url, { method: "POST", headers, body, }) if (!response.ok) { const error = await response.text() throw new Error(`Failed to send message: ${response.status} - ${error}`) } }, command: async ( sessionID: string, params: { command: string arguments: string agent?: string model?: string variant?: string parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }> }, ): Promise => { const url = `${config.baseUrl}/session/${sessionID}/command` const headers = createHeaders(config) const response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify({ ...params, sessionID }), }) if (!response.ok) { const error = await response.text() throw new Error(`Failed to run command: ${response.status} - ${error}`) } }, abort: (sessionID: string) => request(config, `/session/${sessionID}/abort`, { method: "POST" }), diff: (sessionID: string, messageID?: string) => { const qs = messageID ? `?messageID=${messageID}` : "" return request(config, `/session/${sessionID}/diff${qs}`) }, // Marks messageID (and everything after it) as pending revert. The // underlying messages aren't deleted until the next prompt runs // cleanup, or the revert is undone with unrevert() below. revert: (sessionID: string, messageID: string, partID?: string) => request(config, `/session/${sessionID}/revert`, { method: "POST", body: JSON.stringify(partID ? { messageID, partID } : { messageID }), }), unrevert: (sessionID: string) => request(config, `/session/${sessionID}/unrevert`, { method: "POST", }), }, permission: { list: () => request>(config, "/permission"), reply: (requestID: string, reply: "once" | "always" | "reject") => request(config, `/permission/${requestID}/reply`, { method: "POST", body: JSON.stringify({ reply }), }), }, question: { list: () => request>(config, "/question"), reply: (requestID: string, answers: string[][]) => request(config, `/question/${requestID}/reply`, { method: "POST", body: JSON.stringify({ answers }), }), reject: (requestID: string) => request(config, `/question/${requestID}/reject`, { method: "POST", }), }, agent: { list: () => request(config, "/agent"), }, command: { list: () => request(config, "/command"), }, provider: { list: () => request<{ all: Array<{ id: string name: string models: Record< string, { id: string name: string attachment: boolean reasoning: boolean tool_call: boolean cost?: { input: number; output: number } limit: { context: number; output: number } status?: "alpha" | "beta" | "deprecated" | "active" variants?: Record } > }> default: Record connected: string[] }>(config, "/provider"), }, config: { get: () => request(config, "/config"), }, } } export type Client = ReturnType