summaryrefslogtreecommitdiffhomepage
path: root/packages/sdk/src/core
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-22 11:50:51 -0500
committeradamdotdevin <[email protected]>2025-07-22 11:50:51 -0500
commit10c8b495907069461f5dc464f6285321290c8b14 (patch)
tree9fed07910dd7d99688c0261a364d810a500e21d3 /packages/sdk/src/core
parent500cea5ce7fa635a924cd9abea63aaf672f7645d (diff)
downloadopencode-10c8b495907069461f5dc464f6285321290c8b14.tar.gz
opencode-10c8b495907069461f5dc464f6285321290c8b14.zip
chore: generate sdk into packages/sdk
Diffstat (limited to 'packages/sdk/src/core')
-rw-r--r--packages/sdk/src/core/README.md3
-rw-r--r--packages/sdk/src/core/api-promise.ts92
-rw-r--r--packages/sdk/src/core/error.ts130
-rw-r--r--packages/sdk/src/core/resource.ts11
-rw-r--r--packages/sdk/src/core/streaming.ts315
-rw-r--r--packages/sdk/src/core/uploads.ts2
6 files changed, 553 insertions, 0 deletions
diff --git a/packages/sdk/src/core/README.md b/packages/sdk/src/core/README.md
new file mode 100644
index 000000000..485fce861
--- /dev/null
+++ b/packages/sdk/src/core/README.md
@@ -0,0 +1,3 @@
+# `core`
+
+This directory holds public modules implementing non-resource-specific SDK functionality.
diff --git a/packages/sdk/src/core/api-promise.ts b/packages/sdk/src/core/api-promise.ts
new file mode 100644
index 000000000..fc1c6dd76
--- /dev/null
+++ b/packages/sdk/src/core/api-promise.ts
@@ -0,0 +1,92 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import { type Opencode } from '../client';
+
+import { type PromiseOrValue } from '../internal/types';
+import { APIResponseProps, defaultParseResponse } from '../internal/parse';
+
+/**
+ * A subclass of `Promise` providing additional helper methods
+ * for interacting with the SDK.
+ */
+export class APIPromise<T> extends Promise<T> {
+ private parsedPromise: Promise<T> | undefined;
+ #client: Opencode;
+
+ constructor(
+ client: Opencode,
+ private responsePromise: Promise<APIResponseProps>,
+ private parseResponse: (
+ client: Opencode,
+ props: APIResponseProps,
+ ) => PromiseOrValue<T> = defaultParseResponse,
+ ) {
+ super((resolve) => {
+ // this is maybe a bit weird but this has to be a no-op to not implicitly
+ // parse the response body; instead .then, .catch, .finally are overridden
+ // to parse the response
+ resolve(null as any);
+ });
+ this.#client = client;
+ }
+
+ _thenUnwrap<U>(transform: (data: T, props: APIResponseProps) => U): APIPromise<U> {
+ return new APIPromise(this.#client, this.responsePromise, async (client, props) =>
+ transform(await this.parseResponse(client, props), props),
+ );
+ }
+
+ /**
+ * Gets the raw `Response` instance instead of parsing the response
+ * data.
+ *
+ * If you want to parse the response body but still get the `Response`
+ * instance, you can use {@link withResponse()}.
+ *
+ * 👋 Getting the wrong TypeScript type for `Response`?
+ * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]`
+ * to your `tsconfig.json`.
+ */
+ asResponse(): Promise<Response> {
+ return this.responsePromise.then((p) => p.response);
+ }
+
+ /**
+ * Gets the parsed response data and the raw `Response` instance.
+ *
+ * If you just want to get the raw `Response` instance without parsing it,
+ * you can use {@link asResponse()}.
+ *
+ * 👋 Getting the wrong TypeScript type for `Response`?
+ * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]`
+ * to your `tsconfig.json`.
+ */
+ async withResponse(): Promise<{ data: T; response: Response }> {
+ const [data, response] = await Promise.all([this.parse(), this.asResponse()]);
+ return { data, response };
+ }
+
+ private parse(): Promise<T> {
+ if (!this.parsedPromise) {
+ this.parsedPromise = this.responsePromise.then((data) => this.parseResponse(this.#client, data));
+ }
+ return this.parsedPromise;
+ }
+
+ override then<TResult1 = T, TResult2 = never>(
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
+ ): Promise<TResult1 | TResult2> {
+ return this.parse().then(onfulfilled, onrejected);
+ }
+
+ override catch<TResult = never>(
+ onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null,
+ ): Promise<T | TResult> {
+ return this.parse().catch(onrejected);
+ }
+
+ override finally(onfinally?: (() => void) | undefined | null): Promise<T> {
+ return this.parse().finally(onfinally);
+ }
+}
diff --git a/packages/sdk/src/core/error.ts b/packages/sdk/src/core/error.ts
new file mode 100644
index 000000000..44698c739
--- /dev/null
+++ b/packages/sdk/src/core/error.ts
@@ -0,0 +1,130 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import { castToError } from '../internal/errors';
+
+export class OpencodeError extends Error {}
+
+export class APIError<
+ TStatus extends number | undefined = number | undefined,
+ THeaders extends Headers | undefined = Headers | undefined,
+ TError extends Object | undefined = Object | undefined,
+> extends OpencodeError {
+ /** HTTP status for the response that caused the error */
+ readonly status: TStatus;
+ /** HTTP headers for the response that caused the error */
+ readonly headers: THeaders;
+ /** JSON body of the response that caused the error */
+ readonly error: TError;
+
+ constructor(status: TStatus, error: TError, message: string | undefined, headers: THeaders) {
+ super(`${APIError.makeMessage(status, error, message)}`);
+ this.status = status;
+ this.headers = headers;
+ this.error = error;
+ }
+
+ private static makeMessage(status: number | undefined, error: any, message: string | undefined) {
+ const msg =
+ error?.message ?
+ typeof error.message === 'string' ?
+ error.message
+ : JSON.stringify(error.message)
+ : error ? JSON.stringify(error)
+ : message;
+
+ if (status && msg) {
+ return `${status} ${msg}`;
+ }
+ if (status) {
+ return `${status} status code (no body)`;
+ }
+ if (msg) {
+ return msg;
+ }
+ return '(no status code or body)';
+ }
+
+ static generate(
+ status: number | undefined,
+ errorResponse: Object | undefined,
+ message: string | undefined,
+ headers: Headers | undefined,
+ ): APIError {
+ if (!status || !headers) {
+ return new APIConnectionError({ message, cause: castToError(errorResponse) });
+ }
+
+ const error = errorResponse as Record<string, any>;
+
+ if (status === 400) {
+ return new BadRequestError(status, error, message, headers);
+ }
+
+ if (status === 401) {
+ return new AuthenticationError(status, error, message, headers);
+ }
+
+ if (status === 403) {
+ return new PermissionDeniedError(status, error, message, headers);
+ }
+
+ if (status === 404) {
+ return new NotFoundError(status, error, message, headers);
+ }
+
+ if (status === 409) {
+ return new ConflictError(status, error, message, headers);
+ }
+
+ if (status === 422) {
+ return new UnprocessableEntityError(status, error, message, headers);
+ }
+
+ if (status === 429) {
+ return new RateLimitError(status, error, message, headers);
+ }
+
+ if (status >= 500) {
+ return new InternalServerError(status, error, message, headers);
+ }
+
+ return new APIError(status, error, message, headers);
+ }
+}
+
+export class APIUserAbortError extends APIError<undefined, undefined, undefined> {
+ constructor({ message }: { message?: string } = {}) {
+ super(undefined, undefined, message || 'Request was aborted.', undefined);
+ }
+}
+
+export class APIConnectionError extends APIError<undefined, undefined, undefined> {
+ constructor({ message, cause }: { message?: string | undefined; cause?: Error | undefined }) {
+ super(undefined, undefined, message || 'Connection error.', undefined);
+ // in some environments the 'cause' property is already declared
+ // @ts-ignore
+ if (cause) this.cause = cause;
+ }
+}
+
+export class APIConnectionTimeoutError extends APIConnectionError {
+ constructor({ message }: { message?: string } = {}) {
+ super({ message: message ?? 'Request timed out.' });
+ }
+}
+
+export class BadRequestError extends APIError<400, Headers> {}
+
+export class AuthenticationError extends APIError<401, Headers> {}
+
+export class PermissionDeniedError extends APIError<403, Headers> {}
+
+export class NotFoundError extends APIError<404, Headers> {}
+
+export class ConflictError extends APIError<409, Headers> {}
+
+export class UnprocessableEntityError extends APIError<422, Headers> {}
+
+export class RateLimitError extends APIError<429, Headers> {}
+
+export class InternalServerError extends APIError<number, Headers> {}
diff --git a/packages/sdk/src/core/resource.ts b/packages/sdk/src/core/resource.ts
new file mode 100644
index 000000000..ccf9032e4
--- /dev/null
+++ b/packages/sdk/src/core/resource.ts
@@ -0,0 +1,11 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import type { Opencode } from '../client';
+
+export abstract class APIResource {
+ protected _client: Opencode;
+
+ constructor(client: Opencode) {
+ this._client = client;
+ }
+}
diff --git a/packages/sdk/src/core/streaming.ts b/packages/sdk/src/core/streaming.ts
new file mode 100644
index 000000000..33dcfd126
--- /dev/null
+++ b/packages/sdk/src/core/streaming.ts
@@ -0,0 +1,315 @@
+import { OpencodeError } from './error';
+import { type ReadableStream } from '../internal/shim-types';
+import { makeReadableStream } from '../internal/shims';
+import { findDoubleNewlineIndex, LineDecoder } from '../internal/decoders/line';
+import { ReadableStreamToAsyncIterable } from '../internal/shims';
+import { isAbortError } from '../internal/errors';
+import { encodeUTF8 } from '../internal/utils/bytes';
+import { loggerFor } from '../internal/utils/log';
+import type { Opencode } from '../client';
+
+type Bytes = string | ArrayBuffer | Uint8Array | null | undefined;
+
+export type ServerSentEvent = {
+ event: string | null;
+ data: string;
+ raw: string[];
+};
+
+export class Stream<Item> implements AsyncIterable<Item> {
+ controller: AbortController;
+ #client: Opencode | undefined;
+
+ constructor(
+ private iterator: () => AsyncIterator<Item>,
+ controller: AbortController,
+ client?: Opencode,
+ ) {
+ this.controller = controller;
+ this.#client = client;
+ }
+
+ static fromSSEResponse<Item>(
+ response: Response,
+ controller: AbortController,
+ client?: Opencode,
+ ): Stream<Item> {
+ let consumed = false;
+ const logger = client ? loggerFor(client) : console;
+
+ async function* iterator(): AsyncIterator<Item, any, undefined> {
+ if (consumed) {
+ throw new OpencodeError('Cannot iterate over a consumed stream, use `.tee()` to split the stream.');
+ }
+ consumed = true;
+ let done = false;
+ try {
+ for await (const sse of _iterSSEMessages(response, controller)) {
+ try {
+ yield JSON.parse(sse.data);
+ } catch (e) {
+ logger.error(`Could not parse message into JSON:`, sse.data);
+ logger.error(`From chunk:`, sse.raw);
+ throw e;
+ }
+ }
+ done = true;
+ } catch (e) {
+ // If the user calls `stream.controller.abort()`, we should exit without throwing.
+ if (isAbortError(e)) return;
+ throw e;
+ } finally {
+ // If the user `break`s, abort the ongoing request.
+ if (!done) controller.abort();
+ }
+ }
+
+ return new Stream(iterator, controller, client);
+ }
+
+ /**
+ * Generates a Stream from a newline-separated ReadableStream
+ * where each item is a JSON value.
+ */
+ static fromReadableStream<Item>(
+ readableStream: ReadableStream,
+ controller: AbortController,
+ client?: Opencode,
+ ): Stream<Item> {
+ let consumed = false;
+
+ async function* iterLines(): AsyncGenerator<string, void, unknown> {
+ const lineDecoder = new LineDecoder();
+
+ const iter = ReadableStreamToAsyncIterable<Bytes>(readableStream);
+ for await (const chunk of iter) {
+ for (const line of lineDecoder.decode(chunk)) {
+ yield line;
+ }
+ }
+
+ for (const line of lineDecoder.flush()) {
+ yield line;
+ }
+ }
+
+ async function* iterator(): AsyncIterator<Item, any, undefined> {
+ if (consumed) {
+ throw new OpencodeError('Cannot iterate over a consumed stream, use `.tee()` to split the stream.');
+ }
+ consumed = true;
+ let done = false;
+ try {
+ for await (const line of iterLines()) {
+ if (done) continue;
+ if (line) yield JSON.parse(line);
+ }
+ done = true;
+ } catch (e) {
+ // If the user calls `stream.controller.abort()`, we should exit without throwing.
+ if (isAbortError(e)) return;
+ throw e;
+ } finally {
+ // If the user `break`s, abort the ongoing request.
+ if (!done) controller.abort();
+ }
+ }
+
+ return new Stream(iterator, controller, client);
+ }
+
+ [Symbol.asyncIterator](): AsyncIterator<Item> {
+ return this.iterator();
+ }
+
+ /**
+ * Splits the stream into two streams which can be
+ * independently read from at different speeds.
+ */
+ tee(): [Stream<Item>, Stream<Item>] {
+ const left: Array<Promise<IteratorResult<Item>>> = [];
+ const right: Array<Promise<IteratorResult<Item>>> = [];
+ const iterator = this.iterator();
+
+ const teeIterator = (queue: Array<Promise<IteratorResult<Item>>>): AsyncIterator<Item> => {
+ return {
+ next: () => {
+ if (queue.length === 0) {
+ const result = iterator.next();
+ left.push(result);
+ right.push(result);
+ }
+ return queue.shift()!;
+ },
+ };
+ };
+
+ return [
+ new Stream(() => teeIterator(left), this.controller, this.#client),
+ new Stream(() => teeIterator(right), this.controller, this.#client),
+ ];
+ }
+
+ /**
+ * Converts this stream to a newline-separated ReadableStream of
+ * JSON stringified values in the stream
+ * which can be turned back into a Stream with `Stream.fromReadableStream()`.
+ */
+ toReadableStream(): ReadableStream {
+ const self = this;
+ let iter: AsyncIterator<Item>;
+
+ return makeReadableStream({
+ async start() {
+ iter = self[Symbol.asyncIterator]();
+ },
+ async pull(ctrl: any) {
+ try {
+ const { value, done } = await iter.next();
+ if (done) return ctrl.close();
+
+ const bytes = encodeUTF8(JSON.stringify(value) + '\n');
+
+ ctrl.enqueue(bytes);
+ } catch (err) {
+ ctrl.error(err);
+ }
+ },
+ async cancel() {
+ await iter.return?.();
+ },
+ });
+ }
+}
+
+export async function* _iterSSEMessages(
+ response: Response,
+ controller: AbortController,
+): AsyncGenerator<ServerSentEvent, void, unknown> {
+ if (!response.body) {
+ controller.abort();
+ if (
+ typeof (globalThis as any).navigator !== 'undefined' &&
+ (globalThis as any).navigator.product === 'ReactNative'
+ ) {
+ throw new OpencodeError(
+ `The default react-native fetch implementation does not support streaming. Please use expo/fetch: https://docs.expo.dev/versions/latest/sdk/expo/#expofetch-api`,
+ );
+ }
+ throw new OpencodeError(`Attempted to iterate over a response with no body`);
+ }
+
+ const sseDecoder = new SSEDecoder();
+ const lineDecoder = new LineDecoder();
+
+ const iter = ReadableStreamToAsyncIterable<Bytes>(response.body);
+ for await (const sseChunk of iterSSEChunks(iter)) {
+ for (const line of lineDecoder.decode(sseChunk)) {
+ const sse = sseDecoder.decode(line);
+ if (sse) yield sse;
+ }
+ }
+
+ for (const line of lineDecoder.flush()) {
+ const sse = sseDecoder.decode(line);
+ if (sse) yield sse;
+ }
+}
+
+/**
+ * Given an async iterable iterator, iterates over it and yields full
+ * SSE chunks, i.e. yields when a double new-line is encountered.
+ */
+async function* iterSSEChunks(iterator: AsyncIterableIterator<Bytes>): AsyncGenerator<Uint8Array> {
+ let data = new Uint8Array();
+
+ for await (const chunk of iterator) {
+ if (chunk == null) {
+ continue;
+ }
+
+ const binaryChunk =
+ chunk instanceof ArrayBuffer ? new Uint8Array(chunk)
+ : typeof chunk === 'string' ? encodeUTF8(chunk)
+ : chunk;
+
+ let newData = new Uint8Array(data.length + binaryChunk.length);
+ newData.set(data);
+ newData.set(binaryChunk, data.length);
+ data = newData;
+
+ let patternIndex;
+ while ((patternIndex = findDoubleNewlineIndex(data)) !== -1) {
+ yield data.slice(0, patternIndex);
+ data = data.slice(patternIndex);
+ }
+ }
+
+ if (data.length > 0) {
+ yield data;
+ }
+}
+
+class SSEDecoder {
+ private data: string[];
+ private event: string | null;
+ private chunks: string[];
+
+ constructor() {
+ this.event = null;
+ this.data = [];
+ this.chunks = [];
+ }
+
+ decode(line: string) {
+ if (line.endsWith('\r')) {
+ line = line.substring(0, line.length - 1);
+ }
+
+ if (!line) {
+ // empty line and we didn't previously encounter any messages
+ if (!this.event && !this.data.length) return null;
+
+ const sse: ServerSentEvent = {
+ event: this.event,
+ data: this.data.join('\n'),
+ raw: this.chunks,
+ };
+
+ this.event = null;
+ this.data = [];
+ this.chunks = [];
+
+ return sse;
+ }
+
+ this.chunks.push(line);
+
+ if (line.startsWith(':')) {
+ return null;
+ }
+
+ let [fieldname, _, value] = partition(line, ':');
+
+ if (value.startsWith(' ')) {
+ value = value.substring(1);
+ }
+
+ if (fieldname === 'event') {
+ this.event = value;
+ } else if (fieldname === 'data') {
+ this.data.push(value);
+ }
+
+ return null;
+ }
+}
+
+function partition(str: string, delimiter: string): [string, string, string] {
+ const index = str.indexOf(delimiter);
+ if (index !== -1) {
+ return [str.substring(0, index), delimiter, str.substring(index + delimiter.length)];
+ }
+
+ return [str, '', ''];
+}
diff --git a/packages/sdk/src/core/uploads.ts b/packages/sdk/src/core/uploads.ts
new file mode 100644
index 000000000..2882ca6d1
--- /dev/null
+++ b/packages/sdk/src/core/uploads.ts
@@ -0,0 +1,2 @@
+export { type Uploadable } from '../internal/uploads';
+export { toFile, type ToFileInput } from '../internal/to-file';