diff --git a/CONTEXT.md b/CONTEXT.md index faf8ce9d12..e0e898f15a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -55,6 +55,21 @@ Provider-neutral sampling and output controls, partitioned from provider semanti **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. +**OpenCode Client**: +The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers. +_Avoid_: Remote client + +**SDK Contract IR**: +The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter. + +**Embedded OpenCode**: +A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly. +_Avoid_: Local implementation + +**Page**: +A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction. +_Avoid_: Response envelope + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -100,6 +115,47 @@ The host-supplied environment overlay applied by the server when creating a PTY, - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. +- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. +- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. +- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers. +- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property. +- The **OpenCode Client** uses plural consumer-facing capability groups such as `sessions`; internal server identifiers such as `server.session` and `session.get` do not define its public names. +- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server hosts those exact endpoint declarations and code generation consumes them directly. Public endpoints are not duplicated or projected from a separately named internal contract. +- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. +- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. +- Initially, the rich Effect emitter regenerates private executable schemas and `HttpApi` groups from the **SDK Contract IR**, reproducing semantics exactly or rejecting generation. Importing authoritative schemas from a future dependency-leaf public API package remains an alternative emitter strategy, not a prerequisite refactor. +- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. +- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. +- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. +- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division. +- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred. +- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. +- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. +- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. +- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. +- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. +- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. +- Promise and Effect client outputs ship from `@opencode-ai/client` behind isolated root, `/effect`, and `/effect/embedded` exports. The root export has no runtime path to Effect; `/effect` contains only the rich network client and may use Effect as an optional peer dependency; `/effect/embedded` owns the heavy private Core/server dependency closure for the scoped embedded host. The package remains private until the authoritative public `HttpApi` is composed and packaging of embedded dependencies is settled. +- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. +- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. +- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. +- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. +- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. +- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. +- `sessions.list(...)` returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. +- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. +- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. +- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. +- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. +- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? +- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior. +- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics. +- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics. +- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session. +- A future Layer constructor may provide **Embedded OpenCode** using the same scoped creation path; it does not define a second implementation. - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. diff --git a/bun.lock b/bun.lock index ca92b78d53..0535c45cf6 100644 --- a/bun.lock +++ b/bun.lock @@ -109,6 +109,20 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/client": { + "name": "@opencode-ai/client", + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.83", + }, + "optionalPeers": [ + "effect", + ], + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.9", @@ -1786,6 +1800,8 @@ "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000000..d5a6353e7c --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,11 @@ +# @opencode-ai/client + +Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`. + +## Entrypoints + +- `@opencode-ai/client`: zero-Effect Promise client using `fetch`. +- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. +- `@opencode-ai/client/effect/embedded`: scoped embedded OpenCode host backed by Core and the in-memory HTTP router. + +The entry modules are intentionally empty until the authoritative public `HttpApi` is composed. Do not generate clients from the current internal server API. diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000000..4f304b0e19 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/client", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./effect": "./src/effect.ts", + "./effect/embedded": "./src/effect-embedded.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "peerDependencies": { + "effect": "4.0.0-beta.83" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/client/src/effect-embedded.ts b/packages/client/src/effect-embedded.ts new file mode 100644 index 0000000000..614e4f6a15 --- /dev/null +++ b/packages/client/src/effect-embedded.ts @@ -0,0 +1,2 @@ +// Embedded Effect host target. Intentionally empty until the public HttpApi is available. +export {} diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts new file mode 100644 index 0000000000..8ddd141332 --- /dev/null +++ b/packages/client/src/effect.ts @@ -0,0 +1,2 @@ +// Generated Effect network client target. Intentionally empty until the public HttpApi is available. +export {} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000000..daf5eac75b --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,2 @@ +// Generated Promise client target. Intentionally empty until the public HttpApi is available. +export {} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000000..8e4f68a035 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"] + } +} diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md index 80b6c86d24..14a4dd3932 100644 --- a/packages/httpapi-codegen/README.md +++ b/packages/httpapi-codegen/README.md @@ -1,11 +1,19 @@ # @opencode-ai/httpapi-codegen -Build-time source generation for domain-oriented Effect APIs derived from `HttpApi` and Effect Schema contracts. +Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts. The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures. ## Settled rules +- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`. +- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`. +- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source. +- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`. +- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation. +- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`. +- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically. + - Flatten path, query, header, and payload fields into one input object. - Reject duplicate field names across input channels. - Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required. @@ -16,22 +24,16 @@ The package is private while its API is explored. Its tests are the executable s - Expose streaming success as `Stream`, not `Effect`. - Reject schemas whose wire/domain transformation cannot be generated exactly. - Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`. -- Generate only the Effect API initially; Promise runtime ownership, cancellation, and stream adaptation are deferred. - Commit generated source for review; CI regenerates and fails when the worktree changes. - Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator. ## Boundary -This package generates only the remote API derived from `HttpApi`. It does not generate embedded implementations or embedded-only capabilities. The OpenCode integration composes two distinct total objects: - -- A remote object containing the generated HTTP capabilities. -- An embedded object implementing the shared shape against local services and adding embedded-only capabilities. - -The embedded object may be a structural superset of the remote object, but the constructors and concrete result types remain distinct. +This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities. Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy. -The public `generate(Api, { directory })` operation is an Effect requiring `FileSystem`. Internally it composes a pure `compile(Api)` phase with `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`. +The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`. Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links. diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 04f926c8e0..d691b18828 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -1,6 +1,6 @@ import { isAbsolute, join } from "node:path" import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" -import type { HttpRouter } from "effect/unstable/http" +import { HttpMethod, type HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { format } from "prettier" @@ -26,6 +26,10 @@ export type Output = { }> } +export type Contract = { + readonly groups: ReadonlyArray +} + export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { reason: Schema.String, }) { @@ -34,7 +38,7 @@ export class GenerationError extends Schema.TaggedErrorClass()( } } -type Endpoint = { +export type Endpoint = { readonly group: string readonly topLevel: boolean readonly endpoint: HttpApiEndpoint.AnyWithProps @@ -49,7 +53,7 @@ type Endpoint = { readonly successes: ReadonlyArray } -type Group = { +export type Group = { readonly identifier: string readonly module: string readonly endpoints: ReadonlyArray @@ -61,11 +65,13 @@ type Slot = { } const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") -const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) const manifestName = ".httpapi-codegen.json" -export function compile(api: HttpApi.HttpApi): Output { +export function compile( + api: HttpApi.HttpApi, +): Contract { const endpoints: Array = [] const portable = new Map() @@ -167,23 +173,243 @@ export function compile(api: } } return { - operations: endpoints.map((endpoint) => endpoint.operation), + groups, + } +} + +export function emitEffect(contract: Contract): Output { + return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } +} + +export function emitPromise(contract: Contract): Output { + const groups = contract.groups + for (const group of groups) { + for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) + } + return { + operations: operations(groups), files: [ - ...groups.map((group, index) => ({ - path: `${group.module}.ts`, - content: renderGroup(group, index), - })), + { path: "types.ts", content: renderPromiseTypes(groups) }, { path: "client-error.ts", - content: - 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { path: "client.ts", content: renderPromiseClient(groups) }, + { + path: "index.ts", + content: + 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\n', }, - { path: "client.ts", content: renderClient(groups) }, - { path: "index.ts", content: 'export { ClientError } from "./client-error"\nexport { make } from "./client"\n' }, ], } } +function assertPromiseEndpoint(endpoint: Endpoint) { + const name = `${endpoint.group}.${endpoint.endpoint.name}` + const payload = endpoint.payloads[0] + const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast) + if ( + payload !== undefined && + (payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` }) + } + const success = endpoint.successes[0] + if (isStreamSchema(success)) { + if ( + success._tag !== "StreamSse" || + success.sseMode !== "data" || + !SchemaAST.isNever(Schema.toType(success.error).ast) + ) { + throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) + } + } else if ( + !HttpApiSchema.isNoContent(success.ast) && + (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } + for (const error of endpoint.errors) { + if (taggedErrorFields(error) === undefined) { + throw new GenerationError({ reason: `Promise error must be tagged: ${name}` }) + } + if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") { + throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` }) + } + } +} + +function operations(groups: ReadonlyArray) { + return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) +} + +function renderEffectFiles(groups: ReadonlyArray): Output["files"] { + return [ + ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: renderClient(groups) }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderPromiseTypes(groups: ReadonlyArray) { + const types = new Map() + const typeOf = (schema: Schema.Top) => { + const encoded = Schema.toEncoded(schema) + const cached = types.get(encoded.ast) + if (cached !== undefined) return cached + const type = structuralType(encoded) + types.set(encoded.ast, type) + return type + } + const errors = new Map( + groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => + endpoint.errors.flatMap((schema) => { + const tagged = taggedErrorFields(schema) + return tagged === undefined ? [] : [[tagged.tag, tagged] as const] + }), + ), + ), + ) + const errorTypes = Array.from(errors.values()).map((error) => { + const fields = error.fields + .map(([name, schema]) => `readonly ${JSON.stringify(name)}: ${typeOf(schema)}`) + .join("; ") + return `export type ${error.identifier} = { readonly _tag: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && "_tag" in value && value._tag === ${JSON.stringify(error.tag)}` + }) + const operations = groups + .flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + const schemas = { + params: endpoint.params, + query: endpoint.query, + headers: endpoint.headers, + payload: endpoint.payloads[0], + } + const input = endpoint.input + .map((field) => { + const schema = schemas[field.source] + if (schema === undefined) + throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]` + }) + .join("; ") + const successSchema = endpoint.successes[0] + const success = typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamDataSchema(successSchema) + : successSchema.events + : successSchema, + ) + return [ + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, + ] + }), + ) + .join("\n\n") + return [...errorTypes, operations].filter(Boolean).join("\n\n") +} + +function renderPromiseClient(groups: ReadonlyArray) { + const imports = groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + }), + ) + const fields = groups.map((group) => { + const methods = group.endpoints.map((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + const argument = + endpoint.operation.inputMode === "none" + ? "requestOptions?: RequestOptions" + : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input) + const access = (name: string) => `input${endpoint.operation.inputMode === "optional" ? "?." : "."}${name}` + const part = (source: InputField["source"]) => { + const inputs = endpoint.input.filter((field) => field.source === source) + return inputs.length === 0 + ? undefined + : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` + } + const parts = [ + endpoint.query === undefined ? undefined : `query: ${part("query")}`, + endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`, + endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, + ].filter((value): value is string => value !== undefined) + const declaredStatuses = [ + ...new Set( + endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined), + ), + ] + const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` + if (endpoint.operation.success === "stream") { + const success = endpoint.successes[0] + if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { + throw new GenerationError({ + reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return `${JSON.stringify(endpoint.endpoint.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + } + const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" + return `${JSON.stringify(endpoint.endpoint.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + }) + if (group.endpoints[0]?.topLevel) return methods.join(", ") + return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` + }) + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` +} + +function promiseTypePrefix(group: string, endpoint: string) { + return `${identifierPart(group)}${identifierPart(endpoint)}` +} + +function identifierPart(value: string) { + return value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join("") +} + +function structuralType(schema: Schema.Top) { + const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) + if ( + document.artifacts.length > 0 || + document.references.nonRecursives.length > 0 || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + return document.codes[0].Type +} + +function promisePath(path: string, input: ReadonlyArray) { + const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name)) + const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean) + const template = segments + .map((segment) => { + if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") + const name = segment.slice(1) + if (!fields.has(name)) throw new GenerationError({ reason: `Missing path parameter: ${name}` }) + return `\${encodeURIComponent(input.${name})}` + }) + .join("") + return `\`${template}\`` +} + function uniqueModule(base: string, index: number, modules: ReadonlySet) { if (!modules.has(base.toLowerCase())) return base const seed = `${base}-${index}` @@ -357,7 +583,7 @@ export function generate( options: { readonly directory: string }, ): Effect.Effect { return Effect.try({ - try: () => compile(api), + try: () => emitEffect(compile(api)), catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), }).pipe(Effect.flatMap((output) => write(output, options.directory))) } diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 0f457a0f3f..f0ed070905 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -1,16 +1,267 @@ import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" import { format } from "prettier" -import { compile, generate, GenerationError } from "../src" +import { compile as compileContract, emitEffect, emitPromise, generate, GenerationError } from "../src" import { it } from "./effect" -import { Api as FixtureApi } from "./fixture" +import { Api as FixtureApi, Missing } from "./fixture" function api(endpoint: HttpApiEndpoint.Any) { return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint)) } +function compile(source: HttpApi.HttpApi) { + return emitEffect(compileContract(source)) +} + describe("HttpApiCodegen.generate", () => { + test("compiles one contract for Promise and Effect emitters", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + const promise = emitPromise(contract) + const effect = emitEffect(contract) + + expect(promise.operations).toEqual(effect.operations) + expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"]) + const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content + expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`") + expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("emits an optional Promise input when every field is optional", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.Number) }, + success: Schema.Array(Schema.String), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + '"list": (input?: SessionListInput, requestOptions?: RequestOptions)', + ) + }) + + test("rejects Promise transports that are not implemented", () => { + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("text", "/text", { + success: Schema.String.pipe(HttpApiSchema.asText()), + }), + ), + ), + ), + ).toThrow("Unsupported Promise success encoding: session.text") + + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("events", "/events", { + success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }), + }), + ), + ), + ), + ).toThrow("Unsupported Promise stream: session.events") + }) + + test("executes an emitted Promise GET through fetch", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ data: "hello" }) + }, + }) + + expect(await client.session.get({ sessionID: "a/b" })).toBe("hello") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/session/a%2Fb") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("maps an emitted no-content response to undefined", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => new Response(null, { status: 204 }), + }) + + expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("serializes flattened query, header, and JSON payload inputs", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.optional(Schema.Boolean) }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: "admitted" }) + }, + }) + + expect( + await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }), + ).toBe("admitted") + expect(request?.url).toBe("https://example.com/session/session?resume=true") + expect(request?.headers.get("traceID")).toBe("trace") + expect(await request?.json()).toEqual({ prompt: "hello" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("rejects with declared tagged errors and exports a type guard", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }), + }) + + const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause) + expect(error).toEqual({ _tag: "Missing", message: "gone" }) + expect(generated.isMissing(error)).toBeTrue() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("iterates an emitted SSE stream lazily without reconnecting", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("subscribe", "/event", { + query: { after: Schema.optional(Schema.Number) }, + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let requests = 0 + let url: string | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + requests++ + url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"ready"}\r')) + controller.enqueue(encoder.encode("\n\r\n")) + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + const events = client.session.subscribe({ after: 2 }) + + expect(requests).toBe(0) + const received = [] + for await (const event of events) received.push(event) + expect(received).toEqual([{ type: "ready" }]) + expect(requests).toBe(1) + expect(url).toBe("https://example.com/event?after=2") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + test("preserves public group and endpoint identifiers exactly", () => { const output = compile( HttpApi.make("test").add( diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts index 1eb1d2bc10..448db01be8 100644 --- a/packages/httpapi-codegen/test/generated-consumer.ts +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -1,9 +1,9 @@ import { Effect, Stream } from "effect" import { HttpClient } from "effect/unstable/http" -import { ClientError, make } from "./generated" +import { ClientError, OpenCode } from "./generated" import { Missing } from "./fixture" -export const program = make().pipe( +export const program = OpenCode.make().pipe( Effect.map((client) => { const health = client.session.health() const list = client.session.list() diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts index 0eb8005be9..bc0dbc9fa4 100644 --- a/packages/httpapi-codegen/test/generated/index.ts +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -1,2 +1,2 @@ export { ClientError } from "./client-error" -export { make } from "./client" +export * as OpenCode from "./client"