From cdd67cf30fc01ea383ae5a6ff582709f9ee07f5d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 05:08:54 +0200 Subject: [PATCH] feat(sdk): add HttpApi clients and embedded host (#33445) --- .github/workflows/test.yml | 5 + CONTEXT.md | 77 ++ bun.lock | 55 + packages/client/README.md | 27 + packages/client/package.json | 39 + packages/client/script/build.ts | 23 + packages/client/src/contract.ts | 19 + packages/client/src/effect.ts | 12 + .../generated-effect/.httpapi-codegen.json | 5 + .../src/generated-effect/client-error.ts | 5 + .../client/src/generated-effect/client.ts | 164 +++ packages/client/src/generated-effect/index.ts | 2 + .../src/generated/.httpapi-codegen.json | 6 + packages/client/src/generated/client-error.ts | 11 + packages/client/src/generated/client.ts | 349 +++++ packages/client/src/generated/index.ts | 3 + packages/client/src/generated/types.ts | 631 +++++++++ packages/client/src/index.ts | 1 + .../client/test/contract-identity.test.ts | 60 + packages/client/test/effect.test.ts | 98 ++ .../client/test/import-boundaries.test.ts | 68 + packages/client/test/promise.test.ts | 117 ++ packages/client/tsconfig.json | 9 + packages/core/package.json | 1 - packages/core/src/public/agent.ts | 6 - packages/core/src/public/index.ts | 9 - packages/core/src/public/location.ts | 6 - packages/core/src/public/model.ts | 9 - packages/core/src/public/opencode.ts | 76 -- packages/core/src/public/session.ts | 96 -- packages/core/src/public/tool.ts | 17 - packages/core/test/application-tools.test.ts | 2 +- packages/core/test/location-layer.test.ts | 2 +- packages/core/test/public-opencode.test.ts | 76 -- packages/core/test/public-tool.test.ts | 13 - packages/httpapi-codegen/README.md | 42 + packages/httpapi-codegen/package.json | 22 + packages/httpapi-codegen/src/index.ts | 1147 +++++++++++++++++ packages/httpapi-codegen/test/effect.ts | 28 + packages/httpapi-codegen/test/fixture.ts | 45 + .../httpapi-codegen/test/generate.test.ts | 897 +++++++++++++ .../test/generated-consumer.ts | 28 + .../test/generated/client-error.ts | 5 + .../httpapi-codegen/test/generated/client.ts | 16 + .../httpapi-codegen/test/generated/event.ts | 39 + .../httpapi-codegen/test/generated/index.ts | 2 + .../httpapi-codegen/test/generated/session.ts | 96 ++ .../httpapi-codegen/test/generated/system.ts | 25 + packages/httpapi-codegen/test/write.test.ts | 160 +++ packages/httpapi-codegen/tsconfig.json | 8 + packages/sdk-next/README.md | 27 + packages/sdk-next/package.json | 25 + packages/sdk-next/src/index.ts | 16 + packages/sdk-next/src/opencode.ts | 53 + packages/sdk-next/src/tool.ts | 2 + packages/sdk-next/test/embedded.test.ts | 84 ++ .../sdk-next/test/import-boundaries.test.ts | 53 + packages/sdk-next/tsconfig.json | 8 + packages/server/src/routes.ts | 18 +- 59 files changed, 4629 insertions(+), 316 deletions(-) create mode 100644 packages/client/README.md create mode 100644 packages/client/package.json create mode 100644 packages/client/script/build.ts create mode 100644 packages/client/src/contract.ts create mode 100644 packages/client/src/effect.ts create mode 100644 packages/client/src/generated-effect/.httpapi-codegen.json create mode 100644 packages/client/src/generated-effect/client-error.ts create mode 100644 packages/client/src/generated-effect/client.ts create mode 100644 packages/client/src/generated-effect/index.ts create mode 100644 packages/client/src/generated/.httpapi-codegen.json create mode 100644 packages/client/src/generated/client-error.ts create mode 100644 packages/client/src/generated/client.ts create mode 100644 packages/client/src/generated/index.ts create mode 100644 packages/client/src/generated/types.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/test/contract-identity.test.ts create mode 100644 packages/client/test/effect.test.ts create mode 100644 packages/client/test/import-boundaries.test.ts create mode 100644 packages/client/test/promise.test.ts create mode 100644 packages/client/tsconfig.json delete mode 100644 packages/core/src/public/agent.ts delete mode 100644 packages/core/src/public/index.ts delete mode 100644 packages/core/src/public/location.ts delete mode 100644 packages/core/src/public/model.ts delete mode 100644 packages/core/src/public/opencode.ts delete mode 100644 packages/core/src/public/session.ts delete mode 100644 packages/core/src/public/tool.ts delete mode 100644 packages/core/test/public-opencode.test.ts delete mode 100644 packages/core/test/public-tool.test.ts create mode 100644 packages/httpapi-codegen/README.md create mode 100644 packages/httpapi-codegen/package.json create mode 100644 packages/httpapi-codegen/src/index.ts create mode 100644 packages/httpapi-codegen/test/effect.ts create mode 100644 packages/httpapi-codegen/test/fixture.ts create mode 100644 packages/httpapi-codegen/test/generate.test.ts create mode 100644 packages/httpapi-codegen/test/generated-consumer.ts create mode 100644 packages/httpapi-codegen/test/generated/client-error.ts create mode 100644 packages/httpapi-codegen/test/generated/client.ts create mode 100644 packages/httpapi-codegen/test/generated/event.ts create mode 100644 packages/httpapi-codegen/test/generated/index.ts create mode 100644 packages/httpapi-codegen/test/generated/session.ts create mode 100644 packages/httpapi-codegen/test/generated/system.ts create mode 100644 packages/httpapi-codegen/test/write.test.ts create mode 100644 packages/httpapi-codegen/tsconfig.json create mode 100644 packages/sdk-next/README.md create mode 100644 packages/sdk-next/package.json create mode 100644 packages/sdk-next/src/index.ts create mode 100644 packages/sdk-next/src/opencode.ts create mode 100644 packages/sdk-next/src/tool.ts create mode 100644 packages/sdk-next/test/embedded.test.ts create mode 100644 packages/sdk-next/test/import-boundaries.test.ts create mode 100644 packages/sdk-next/tsconfig.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13a76c0a1f..c69de1d93b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,6 +69,11 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' working-directory: packages/opencode diff --git a/CONTEXT.md b/CONTEXT.md index 4b5fabd93c..97919b63f6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -60,6 +60,21 @@ A temporary file created under OpenCode's shared tool-output directory to retain **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**. @@ -108,6 +123,51 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - 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 beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names. +- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server. +- 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. +- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR. +- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime. +- 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 network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol. +- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client. +- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors. +- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names. +- 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. +- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. +- 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. +- The **Embedded OpenCode** Layer delegates to 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. @@ -124,6 +184,23 @@ The host-supplied environment overlay applied by the server when creating a PTY, - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. +## Client contract architecture + +Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server. + +Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases. + +Before stabilizing the client API: + +- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM. +- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API. +- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract. +- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier. +- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change. +- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests. +- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly. +- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client. + ## Example dialogue > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?" diff --git a/bun.lock b/bun.lock index 580e4f9625..78f9fc99bc 100644 --- a/bun.lock +++ b/bun.lock @@ -110,6 +110,29 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/client": { + "name": "@opencode-ai/client", + "dependencies": { + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/httpapi-codegen": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.83", + }, + "optionalPeers": [ + "effect", + ], + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.10", @@ -479,6 +502,18 @@ "effect": "4.0.0-beta.83", }, }, + "packages/httpapi-codegen": { + "name": "@opencode-ai/httpapi-codegen", + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/llm": { "name": "@opencode-ai/llm", "version": "1.17.10", @@ -690,6 +725,20 @@ "@types/semver": "^7.5.8", }, }, + "packages/sdk-next": { + "name": "@opencode-ai/sdk-next", + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/sdk/js": { "name": "@opencode-ai/sdk", "version": "1.17.10", @@ -1847,6 +1896,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"], @@ -1873,6 +1924,8 @@ "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], @@ -1885,6 +1938,8 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000000..fc16795075 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,27 @@ +# @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`. + +The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift. + +The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API. + +The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs. + +Effect consumers construct canonical decoded inputs: + +```ts +import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect" + +const client = yield * OpenCode.make({ baseUrl: "https://opencode.example" }) +yield * + client.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }), + }) +yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) }) +``` diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000000..4f2445ca9d --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,39 @@ +{ + "$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" + }, + "scripts": { + "generate": "bun run script/build.ts", + "check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect", + "test": "bun test --timeout 5000", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "@opencode-ai/protocol": "workspace:*" + }, + "peerDependencies": { + "effect": "4.0.0-beta.83" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/httpapi-codegen": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:" + } +} diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts new file mode 100644 index 0000000000..35ce8387a8 --- /dev/null +++ b/packages/client/script/build.ts @@ -0,0 +1,23 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" +import { Api } from "@opencode-ai/server/api" +import { Effect } from "effect" +import { HttpApi } from "effect/unstable/httpapi" +import { fileURLToPath } from "url" + +const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), { + groupNames: { "server.session": "sessions" }, +}) + +await Effect.runPromise( + Effect.all( + [ + write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))), + write( + emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }), + fileURLToPath(new URL("../src/generated-effect", import.meta.url)), + ), + ], + { concurrency: 2, discard: true }, + ).pipe(Effect.provide(NodeFileSystem.layer)), +) diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts new file mode 100644 index 0000000000..2190c130ca --- /dev/null +++ b/packages/client/src/contract.ts @@ -0,0 +1,19 @@ +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/LocationMiddleware", +) {} + +class SessionLocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/SessionLocationMiddleware", + { error: [InvalidRequestError, SessionNotFoundError] }, +) {} + +const Api = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) + +export const SessionGroup = Api.groups["server.session"] diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts new file mode 100644 index 0000000000..6c0aca564e --- /dev/null +++ b/packages/client/src/effect.ts @@ -0,0 +1,12 @@ +// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import +// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. +export * from "./generated-effect/index" +export { Agent } from "@opencode-ai/schema/agent" +export { Location } from "@opencode-ai/schema/location" +export { Model } from "@opencode-ai/schema/model" +export { Provider } from "@opencode-ai/schema/provider" +export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" +export { Session } from "@opencode-ai/schema/session" +export { SessionInput } from "@opencode-ai/schema/session-input" +export { SessionMessage } from "@opencode-ai/schema/session-message" +export { Prompt } from "@opencode-ai/schema/prompt" diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json new file mode 100644 index 0000000000..958eb566db --- /dev/null +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -0,0 +1,5 @@ +[ + "client-error.ts", + "client.ts", + "index.ts" +] diff --git a/packages/client/src/generated-effect/client-error.ts b/packages/client/src/generated-effect/client-error.ts new file mode 100644 index 0000000000..bcc65d9bdd --- /dev/null +++ b/packages/client/src/generated-effect/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts new file mode 100644 index 0000000000..9088c533fa --- /dev/null +++ b/packages/client/src/generated-effect/client.ts @@ -0,0 +1,164 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { SessionGroup } from "../contract" +import { ClientError } from "./client-error" + +const Api = HttpApi.make("generated").add(SessionGroup) + +type RawClient = HttpApiClient.ForApi + +const mapClientError = (error: E) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : error + +type Endpoint0_0Request = Parameters[0] +type Endpoint0_0Input = { + readonly workspace?: Endpoint0_0Request["query"]["workspace"] + readonly limit?: Endpoint0_0Request["query"]["limit"] + readonly order?: Endpoint0_0Request["query"]["order"] + readonly search?: Endpoint0_0Request["query"]["search"] + readonly directory?: Endpoint0_0Request["query"]["directory"] + readonly project?: Endpoint0_0Request["query"]["project"] + readonly subpath?: Endpoint0_0Request["query"]["subpath"] + readonly cursor?: Endpoint0_0Request["query"]["cursor"] +} +const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) => + raw["session.list"]({ + query: { + workspace: input?.workspace, + limit: input?.limit, + order: input?.order, + search: input?.search, + directory: input?.directory, + project: input?.project, + subpath: input?.subpath, + cursor: input?.cursor, + }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_1Request = Parameters[0] +type Endpoint0_1Input = { + readonly id?: Endpoint0_1Request["payload"]["id"] + readonly agent?: Endpoint0_1Request["payload"]["agent"] + readonly model?: Endpoint0_1Request["payload"]["model"] + readonly location?: Endpoint0_1Request["payload"]["location"] +} +const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) => + raw["session.create"]({ + payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint0_2Request = Parameters[0] +type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] } +const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) => + raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint0_3Request = Parameters[0] +type Endpoint0_3Input = { + readonly sessionID: Endpoint0_3Request["params"]["sessionID"] + readonly agent: Endpoint0_3Request["payload"]["agent"] +} +const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) => + raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint0_4Request = Parameters[0] +type Endpoint0_4Input = { + readonly sessionID: Endpoint0_4Request["params"]["sessionID"] + readonly model: Endpoint0_4Request["payload"]["model"] +} +const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => + raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint0_5Request = Parameters[0] +type Endpoint0_5Input = { + readonly sessionID: Endpoint0_5Request["params"]["sessionID"] + readonly id?: Endpoint0_5Request["payload"]["id"] + readonly prompt: Endpoint0_5Request["payload"]["prompt"] + readonly delivery?: Endpoint0_5Request["payload"]["delivery"] + readonly resume?: Endpoint0_5Request["payload"]["resume"] +} +const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => + raw["session.prompt"]({ + params: { sessionID: input.sessionID }, + payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint0_6Request = Parameters[0] +type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] } +const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) => + raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_7Request = Parameters[0] +type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] } +const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) => + raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_8Request = Parameters[0] +type Endpoint0_8Input = { + readonly sessionID: Endpoint0_8Request["params"]["sessionID"] + readonly messageID: Endpoint0_8Request["payload"]["messageID"] + readonly files?: Endpoint0_8Request["payload"]["files"] +} +const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) => + raw["session.revert.stage"]({ + params: { sessionID: input.sessionID }, + payload: { messageID: input.messageID, files: input.files }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint0_9Request = Parameters[0] +type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] } +const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) => + raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_10Request = Parameters[0] +type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] } +const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) => + raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_11Request = Parameters[0] +type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] } +const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) => + raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const adaptGroup0 = (raw: RawClient["server.session"]) => ({ + list: Endpoint0_0(raw), + create: Endpoint0_1(raw), + get: Endpoint0_2(raw), + switchAgent: Endpoint0_3(raw), + switchModel: Endpoint0_4(raw), + prompt: Endpoint0_5(raw), + compact: Endpoint0_6(raw), + wait: Endpoint0_7(raw), + stage: Endpoint0_8(raw), + clear: Endpoint0_9(raw), + commit: Endpoint0_10(raw), + context: Endpoint0_11(raw), +}) + +const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) diff --git a/packages/client/src/generated-effect/index.ts b/packages/client/src/generated-effect/index.ts new file mode 100644 index 0000000000..bc0dbc9fa4 --- /dev/null +++ b/packages/client/src/generated-effect/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json new file mode 100644 index 0000000000..25700fc72d --- /dev/null +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -0,0 +1,6 @@ +[ + "client-error.ts", + "client.ts", + "index.ts", + "types.ts" +] diff --git a/packages/client/src/generated/client-error.ts b/packages/client/src/generated/client-error.ts new file mode 100644 index 0000000000..c278f0ddc8 --- /dev/null +++ b/packages/client/src/generated/client-error.ts @@ -0,0 +1,11 @@ +export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" + +export class ClientError extends Error { + override readonly name = "ClientError" + constructor( + readonly reason: ClientErrorReason, + options?: ErrorOptions, + ) { + super(reason, options) + } +} diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts new file mode 100644 index 0000000000..781ba9730c --- /dev/null +++ b/packages/client/src/generated/client.ts @@ -0,0 +1,349 @@ +import type { + SessionsListInput, + SessionsListOutput, + SessionsCreateInput, + SessionsCreateOutput, + SessionsGetInput, + SessionsGetOutput, + SessionsSwitchAgentInput, + SessionsSwitchAgentOutput, + SessionsSwitchModelInput, + SessionsSwitchModelOutput, + SessionsPromptInput, + SessionsPromptOutput, + SessionsCompactInput, + SessionsCompactOutput, + SessionsWaitInput, + SessionsWaitOutput, + SessionsStageInput, + SessionsStageOutput, + SessionsClearInput, + SessionsClearOutput, + SessionsCommitInput, + SessionsCommitOutput, + SessionsContextInput, + SessionsContextOutput, +} from "./types" +import { ClientError } from "./client-error" + +export interface ClientOptions { + readonly baseUrl: string + readonly fetch?: typeof globalThis.fetch + readonly headers?: HeadersInit +} + +export interface RequestOptions { + readonly signal?: AbortSignal + readonly headers?: HeadersInit +} + +interface RequestDescriptor { + readonly method: string + readonly path: string + readonly query?: Record + readonly headers?: Record + readonly body?: unknown + readonly successStatus: number + readonly declaredStatuses: ReadonlyArray + readonly empty: boolean +} + +export function make(options: ClientOptions) { + const fetch = options.fetch ?? globalThis.fetch + + const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + const url = new URL(descriptor.path, options.baseUrl) + for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value) + const headers = new Headers(options.headers) + for (const [key, value] of Object.entries(descriptor.headers ?? {})) { + if (value !== undefined && value !== null) headers.set(key, String(value)) + } + for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value) + if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") + return { + url, + init: { + method: descriptor.method, + signal: requestOptions?.signal, + headers, + body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body), + } satisfies RequestInit, + } + } + + const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + try { + const prepared = prepare(descriptor, requestOptions) + return await fetch(prepared.url, prepared.init) + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + } + + const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => { + if (descriptor.declaredStatuses.includes(response.status)) throw await json(response) + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnexpectedStatus", { cause: { status: response.status } }) + } + + const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) return responseError(response, descriptor) + if (descriptor.empty) { + try { + await response.body?.cancel() + } catch {} + return undefined as A + } + return (await json(response)) as A + } + + const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) await responseError(response, descriptor) + if (!isContentType(response, "text/event-stream")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + if (response.body === null) throw new ClientError("MalformedResponse") + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + let next + try { + next = await reader.read() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + buffer += decoder.decode(next.value, { stream: !next.done }) + if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse") + const trailingCarriageReturn = !next.done && buffer.endsWith("\r") + if (trailingCarriageReturn) buffer = buffer.slice(0, -1) + buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n") + if (trailingCarriageReturn) buffer += "\r" + if (next.done && buffer !== "") buffer += "\n\n" + let boundary = buffer.indexOf("\n\n") + while (boundary >= 0) { + const block = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + const data = block + .split("\n") + .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : [])) + .join("\n") + if (data !== "") { + try { + yield JSON.parse(data) as A + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } + } + boundary = buffer.indexOf("\n\n") + } + if (next.done) return + } + } finally { + try { + await reader.cancel() + } catch {} + reader.releaseLock() + } + }, + }) + + return { + sessions: { + list: (input?: SessionsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session`, + query: { + workspace: input?.workspace, + limit: input?.limit, + order: input?.order, + search: input?.search, + directory: input?.directory, + project: input?.project, + subpath: input?.subpath, + cursor: input?.cursor, + }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsCreateOutput }>( + { + method: "POST", + path: `/api/session`, + body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: SessionsGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, + body: { agent: input.agent }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, + body: { model: input.model }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsPromptOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, + body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, + successStatus: 200, + declaredStatuses: [409, 404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), + wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), + stage: (input: SessionsStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsStageOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, + body: { messageID: input.messageID, files: input.files }, + successStatus: 200, + declaredStatuses: [404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + clear: (input: SessionsClearInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, + successStatus: 204, + declaredStatuses: [404, 500, 400, 401], + empty: true, + }, + requestOptions, + ), + commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + context: (input: SessionsContextInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsContextOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/context`, + successStatus: 200, + declaredStatuses: [404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + }, + } +} + +function appendQuery(params: URLSearchParams, key: string, value: unknown): void { + if (value === undefined || value === null) return + if (Array.isArray(value)) { + for (const item of value) appendQuery(params, key, item) + return + } + if (typeof value === "object") { + for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item) + return + } + params.append(key, String(value)) +} + +async function json(response: Response): Promise { + if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + let text: string + try { + text = await response.text() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + if (text === "") throw new ClientError("MalformedResponse") + try { + return JSON.parse(text) + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } +} + +function isContentType(response: Response, expected: string) { + return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected +} diff --git a/packages/client/src/generated/index.ts b/packages/client/src/generated/index.ts new file mode 100644 index 0000000000..2570372cf8 --- /dev/null +++ b/packages/client/src/generated/index.ts @@ -0,0 +1,3 @@ +export { ClientError, type ClientErrorReason } from "./client-error" +export * as OpenCode from "./client" +export * from "./types" diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts new file mode 100644 index 0000000000..ac888719f4 --- /dev/null +++ b/packages/client/src/generated/types.ts @@ -0,0 +1,631 @@ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + +export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string } +export const isInvalidCursorError = (value: unknown): value is InvalidCursorError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError" + +export type InvalidRequestError = { + readonly _tag: "InvalidRequestError" + readonly message: string + readonly kind?: string | undefined + readonly field?: string | undefined +} +export const isInvalidRequestError = (value: unknown): value is InvalidRequestError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError" + +export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string } +export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError" + +export type SessionNotFoundError = { + readonly _tag: "SessionNotFoundError" + readonly sessionID: string + readonly message: string +} +export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError" + +export type ConflictError = { + readonly _tag: "ConflictError" + readonly message: string + readonly resource?: string | undefined +} +export const isConflictError = (value: unknown): value is ConflictError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError" + +export type ServiceUnavailableError = { + readonly _tag: "ServiceUnavailableError" + readonly message: string + readonly service?: string | undefined +} +export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError" + +export type MessageNotFoundError = { + readonly _tag: "MessageNotFoundError" + readonly sessionID: string + readonly messageID: string + readonly message: string +} +export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "MessageNotFoundError" + +export type UnknownError = { + readonly _tag: "UnknownError" + readonly message: string + readonly ref?: string | undefined +} +export const isUnknownError = (value: unknown): value is UnknownError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError" + +export type SessionsListInput = { + readonly workspace?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["workspace"] + readonly limit?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["limit"] + readonly order?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["order"] + readonly search?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["search"] + readonly directory?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["directory"] + readonly project?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["project"] + readonly subpath?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["subpath"] + readonly cursor?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["cursor"] +} + +export type SessionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string | null | null } + readonly subpath?: string | null + readonly revert?: { + readonly messageID: string + readonly partID?: string | null + readonly snapshot?: string | null + readonly diff?: string | null + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> | null + } | null + }> + readonly cursor: { readonly previous?: string | null; readonly next?: string | null } +} + +export type SessionsCreateInput = { + readonly id?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + }["id"] + readonly agent?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + }["agent"] + readonly model?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + }["model"] + readonly location?: { + readonly id?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null + }["location"] +} + +export type SessionsCreateOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string | null | null } + readonly subpath?: string | null + readonly revert?: { + readonly messageID: string + readonly partID?: string | null + readonly snapshot?: string | null + readonly diff?: string | null + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> | null + } | null + } +}["data"] + +export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsGetOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string | null | null } + readonly subpath?: string | null + readonly revert?: { + readonly messageID: string + readonly partID?: string | null + readonly snapshot?: string | null + readonly diff?: string | null + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> | null + } | null + } +}["data"] + +export type SessionsSwitchAgentInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly agent: { readonly agent: string }["agent"] +} + +export type SessionsSwitchAgentOutput = void + +export type SessionsSwitchModelInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly model: { + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + }["model"] +} + +export type SessionsSwitchModelOutput = void + +export type SessionsPromptInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["id"] + readonly prompt: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["prompt"] + readonly delivery?: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["delivery"] + readonly resume?: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["resume"] +} + +export type SessionsPromptOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | null + readonly description?: string | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + } + readonly delivery: "steer" | "queue" + readonly timeCreated: number + readonly promotedSeq?: number | null + } +}["data"] + +export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsCompactOutput = void + +export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsWaitOutput = void + +export type SessionsStageInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] + readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] +} + +export type SessionsStageOutput = { + readonly data: { + readonly messageID: string + readonly partID?: string | undefined + readonly snapshot?: string | undefined + readonly diff?: string | undefined + readonly files?: + | ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + | undefined + } +}["data"] + +export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsClearOutput = void + +export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsCommitOutput = void + +export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsContextOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | null + readonly description?: string | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number; readonly completed?: number | null } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number; readonly completed?: number | null } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + } | null + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: any } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | null + readonly description?: string | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + readonly outputPaths?: ReadonlyArray | null + readonly structured: { readonly [x: string]: any } + readonly result?: JsonValue | null + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + readonly structured: { readonly [x: string]: any } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue | null + } + readonly time: { + readonly created: number + readonly ran?: number | null + readonly completed?: number | null + readonly pruned?: number | null + } + } + > + readonly snapshot?: { + readonly start?: string | null + readonly end?: string | null + readonly files?: ReadonlyArray | null + } | null + readonly finish?: string | null + readonly cost?: number | null + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } | null + readonly error?: { readonly type: "unknown"; readonly message: string } | null + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + } + > +}["data"] diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000000..92e36b1c60 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1 @@ +export * from "./generated/index" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts new file mode 100644 index 0000000000..8c9ca70e2d --- /dev/null +++ b/packages/client/test/contract-identity.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Location as CoreLocation } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input" +import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" +import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt" +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Model } from "@opencode-ai/schema/model" +import { Project } from "@opencode-ai/schema/project" +import { Provider } from "@opencode-ai/schema/provider" +import { Prompt } from "@opencode-ai/schema/prompt" +import { Session } from "@opencode-ai/schema/session" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Api } from "@opencode-ai/server/api" +import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" +import { HttpApi } from "effect/unstable/httpapi" +import { SessionGroup } from "../src/contract" + +test("Core and Server reuse the authoritative Schema and Protocol values", () => { + expect(AgentV2.ID).toBe(Agent.ID) + expect(CoreLocation.Ref).toBe(Location.Ref) + expect(ModelV2.Ref).toBe(Model.Ref) + expect(SessionV2.Info).toBe(Session.Info) + expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) + expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) + expect(CorePrompt).toBe(Prompt) + expect(Api.groups["server.session"].identifier).toBe("server.session") + expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier) + expect(Session.ID.create()).toStartWith("ses_") + expect(Project.ID.global).toBe("global") + expect(Provider.ID.anthropic).toBe("anthropic") + expect(Workspace.ID.create()).toStartWith("wrk_") +}) + +test("client and Server Session contracts generate identically", () => { + const options = { groupNames: { "server.session": "sessions" } } + const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options) + const client = compile(HttpApi.make("client").add(SessionGroup), options) + + expect(emitPromise(client)).toEqual(emitPromise(server)) +}) + +test("shared DTO schemas construct and decode plain objects", () => { + const made = Prompt.make({ text: "hello" }) + const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) + const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) + + expect(Object.getPrototypeOf(made)).toBe(Object.prototype) + expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) + expect(Object.getPrototypeOf(content)).toBe(Object.prototype) + expect(Prompt.ast.annotations?.identifier).toBe("Prompt") + expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text") + expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText) +}) diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts new file mode 100644 index 0000000000..5613c3ce77 --- /dev/null +++ b/packages/client/test/effect.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test" +import { DateTime, Effect } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect" + +test("sessions.get returns the decoded Effect projection", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))), + ) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") }) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) +}) + +test("session methods retain decoded Effect inputs and outputs", async () => { + const httpClient = HttpClient.make((request) => { + const url = request.url + if (url.includes("/prompt")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) + } + if (url.includes("/context")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) + } + if (request.method === "POST" && url.endsWith("/api/session")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) + } + if (request.method === "POST") { + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + } + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })), + ) + }) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + const page = yield* client.sessions.list({ limit: 10 }) + const created = yield* client.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), + }) + yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) + yield* client.sessions.switchModel({ + sessionID: Session.ID.make("ses_test"), + model: Model.Ref.make({ id: "claude", providerID: "anthropic" }), + }) + const admitted = yield* client.sessions.prompt({ + sessionID: Session.ID.make("ses_test"), + prompt: Prompt.make({ text: "Hello" }), + resume: false, + }) + yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) + yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) + const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) + return { page, created, admitted, context } + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) + expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) + expect(result.created.id).toBe("ses_test") + expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) + expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) + expect(result.context).toEqual([]) +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts new file mode 100644 index 0000000000..4875a3a5dc --- /dev/null +++ b/packages/client/test/import-boundaries.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { realpathSync } from "node:fs" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect")) +const schema = resolve(import.meta.dir, "../../schema") +const protocol = resolve(import.meta.dir, "../../protocol") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +describe("public import boundaries", () => { + test("isolates each public entrypoint", async () => { + const root = await bundleInputs("@opencode-ai/client", "browser") + + expect(within(root, effect)).toEqual([]) + expect(within(root, schema)).toEqual([]) + expect(within(root, protocol)).toEqual([]) + expect(within(root, core)).toEqual([]) + expect(within(root, server)).toEqual([]) + + const network = await bundleInputs("@opencode-ai/client/effect", "browser") + + expect(within(network, effect).length).toBeGreaterThan(0) + expect(within(network, schema).length).toBeGreaterThan(0) + expect(within(network, protocol).length).toBeGreaterThan(0) + expect(within(network, core)).toEqual([]) + expect(within(network, server)).toEqual([]) + }) +}) + +async function bundleInputs(specifier: string, target: "browser" | "bun") { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`) + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + `--target=${target}`, + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts new file mode 100644 index 0000000000..a7fac9780f --- /dev/null +++ b/packages/client/test/promise.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test" +import { isUnauthorizedError, OpenCode } from "../src" + +test("sessions.get returns the wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe( + "http://localhost:3000/api/session/ses_test", + ) + return Response.json(session) + }, + }) + + const result = await client.sessions.get({ sessionID: "ses_test" }) + + expect(result.time.created).toBe(1_717_171_717_000) +}) + +test("session methods use the public HTTP contract", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ url, init }) + if (url.includes("/prompt")) return Response.json(admission) + if (url.includes("/context")) return Response.json({ data: [] }) + if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) + if (init?.method === "POST") return new Response(null, { status: 204 }) + return Response.json({ data: [session.data], cursor: { next: "next" } }) + }, + }) + + const page = await client.sessions.list({ limit: "10", order: "desc" }) + const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) + await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) + await client.sessions.switchModel({ + sessionID: "ses_test", + model: { id: "claude", providerID: "anthropic" }, + }) + const admitted = await client.sessions.prompt({ + sessionID: "ses_test", + prompt: { text: "Hello" }, + resume: false, + }) + await client.sessions.compact({ sessionID: "ses_test" }) + await client.sessions.wait({ sessionID: "ses_test" }) + const context = await client.sessions.context({ sessionID: "ses_test" }) + + expect(page.cursor.next).toBe("next") + expect(created.id).toBe("ses_test") + expect(admitted.id).toBe("msg_test") + expect(context).toEqual([]) + expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ + ["GET", "http://localhost:3000/api/session?limit=10&order=desc"], + ["POST", "http://localhost:3000/api/session"], + ["POST", "http://localhost:3000/api/session/ses_test/agent"], + ["POST", "http://localhost:3000/api/session/ses_test/model"], + ["POST", "http://localhost:3000/api/session/ses_test/prompt"], + ["POST", "http://localhost:3000/api/session/ses_test/compact"], + ["POST", "http://localhost:3000/api/session/ses_test/wait"], + ["GET", "http://localhost:3000/api/session/ses_test/context"], + ]) + const body = requests[4]?.init?.body + if (typeof body !== "string") throw new Error("Expected JSON request body") + expect(JSON.parse(body)).toEqual({ + prompt: { text: "Hello" }, + resume: false, + }) +}) + +test("middleware errors remain declared client errors", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }), + }) + + try { + await client.sessions.create({}) + throw new Error("Expected request to fail") + } catch (error) { + expect(isUnauthorizedError(error)).toBe(true) + } +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000000..47fc90bc55 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + }, + "include": ["src"] +} diff --git a/packages/core/package.json b/packages/core/package.json index c177d94018..19854c7a62 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,6 @@ "opencode": "./bin/opencode" }, "exports": { - "./public": "./src/public/index.ts", "./session/runner": "./src/session/runner/index.ts", "./system-context": "./src/system-context/index.ts", "./*": "./src/*.ts" diff --git a/packages/core/src/public/agent.ts b/packages/core/src/public/agent.ts deleted file mode 100644 index ade2096f89..0000000000 --- a/packages/core/src/public/agent.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Agent from "./agent" - -import { AgentV2 } from "../agent" - -export const ID = AgentV2.ID -export type ID = AgentV2.ID diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts deleted file mode 100644 index 2229039b9a..0000000000 --- a/packages/core/src/public/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */ -export { Agent } from "./agent" -export { Model } from "./model" -export { OpenCode } from "./opencode" -export { Session } from "./session" -export { Tool } from "./tool" -export { Location } from "./location" -export { Prompt } from "../session/prompt" -export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/location.ts b/packages/core/src/public/location.ts deleted file mode 100644 index aab15181d1..0000000000 --- a/packages/core/src/public/location.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Location from "./location" - -import { Location } from "../location" - -export const Ref = Location.Ref -export type Ref = Location.Ref diff --git a/packages/core/src/public/model.ts b/packages/core/src/public/model.ts deleted file mode 100644 index ab92b8dfe7..0000000000 --- a/packages/core/src/public/model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * as Model from "./model" - -import { ModelV2 } from "../model" - -export const ID = ModelV2.ID -export type ID = ModelV2.ID - -export const Ref = ModelV2.Ref -export type Ref = ModelV2.Ref diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts deleted file mode 100644 index 7d32556340..0000000000 --- a/packages/core/src/public/opencode.ts +++ /dev/null @@ -1,76 +0,0 @@ -export * as OpenCode from "./opencode" - -import { Context, Effect, Layer } from "effect" -import { Database } from "../database/database" -import { EventV2 } from "../event" -import { LocationServiceMap } from "../location-layer" -import { ProjectV2 } from "../project" -import { SessionV2 } from "../session" -import * as SessionExecutionLocal from "../session/execution/local" -import { SessionProjector } from "../session/projector" -import { SessionStore } from "../session/store" -import { ApplicationTools } from "../tool/application-tools" -import { Session } from "./session" -import { Tool } from "./tool" - -export interface Interface { - readonly sessions: Session.Interface - readonly tools: Tool.Interface -} - -/** Intentional public native API for Effect applications embedding OpenCode. */ -export class Service extends Context.Service()("@opencode/public/OpenCode") {} - -const SessionsLayer = SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))), - Layer.orDie, -) -// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* SessionV2.Service - const tools = yield* ApplicationTools.Service - return Service.of({ - tools: { register: tools.register }, - sessions: { - create: (input) => - sessions.create({ - id: input.id, - agent: input.agent, - model: input.model, - location: input.location, - }), - get: sessions.get, - list: sessions.list, - switchModel: sessions.switchModel, - interrupt: sessions.interrupt, - prompt: (input) => - sessions.prompt({ - id: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, - }), - messages: (input) => - sessions.messages({ - sessionID: input.sessionID, - limit: input.limit, - order: input.order, - cursor: input.cursor, - }), - message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), - context: sessions.context, - events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), - }, - }) - }), -).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer))) - -// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts deleted file mode 100644 index 212583b559..0000000000 --- a/packages/core/src/public/session.ts +++ /dev/null @@ -1,96 +0,0 @@ -export * as Session from "./session" - -import { Effect, Stream } from "effect" -import { SessionV2 } from "../session" -import { MessageDecodeError } from "../session/error" -import { SessionEvent } from "../session/event" -import { SessionInput } from "../session/input" -import { SessionMessage } from "../session/message" -import { Prompt } from "../session/prompt" -import { Agent } from "./agent" -import { Location } from "./location" -import { Model } from "./model" - -export const ID = SessionV2.ID -export type ID = SessionV2.ID - -export const Info = SessionV2.Info -export type Info = SessionV2.Info - -export const MessageID = SessionMessage.ID -export type MessageID = SessionMessage.ID - -export const Message = SessionMessage.Message -export type Message = SessionMessage.Message - -export const Admission = SessionInput.Admitted -export type Admission = SessionInput.Admitted - -export const Delivery = SessionInput.Delivery -export type Delivery = SessionInput.Delivery - -export const ListInput = SessionV2.ListInput -export type ListInput = SessionV2.ListInput - -export type Event = SessionEvent.DurableEvent - -export const NotFoundError = SessionV2.NotFoundError -export type NotFoundError = SessionV2.NotFoundError - -export const PromptConflictError = SessionV2.PromptConflictError -export type PromptConflictError = SessionV2.PromptConflictError - -export { MessageDecodeError } - -export interface CreateInput { - readonly id?: ID - readonly agent?: Agent.ID - readonly model?: Model.Ref - readonly location: Location.Ref -} - -export interface PromptInput { - readonly id?: MessageID - readonly sessionID: ID - readonly prompt: Prompt - readonly delivery?: Delivery -} - -export interface SwitchModelInput { - readonly sessionID: ID - readonly model: Model.Ref -} - -export interface MessagesInput { - readonly sessionID: ID - readonly limit?: number - readonly order?: "asc" | "desc" - readonly cursor?: { - readonly id: MessageID - readonly direction: "previous" | "next" - } -} - -export interface MessageInput { - readonly sessionID: ID - readonly messageID: MessageID -} - -export interface EventsInput { - readonly sessionID: ID - readonly after?: number -} - -export interface Interface { - readonly create: (input: CreateInput) => Effect.Effect - readonly get: (sessionID: ID) => Effect.Effect - readonly list: (input?: ListInput) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: (input: SwitchModelInput) => Effect.Effect - /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ - readonly interrupt: (sessionID: ID) => Effect.Effect - readonly messages: (input: MessagesInput) => Effect.Effect - readonly message: (input: MessageInput) => Effect.Effect - readonly context: (sessionID: ID) => Effect.Effect - readonly events: (input: EventsInput) => Stream.Stream -} diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts deleted file mode 100644 index 97b436fed9..0000000000 --- a/packages/core/src/public/tool.ts +++ /dev/null @@ -1,17 +0,0 @@ -export * as Tool from "./tool" - -import { Effect, Scope } from "effect" -import type { AnyTool, RegistrationError } from "../tool/tool" - -export { Failure, RegistrationError, make } from "../tool/tool" -export type { AnyTool, Content, Context, Definition } from "../tool/tool" - -export interface Interface { - /** - * Register same-process tools on this OpenCode instance for the current Scope. - * Location tools with the same name take precedence where they are installed. - * Closing the Scope removes the tools immediately, so calls that have not - * started settling may fail because the tool is no longer available. - */ - readonly register: (tools: Readonly>) => Effect.Effect -} diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 38b0c95704..dac711f3c6 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Tool } from "@opencode-ai/core/public" +import { Tool } from "@opencode-ai/core/tool/tool" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 14b2ed7378..6dba16ece6 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect" -import { Tool } from "@opencode-ai/core/public" +import { Tool } from "@opencode-ai/core/tool/tool" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts deleted file mode 100644 index fb9397e572..0000000000 --- a/packages/core/test/public-opencode.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" -import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public" -import { testEffect } from "./lib/effect" - -const it = testEffect(OpenCode.layer) - -describe("public native OpenCode API", () => { - it.effect("exposes only the intentional Session capabilities", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - - expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"]) - - expect(Object.keys(opencode.sessions).sort()).toEqual([ - "context", - "create", - "events", - "get", - "interrupt", - "list", - "message", - "messages", - "prompt", - "switchModel", - ]) - expect(Session.ID.create()).toStartWith("ses_") - expect(Session.MessageID.create()).toStartWith("msg_") - expect(yield* opencode.sessions.list()).toBeArray() - yield* opencode.tools.register({ - public_tool: Tool.make({ - description: "Public tool", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), - }), - }) - }), - ) - - it.effect("records model selection without resolving the Location catalog", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_deferred") - const model = Schema.decodeUnknownSync(Model.Ref)({ - id: "missing", - providerID: "missing", - variant: "unknown", - }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }), - }) - - yield* opencode.sessions.switchModel({ sessionID, model }) - - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) - }), - ) - - it.effect("preserves the typed not-found error for a missing Session", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_missing") - const error = yield* opencode.sessions - .switchModel({ - sessionID, - model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }), - }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Session.NotFoundError) - if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID) - }), - ) -}) diff --git a/packages/core/test/public-tool.test.ts b/packages/core/test/public-tool.test.ts deleted file mode 100644 index d3f444dd09..0000000000 --- a/packages/core/test/public-tool.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { Tool } from "@opencode-ai/core/public" -import { Effect } from "effect" - -describe("public Tool API", () => { - it("keeps the public registration capability narrow", () => { - const tools = { - register: () => Effect.void, - } satisfies Tool.Interface - - expect(Object.keys(tools)).toEqual(["register"]) - }) -}) diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md new file mode 100644 index 0000000000..14a4dd3932 --- /dev/null +++ b/packages/httpapi-codegen/README.md @@ -0,0 +1,42 @@ +# @opencode-ai/httpapi-codegen + +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. +- Unwrap exact `{ data: A }` success envelopes. +- Map no-content success to `void`. +- Preserve other single success values. +- Reject ambiguous multiple-success contracts. +- 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`. +- 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 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 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. + +Generated source starts with one self-contained module per `HttpApiGroup`, plus root client and index modules. Schema dependencies may be duplicated across group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it. + +Codegen preserves group and endpoint identifiers exactly. The composed remote `HttpApi` owns public names such as `session` and `get`; the generator performs no prefix stripping, casing conversion, or public-name annotation mapping. diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json new file mode 100644 index 0000000000..84aa736531 --- /dev/null +++ b/packages/httpapi-codegen/package.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/httpapi-codegen", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --timeout 5000 --only-failures", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts new file mode 100644 index 0000000000..d576b24cd1 --- /dev/null +++ b/packages/httpapi-codegen/src/index.ts @@ -0,0 +1,1147 @@ +import { isAbsolute, join } from "node:path" +import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { HttpMethod, type HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" + +export type InputField = { + readonly name: string + readonly source: "params" | "query" | "headers" | "payload" +} + +export type Operation = { + readonly group: string + readonly name: string + readonly input: ReadonlyArray + readonly inputMode: "none" | "optional" | "required" + readonly success: "value" | "void" | "stream" + readonly errors: ReadonlyArray +} + +export type Output = { + readonly operations: ReadonlyArray + readonly files: ReadonlyArray<{ + readonly path: string + readonly content: string + }> +} + +export type Contract = { + readonly groups: ReadonlyArray +} + +export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { + reason: Schema.String, +}) { + override get message() { + return this.reason + } +} + +export type Endpoint = { + readonly group: string + readonly sourceGroup: string + readonly topLevel: boolean + readonly endpoint: HttpApiEndpoint.AnyWithProps + readonly params: Schema.Top | undefined + readonly query: Schema.Top | undefined + readonly headers: Schema.Top | undefined + readonly payloads: ReadonlyArray + readonly operation: Operation + readonly input: ReadonlyArray + readonly unwrapData: boolean + readonly errors: ReadonlyArray + readonly successes: ReadonlyArray + readonly effectPortable: boolean +} + +export type Group = { + readonly identifier: string + readonly sourceIdentifier: string + readonly module: string + readonly endpoints: ReadonlyArray +} + +type Slot = { + readonly name: string + readonly schema: Schema.Top +} + +const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) +const manifestName = ".httpapi-codegen.json" + +export function compile( + api: HttpApi.HttpApi, + options?: { readonly groupNames?: Readonly> }, +): Contract { + const endpoints: Array = [] + const portable = new Map() + + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint, errors, group, middleware }) { + const groupName = options?.groupNames?.[group.identifier] ?? group.identifier + const name = `${groupName}.${endpoint.name}` + const required = Array.from(middleware).find((item) => item.requiredForClient) + if (required !== undefined) { + throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` }) + } + + const successSchemas = Array.from(endpoint.success) + if (successSchemas.length === 0) successSchemas.push(HttpApiSchema.NoContent) + if (successSchemas.length > 1) throw new GenerationError({ reason: `Multiple success schemas: ${name}` }) + + const params = normalizeTransport(endpoint.params, "params", endpoint, name) + const query = normalizeTransport(endpoint.query, "query", endpoint, name) + const headers = normalizeTransport(endpoint.headers, "headers", endpoint, name) + const sourcePayloads = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas) + if (sourcePayloads.length > 1) { + throw new GenerationError({ reason: `Multiple payload schemas: ${name}` }) + } + const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!) + const success = normalizeTransport(successSchemas[0], "success", endpoint, name)! + const errorSchemas = Array.from(errors.values()).flatMap((schemas) => + schemas.map((schema) => normalizeTransport(schema, "error", endpoint, name)!), + ) + const inputs = [ + ...inputFields(params?.schema, "params", name), + ...inputFields(query?.schema, "query", name), + ...inputFields(headers?.schema, "headers", name), + ...payloads.flatMap((item) => inputFields(item.schema, "payload", name)), + ] + const names = new Set() + for (const field of inputs) { + if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` }) + names.add(field.name) + } + + const schemaPaths: Array = [ + ...(params === undefined ? [] : [[`${name}.params`, params.schema] as const]), + ...(query === undefined ? [] : [[`${name}.query`, query.schema] as const]), + ...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]), + ...payloads.map((item) => [`${name}.payload`, item.schema] as const), + ...responseSchemas(success.schema, `${name}.success`), + ...errorSchemas.map((item) => [`${name}.error`, item.schema] as const), + ] + const effectPortable = [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) + if (effectPortable) { + for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + } + + endpoints.push({ + group: groupName, + sourceGroup: group.identifier, + topLevel: group.topLevel, + endpoint, + params: params?.schema, + query: query?.schema, + headers: headers?.schema, + payloads: payloads.map((item) => item.schema), + input: inputs, + unwrapData: isDataEnvelope(success.schema), + successes: [success.schema], + errors: errorSchemas.map((item) => item.schema), + effectPortable, + operation: { + group: groupName, + name: clientEndpointName(endpoint.name), + input: inputs.map(({ name, source }) => ({ name, source })), + inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", + success: isStreamSchema(success.schema) + ? "stream" + : HttpApiSchema.isNoContent(success.schema.ast) + ? "void" + : "value", + errors: [ + ...new Set([ + ...errorSchemas.flatMap((item) => { + const identifier = SchemaAST.resolveIdentifier(item.schema.ast) + return identifier === undefined ? [] : [identifier] + }), + "ClientError", + ]), + ], + }, + }) + }, + }) + + const modules = new Set(["client", "client-error", "index"]) + const groups = Array.from( + Map.groupBy(endpoints, (endpoint) => endpoint.group), + ([identifier, endpoints], index) => { + if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) { + throw new GenerationError({ reason: `Client group name collision: ${identifier}` }) + } + const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}` + const module = uniqueModule(base, index, modules) + modules.add(module.toLowerCase()) + return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints } + }, + ) + const publicNames = new Set() + for (const group of groups) { + const endpointNames = new Set() + for (const endpoint of group.endpoints) { + if (endpointNames.has(endpoint.operation.name)) { + throw new GenerationError({ + reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`, + }) + } + endpointNames.add(endpoint.operation.name) + } + const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier] + for (const name of names) { + if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) + publicNames.add(name) + } + } + return { + groups, + } +} + +export function emitEffect(contract: Contract): Output { + const endpoint = contract.groups.flatMap((group) => group.endpoints).find((endpoint) => !endpoint.effectPortable) + if (endpoint !== undefined) { + throw new GenerationError({ + reason: `Effect schema requires authoritative import: ${endpoint.group}.${endpoint.endpoint.name}`, + }) + } + return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } +} + +export function emitEffectImported( + contract: Contract, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output { + return { + operations: operations(contract.groups), + files: renderImportedEffectFiles(contract.groups, options), + } +} + +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: [ + { path: "types.ts", content: renderPromiseTypes(groups) }, + { + path: "client-error.ts", + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { + path: "client.ts", + content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), + }, + { + path: "index.ts", + content: + 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\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 renderImportedEffectFiles( + groups: ReadonlyArray, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output["files"] { + const adapters = groups.map((group, groupIndex) => { + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` + const methods = group.endpoints.map((item, endpointIndex) => { + const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const fields = item.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + return [ + `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : "."}${field.name}`).join(", ")} }`, + ] + }) + .join(", ") + const input = item.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") + const argument = + item.operation.inputMode === "none" + ? "" + : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + }) + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` + }) + const fields = groups.flatMap((group, index) => + group.endpoints[0]?.topLevel + ? [`...adaptGroup${index}(raw)`] + : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`], + ) + const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) + const imported = "api" in options + const projection = imported + ? undefined + : "group" in options + ? renderImportedGroup(options.group) + : renderImportedProjection(groups, options.endpoints) + const api = imported ? options.api : "Api" + const imports = + projection === undefined + ? `import { ${api} } from ${JSON.stringify(options.module)}` + : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` + const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nconst adaptClient = (raw: RawClient) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map(adaptClient))\n` + return [ + { + 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: client }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedGroup(group: string) { + return { + imports: [group], + source: `const Api = HttpApi.make("generated").add(${group})\n\n`, + } +} + +function renderImportedProjection(groups: ReadonlyArray, endpoints: Readonly>) { + const imports = groups.flatMap((group) => + group.endpoints.map((endpoint) => { + const name = endpoints[`${group.identifier}.${endpoint.endpoint.name}`] + if (name === undefined) { + throw new GenerationError({ + reason: `Missing imported endpoint: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return name + }), + ) + const source = `const Api = HttpApi.make("generated").${groups + .map((group) => { + const options = group.endpoints[0]?.topLevel ? ", { topLevel: true }" : "" + return `add(HttpApiGroup.make(${JSON.stringify(group.identifier)}${options})${group.endpoints.map((endpoint) => `.add(${endpoints[`${group.identifier}.${endpoint.endpoint.name}`]})`).join("")})` + }) + .join(".")}\n\n` + return { imports: [...new Set(imports)], source } +} + +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, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${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.operation.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") + const json = operations.includes("JsonValue") + ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" + : "" + return [json, ...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.operation.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.operation.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.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + } + const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" + return `${JSON.stringify(endpoint.operation.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 clientEndpointName(name: string) { + return name.slice(name.lastIndexOf(".") + 1) +} + +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.some( + (artifact) => + artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"', + ) || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + const references = new Map( + document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]), + ) + const expand = (type: string, seen = new Set()): string => { + for (const [reference, value] of references) { + if (!type.includes(reference)) continue + if (seen.has(reference)) throw new GenerationError({ reason: "Recursive Promise types are not implemented" }) + type = type.replaceAll(reference, `(${expand(value, new Set([...seen, reference]))})`) + } + return type + } + return expand(document.codes[0].Type) + .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") + .replaceAll("Schema.Json", "JsonValue") +} + +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}` + let suffix = 0 + while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++ + return `${seed}${suffix === 0 ? "" : `-${suffix}`}` +} + +function normalizeTransport( + schema: Schema.Top | undefined, + source: InputField["source"] | "success" | "error", + endpoint: HttpApiEndpoint.AnyWithProps, + operation: string, +) { + if (schema === undefined) return undefined + if (isStreamSchema(schema)) return { schema, effectPortable: true } as const + if (!metadataPortable(schema.ast, new Set())) { + throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + } + const decoded = Schema.toType(schema) + if (!isPathInput(endpoint.path)) { + throw new GenerationError({ reason: `Invalid endpoint path: ${operation}` }) + } + const rebuilt = HttpApiEndpoint.make(endpoint.method)(endpoint.name, endpoint.path, { + ...(source === "params" ? { params: decoded } : undefined), + ...(source === "query" ? { query: decoded } : undefined), + ...(source === "headers" ? { headers: decoded } : undefined), + ...(source === "payload" ? { payload: decoded } : undefined), + ...(source === "success" ? { success: decoded } : { success: Schema.String }), + ...(source === "error" ? { error: decoded } : undefined), + }) + const normalized = + source === "params" + ? rebuilt.params + : source === "query" + ? rebuilt.query + : source === "headers" + ? rebuilt.headers + : source === "payload" + ? Array.from(rebuilt.payload.values())[0]?.schemas[0] + : source === "success" + ? Array.from(rebuilt.success)[0] + : Array.from(rebuilt.error)[0] + if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const + return { schema: decoded, effectPortable: true } as const +} + +function isPathInput(path: string): path is HttpRouter.PathInput { + return path === "*" || path.startsWith("/") +} + +function sameEncoding(left: SchemaAST.AST, right: SchemaAST.AST): boolean { + if (left._tag !== right._tag || left.encoding?.length !== right.encoding?.length) return false + if ( + left.encoding?.some((link, index) => { + const other = right.encoding?.[index] + return other === undefined || link.transformation !== other.transformation || !sameEncoding(link.to, other.to) + }) + ) + return false + if (!sameChecks(left.checks, right.checks) || !sameContext(left.context, right.context)) return false + if (SchemaAST.isSuspend(left) && SchemaAST.isSuspend(right)) return sameEncoding(left.thunk(), right.thunk()) + if (SchemaAST.isUnion(left) && SchemaAST.isUnion(right)) { + return ( + left.types.length === right.types.length && + left.types.every((ast, index) => sameEncoding(ast, right.types[index])) + ) + } + if (SchemaAST.isArrays(left) && SchemaAST.isArrays(right)) { + return ( + left.elements.length === right.elements.length && + left.rest.length === right.rest.length && + left.elements.every((ast, index) => sameEncoding(ast, right.elements[index])) && + left.rest.every((ast, index) => sameEncoding(ast, right.rest[index])) + ) + } + if (SchemaAST.isObjects(left) && SchemaAST.isObjects(right)) { + return ( + left.propertySignatures.length === right.propertySignatures.length && + left.indexSignatures.length === right.indexSignatures.length && + left.propertySignatures.every((field, index) => sameEncoding(field.type, right.propertySignatures[index].type)) && + left.indexSignatures.every( + (field, index) => + sameEncoding(field.parameter, right.indexSignatures[index].parameter) && + sameEncoding(field.type, right.indexSignatures[index].type), + ) + ) + } + return true +} + +function sameChecks(left: SchemaAST.Checks | undefined, right: SchemaAST.Checks | undefined): boolean { + if (left?.length !== right?.length) return false + if (left === undefined || right === undefined) return true + return left.every((check, index) => { + const other = right[index] + if (other === undefined || check._tag !== other._tag) return false + if (check._tag === "Filter" && other._tag === "Filter") { + return check.run === other.run && check.aborted === other.aborted + } + return check._tag === "FilterGroup" && other._tag === "FilterGroup" && sameChecks(check.checks, other.checks) + }) +} + +function sameContext(left: SchemaAST.Context | undefined, right: SchemaAST.Context | undefined) { + return left?.isOptional === right?.isOptional && left?.isMutable === right?.isMutable +} + +export function write( + output: Output, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + const paths = new Set() + const normalizedPaths = new Set() + for (const file of output.files) { + if (!isSafeOutputPath(file.path)) yield* new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + const path = file.path.toLowerCase() + if (normalizedPaths.has(path)) yield* new GenerationError({ reason: `Duplicate output path: ${file.path}` }) + normalizedPaths.add(path) + paths.add(file.path) + } + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(directory, { recursive: true }) + const manifest = join(directory, manifestName) + const previous = (yield* fs.exists(manifest)) + ? yield* fs.readFileString(manifest).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Manifest)), + Effect.mapError(() => new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })), + ) + : [] + if (previous.some((path) => !isSafeOutputPath(path))) { + yield* new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` }) + } + yield* Effect.forEach( + previous.filter((path) => !paths.has(path)), + (path) => fs.remove(join(directory, path), { force: true }), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + fs.exists(join(directory, file.path)).pipe( + Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))), + Effect.flatMap((info) => + info?.type === "SymbolicLink" + ? new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + : Effect.void, + ), + ), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + Effect.tryPromise({ + try: () => format(file.content, { filepath: file.path, parser: "typescript", semi: false, printWidth: 120 }), + catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }), + }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))), + { concurrency: 8, discard: true }, + ) + yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n") + }) +} + +function isSafeOutputPath(path: string) { + return path !== manifestName && !isAbsolute(path) && path !== "." && path !== ".." && !/[\\/]/.test(path) +} + +export function generate( + api: HttpApi.HttpApi, + options: { readonly directory: string }, +): Effect.Effect { + return Effect.try({ + try: () => emitEffect(compile(api)), + catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), + }).pipe(Effect.flatMap((output) => write(output, options.directory))) +} + +function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { + if (schema === undefined) return [] + const ast = Schema.toType(schema).ast + if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) + } + return ast.propertySignatures.map((field) => { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) + } + return { + name: field.name, + source, + optional: SchemaAST.isOptional(field.type), + } + }) +} + +function responseSchemas(schema: Schema.Top, path: string): Array { + if (HttpApiSchema.isNoContent(schema.ast)) return [] + if (!isStreamSchema(schema)) return [[path, schema]] + if (schema._tag === "StreamUint8Array") return [] + const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events + const rebuilt = + schema.sseMode === "data" + ? HttpApiSchema.StreamSse({ data: value, error: schema.error, contentType: schema.contentType }) + : HttpApiSchema.StreamSse({ + events: schema.events, + error: schema.error, + contentType: schema.contentType, + }) + if (!sameEncoding(schema.events.ast, rebuilt.events.ast)) { + throw new GenerationError({ reason: `Unportable schema: ${path}.${schema.sseMode}` }) + } + return [ + [`${path}.${schema.sseMode}`, value], + [`${path}.error`, schema.error], + ] +} + +function assertPortable(schema: Schema.Top, path: string, portable: Map) { + const visiting = new Set() + const taggedError = taggedErrorFields(schema) + const visit = (ast: SchemaAST.AST): boolean => { + const cached = portable.get(ast) + if (cached !== undefined) return cached + if (visiting.has(ast)) return true + visiting.add(ast) + const result = visitCurrent(ast) + visiting.delete(ast) + portable.set(ast, result) + return result + } + const visitCurrent = (ast: SchemaAST.AST): boolean => { + if (!annotationsPortable(ast.annotations)) return false + if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false + if (SchemaAST.isDeclaration(ast)) { + return generationPortable(ast.annotations?.generation) && ast.typeParameters.every(visit) + } + if (ast.encoding !== undefined && ast.annotations?.generation === undefined) return false + if (SchemaAST.isSuspend(ast)) return visit(ast.thunk()) + if (SchemaAST.isUnion(ast)) return ast.types.every(visit) + if (SchemaAST.isArrays(ast)) { + return ast.elements.every(visit) && ast.rest.every(visit) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => visit(field.type)) && + ast.indexSignatures.every((index) => visit(index.parameter) && visit(index.type)) + ) + } + if (SchemaAST.isTemplateLiteral(ast)) return ast.parts.every(visit) + return true + } + if (taggedError !== undefined && SchemaAST.isDeclaration(schema.ast)) { + if ( + schema.ast.checks !== undefined || + ("encodingChecks" in schema.ast && !checksPortable(schema.ast.encodingChecks)) || + schema.ast.typeParameters.some((ast) => ast.checks !== undefined) || + !schema.ast.typeParameters.every(visit) + ) { + throw new GenerationError({ reason: `Unportable schema: ${path}` }) + } + return + } + if (!visit(schema.ast)) throw new GenerationError({ reason: `Unportable schema: ${path}` }) +} + +function checksPortable(checks: SchemaAST.Checks | undefined): boolean { + if (checks === undefined) return true + return checks.every((check) => + check._tag === "Filter" + ? !check.aborted && + check.annotations?.meta !== undefined && + typeof check.annotations.arbitrary === "object" && + check.annotations.arbitrary !== null && + "constraint" in check.annotations.arbitrary + : checksPortable(check.checks), + ) +} + +function metadataPortable(ast: SchemaAST.AST, seen: Set): boolean { + if (seen.has(ast)) return true + seen.add(ast) + if (!annotationsPortable(ast.annotations) || !checksPortable(ast.checks)) return false + if ("encodingChecks" in ast && !checksPortable(ast.encodingChecks)) return false + if (ast.encoding?.some((link) => !metadataPortable(link.to, seen))) return false + if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isSuspend(ast)) return metadataPortable(ast.thunk(), seen) + if (SchemaAST.isUnion(ast)) return ast.types.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isArrays(ast)) { + return ( + ast.elements.every((item) => metadataPortable(item, seen)) && + ast.rest.every((item) => metadataPortable(item, seen)) + ) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => metadataPortable(field.type, seen)) && + ast.indexSignatures.every( + (field) => metadataPortable(field.parameter, seen) && metadataPortable(field.type, seen), + ) + ) + } + return true +} + +function generationPortable(generation: unknown): boolean { + if (typeof generation !== "object" || generation === null) return false + const value = generation as { + readonly runtime?: unknown + readonly Type?: unknown + readonly importDeclaration?: unknown + } + if (typeof value.runtime !== "string" || typeof value.Type !== "string") return false + if (value.importDeclaration !== undefined) { + if ( + typeof value.importDeclaration !== "string" || + !/from ["']effect(?:\/[^"']+)?["']$/.test(value.importDeclaration) + ) { + return false + } + } + const namespace = + typeof value.importDeclaration === "string" + ? /import(?: type)? \* as ([A-Za-z_$][\w$]*)/.exec(value.importDeclaration)?.[1] + : undefined + return value.runtime.startsWith("Schema.") || (namespace !== undefined && value.runtime.startsWith(`${namespace}.`)) +} + +function annotationsPortable(annotations: Schema.Annotations.Annotations | undefined) { + if (annotations === undefined) return true + return Object.entries(annotations).every(([key, value]) => { + if ( + ["toCodec", "toCodecJson", "toArbitrary", "toFormatter", "toEquivalence", "~effect/Schema/Class"].includes(key) + ) { + return true + } + if (key === "generation") return generationPortable(value) + return serializable(value) + }) +} + +function serializable(value: unknown): boolean { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true + if (Array.isArray(value)) return value.every(serializable) + if (typeof value !== "object") return false + return Object.values(value).every(serializable) +} + +function taggedErrorFields(schema: Schema.Top) { + if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) { + return undefined + } + const fields = schema.ast.typeParameters[0] + if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined + const tag = fields.propertySignatures.find((field) => field.name === "_tag")?.type + if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined + return { + tag: tag.literal, + identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, + fields: fields.propertySignatures.flatMap((field) => + field.name === "_tag" || typeof field.name !== "string" + ? [] + : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const], + ), + } +} + +function isDataEnvelope(schema: Schema.Top) { + if (isStreamSchema(schema) || HttpApiSchema.isNoContent(schema.ast)) return false + const ast = Schema.toType(schema).ast + return ( + SchemaAST.isObjects(ast) && + ast.indexSignatures.length === 0 && + ast.propertySignatures.length === 1 && + ast.propertySignatures[0]?.name === "data" + ) +} + +function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchema { + return "_tag" in schema && (schema._tag === "StreamSse" || schema._tag === "StreamUint8Array") +} + +function streamDataSchema(schema: Extract) { + const ast = Schema.toType(schema.events).ast + if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const data = ast.propertySignatures.find((field) => field.name === "data")?.type + if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(data) +} + +function renderGroup(group: Group, groupIndex: number) { + const slots: Array = [] + const adapters: Array = [] + const endpointSources = group.endpoints.map((operation, endpointIndex) => { + const { + endpoint, + errors, + headers: endpointHeaders, + params: endpointParams, + payloads: endpointPayloads, + query: endpointQuery, + successes, + } = operation + const prefix = `Endpoint${endpointIndex}` + const params = addSlot(endpointParams, `${prefix}Params`) + const query = addSlot(endpointQuery, `${prefix}Query`) + const headers = addSlot(endpointHeaders, `${prefix}Headers`) + const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!) + const success = renderSuccess(successes[0], `${prefix}Success`) + const errorSlots = errors.map((schema, index) => addSlot(schema, `${prefix}Error${index}`)!) + const options = [ + params === undefined ? undefined : `params: ${params.name}`, + query === undefined ? undefined : `query: ${query.name}`, + headers === undefined ? undefined : `headers: ${headers.name}`, + payloads.length === 0 + ? undefined + : `payload: ${payloads.length === 1 ? payloads[0].name : `[${payloads.map((slot) => slot.name).join(", ")}]`}`, + `success: ${success.source}`, + errorSlots.length === 0 + ? undefined + : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, + ].filter((option): option is string => option !== undefined) + const schemaBySource = { params, query, headers, payload: payloads[0] } + const inputType = operation.input + .map((field) => { + const slot = schemaBySource[field.source] + if (slot === undefined) { + throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) + } + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + }) + .join("; ") + const argument = + operation.operation.inputMode === "none" + ? "" + : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const slot = schemaBySource[source] + if (slot === undefined) return [] + const fields = operation.input + .filter((field) => field.source === source) + .map( + (field) => + `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`, + ) + return [`${source}: { ${fields.join(", ")} }`] + }) + .join(", ") + const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])] + const declaredSchema = + declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` + const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + adapters.push( + `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, + ) + return `HttpApiEndpoint.make(${JSON.stringify(endpoint.method)})(${JSON.stringify(endpoint.name)}, ${JSON.stringify(endpoint.path)}, { ${options.join(", ")} })` + }) + + function addSlot(schema: Schema.Top | undefined, name: string) { + if (schema === undefined) return undefined + const slot = { name, schema } + slots.push(slot) + return slot + } + + function renderSuccess(schema: Schema.Top, name: string) { + if (!isStreamSchema(schema)) return { source: addSlot(schema, name)!.name } + const status = resolveHttpApiStatus(schema.ast) ?? 200 + const annotate = status === 200 ? "" : `.pipe(HttpApiSchema.status(${status}))` + if (schema._tag === "StreamUint8Array") { + return { + source: `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + } + } + const value = addSlot( + schema.sseMode === "data" ? streamDataSchema(schema) : schema.events, + `${name}${schema.sseMode === "data" ? "Data" : "Events"}`, + )! + const error = addSlot(schema.error, `${name}Error`)! + return { + source: `HttpApiSchema.StreamSse({ ${schema.sseMode}: ${value.name}, error: ${error.name}, contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + streamError: error, + } + } + + const declarations = renderSchemas(slots) + const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` + const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) + const methods = group.endpoints + .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`) + .join(", ") + const rawGroup = group.endpoints[0]?.topLevel + ? `HttpApiClient.Client` + : `HttpApiClient.Client.Group` + const usesStream = group.endpoints.some((item) => item.operation.success === "stream") + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n` +} + +function renderSchemas(slots: ReadonlyArray) { + if (slots.length === 0) return "" + const classes = new Map( + slots.flatMap((slot, index) => { + const tagged = taggedErrorFields(slot.schema) + return tagged === undefined ? [] : [[index, tagged] as const] + }), + ) + const expanded = [ + ...slots.map((slot, index) => (classes.has(index) ? { name: slot.name, schema: Schema.Never } : slot)), + ...Array.from(classes.values()).flatMap((tagged, classIndex) => + tagged.fields.map(([name, schema]) => ({ name: `Class${classIndex}${name}`, schema })), + ), + ] + const [first, ...rest] = expanded + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromASTs([first.schema.ast, ...rest.map((slot) => slot.schema.ast)]), + ) + const artifacts = document.artifacts.flatMap((artifact) => { + if (artifact._tag === "Import") return [artifact.importDeclaration] + if (artifact._tag === "Enum") return [artifact.generation.runtime] + return [`const ${artifact.identifier} = ${artifact.generation.runtime}`] + }) + const references = [ + ...document.references.nonRecursives.map(({ $ref, code }) => `const ${$ref} = ${code.runtime}`), + ...Object.entries(document.references.recursives).map( + ([$ref, code]) => `type ${$ref} = ${code.Type}\nconst ${$ref}: Schema.Codec<${$ref}> = ${code.runtime}`, + ), + ] + let fieldIndex = slots.length + const declarations = slots.map((slot, index) => { + const tagged = classes.get(index) + if (tagged === undefined) return `const ${slot.name} = ${document.codes[index].runtime}` + const fields = tagged.fields + .map(([name]) => `${JSON.stringify(name)}: ${document.codes[fieldIndex++].runtime}`) + .join(", ") + const annotations = Object.entries({ + httpApiStatus: resolveHttpApiStatus(slot.schema.ast), + "~httpApiEncoding": resolveHttpApiEncoding(slot.schema.ast), + }).filter((entry) => entry[1] !== undefined) + const annotate = + annotations.length === 0 + ? "" + : `.annotate({ ${annotations.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")} })` + return `class ${slot.name}Class extends Schema.TaggedErrorClass<${slot.name}Class>(${JSON.stringify(tagged.identifier)})(${JSON.stringify(tagged.tag)}, { ${fields} }) {}\nconst ${slot.name} = ${slot.name}Class${annotate}` + }) + return [...artifacts, ...references, ...declarations].join("\n\n") +} + +function renderClient(groups: ReadonlyArray) { + const imports = groups + .map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`) + .join("\n") + const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}` + const fields = groups.flatMap((group, index) => { + if (!group.endpoints[0]?.topLevel) { + return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`] + } + const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }` + return [`...adaptGroup${index}(${raw})`] + }) + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n` +} diff --git a/packages/httpapi-codegen/test/effect.ts b/packages/httpapi-codegen/test/effect.ts new file mode 100644 index 0000000000..3accad3cc9 --- /dev/null +++ b/packages/httpapi-codegen/test/effect.ts @@ -0,0 +1,28 @@ +import { test } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type { Scope } from "effect/Scope" +import { TestClock, TestConsole } from "effect/testing" + +type Body = Effect.Effect | (() => Effect.Effect) + +const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +const effect = (name: string, body: Body, options?: Parameters[2]) => + test( + name, + () => + Effect.gen(function* () { + const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe( + Effect.scoped, + Effect.provide(layer), + Effect.exit, + ) + if (Exit.isFailure(exit)) { + yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true }) + } + return yield* exit + }).pipe(Effect.runPromise), + options, + ) + +export const it = { effect } diff --git a/packages/httpapi-codegen/test/fixture.ts b/packages/httpapi-codegen/test/fixture.ts new file mode 100644 index 0000000000..9fb7fedda1 --- /dev/null +++ b/packages/httpapi-codegen/test/fixture.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" + +export class Missing extends Schema.TaggedErrorClass()("Missing", { + message: Schema.String, +}) {} + +export const Api = HttpApi.make("fixture") + .add( + HttpApiGroup.make("session") + .add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String })) + .add( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.Array(Schema.String), + }), + ) + .add( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ) + .add( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ) + .add( + HttpApiGroup.make("event").add( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe( + HttpApiSchema.status(202), + ), + }), + ), + ) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts new file mode 100644 index 0000000000..8254652ec5 --- /dev/null +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -0,0 +1,897 @@ +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 as compileContract, + emitEffect, + emitEffectImported, + emitPromise, + generate, + GenerationError, +} from "../src" +import { it } from "./effect" +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 Effect client against an imported authoritative API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", api: "Api" }, + ) + + expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"]) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + 'import { Api } from "@example/api"', + ) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + "HttpApiClient.ForApi", + ) + }) + + test("projects imported endpoint constants into a generated API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", endpoints: { "session.get": "SessionGet" } }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGet } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))') + }) + + test("imports an authoritative group without reconstructing it", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ), + { module: "@example/api", group: "SessionGroup" }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGroup } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)') + expect(client).not.toContain("HttpApiGroup") + }) + + test("separates hosted and consumer group names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }), + ), + ) + const contract = compileContract(source, { groupNames: { "server.session": "sessions" } }) + + expect(contract.groups[0]?.identifier).toBe("sessions") + expect(contract.groups[0]?.sourceIdentifier).toBe("server.session") + expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) + }) + + test("rejects consumer group name collisions", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String }))) + .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String }))) + + expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow( + "Client group name collision: same", + ) + }) + + test("uses the unqualified endpoint name for the public client", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("session.get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.session.get": "SessionGet" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get") + expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })') + expect(effect).toContain('raw["session.get"]') + }) + + test("preserves optional keys in Promise error types", () => { + class OptionalError extends Schema.TaggedErrorClass()( + "OptionalError", + { message: Schema.String, detail: Schema.String.pipe(Schema.optional) }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "message": string; readonly "detail"?: string | undefined', + ) + }) + + test("erases brands from Promise wire types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) }, + success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }), + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "sessionID": string') + expect(types).not.toContain("Brand") + }) + + test("inlines non-recursive references in Promise wire types", () => { + const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ data: Referenced }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]', + ) + }) + + test("emits Effect Json schemas as standalone Promise types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Json, + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain("export type JsonValue =") + expect(types).toContain("{ readonly [key: string]: JsonValue }") + expect(types).not.toContain("Schema.Json") + }) + + 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( + HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })), + ), + ) + + expect(output.operations[0]).toMatchObject({ group: "session", name: "get" }) + }) + + test("emits one client module per HttpApi group", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String }))) + + const output = compile(source) + + expect(output.files.map((file) => file.path)).toEqual([ + "session.ts", + "tool.ts", + "client-error.ts", + "client.ts", + "index.ts", + ]) + }) + + test("emits syntactically valid TypeScript modules", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + const transpiler = new Bun.Transpiler({ loader: "ts" }) + + for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow() + }) + + it.effect("keeps the strict generated-consumer fixture current", () => + Effect.gen(function* () { + const output = compile(FixtureApi) + const actual = yield* Effect.promise(() => + Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)), + ) + expect(actual.sort((a, b) => a.localeCompare(b))).toEqual( + output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)), + ) + yield* Effect.forEach(output.files, (file) => + Effect.tryPromise(() => + Promise.all([ + Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(), + format(file.content, { parser: "typescript", semi: false, printWidth: 120 }), + ]), + ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))), + ) + }), + ) + + test("flattens transport input channels into one domain input", () => { + const output = compile( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.String }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.input).toEqual([ + { name: "sessionID", source: "params" }, + { name: "resume", source: "query" }, + { name: "traceID", source: "headers" }, + { name: "prompt", source: "payload" }, + ]) + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("uses no argument when an operation has no input fields", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.inputMode).toBe("none") + }) + + test("uses an optional object when every input field is optional", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.String) }, + success: Schema.Array(Schema.String), + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("optional") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]') + }) + + test("regenerates standard HttpApi transport codecs from decoded schemas", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.String, + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean") + }) + + test("uses a required object when any input field is required", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { includeArchived: Schema.optional(Schema.String) }, + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("required") + }) + + test("rejects colliding input names across transport channels", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session/:id", { + params: { id: Schema.String }, + payload: Schema.Struct({ id: Schema.String }), + success: Schema.Void, + }), + ), + ), + ).toThrow("Input field collision: id") + }) + + test("rejects multiple payload alternatives until selection semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session", { + payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })], + success: Schema.String, + }), + ), + ), + ).toThrow("Multiple payload schemas: session.prompt") + }) + + test("unwraps an exact data success envelope", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("value") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "Effect.map((value) => value.data)", + ) + }) + + test("maps no-content success to void", () => { + const output = compile( + api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })), + ) + + expect(output.operations[0]?.success).toBe("void") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204') + }) + + test("preserves non-default empty response statuses", () => { + const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201') + }) + + test("returns a non-envelope success unchanged", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.success).toBe("value") + }) + + test("rejects multiple success shapes until their public semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: [Schema.String, Schema.Number], + }), + ), + ), + ).toThrow("Multiple success schemas: session.get") + }) + + test("models an SSE success as a direct stream", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("stream") + }) + + test("preserves annotated stream response statuses", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + ".pipe(HttpApiSchema.status(202))", + ) + }) + + test("rejects schemas whose semantics cannot be emitted exactly", () => { + const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects custom transformations hidden beneath standard HttpApi codecs", () => { + const QueryBoolean = Schema.Literals(["yes", "no"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "yes"), + encode: SchemaGetter.transform((value) => (value ? "yes" : "no")), + }), + ) + + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + query: { archived: QueryBoolean }, + success: Schema.String, + }), + ), + ), + ).toThrow("Effect schema requires authoritative import: session.get") + }) + + test("rejects custom validation checks without portable metadata", () => { + const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive"))) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects spoofed and aborted validation checks", () => { + const Spoofed = Schema.Number.check( + Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }), + ) + const Aborted = Schema.Number.check(Schema.isFinite().abort()) + + expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow( + "Unportable schema: session.spoofed.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow( + "Unportable schema: session.aborted.success", + ) + }) + + test("rejects altered wire-side schemas even when the codec transformation is canonical", () => { + const JsonNumber = Schema.toCodecJson(Schema.Number) + const link = JsonNumber.ast.encoding?.[0] + if (link === undefined) throw new Error("Expected JSON number encoding") + // This helper is present at runtime but omitted from the public declaration surface. + const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding") + if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding") + const ast: unknown = replaceEncoding(JsonNumber.ast, [ + new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation), + ]) + if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST") + const Altered = Schema.make(ast) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow( + "Effect schema requires authoritative import: session.get", + ) + }) + + test("rejects lexical generation and annotation values", () => { + const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({ + generation: { runtime: "LocalOnly", Type: "string" }, + }) + const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({ + custom: () => "local", + }) + + expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow( + "Unportable schema: session.generated.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow( + "Unportable schema: session.annotated.success", + ) + }) + + test("preserves errors from server-only middleware", () => { + class Unauthorized extends Schema.TaggedErrorClass()("Unauthorized", {}) {} + class Authorization extends HttpApiMiddleware.Service()("Authorization", { + error: Unauthorized, + }) {} + + const output = compile( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)), + ) + + expect(output.operations[0]).toBeDefined() + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'extends Schema.TaggedErrorClass("Unauthorized")', + ) + }) + + test("preserves tagged error response statuses", () => { + class Missing extends Schema.TaggedErrorClass()("Missing", {}) {} + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })', + ) + }) + + test("supports every HttpApi method through the generic constructor", () => { + const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")') + }) + + test("uses safe unique module paths without changing public group identifiers", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"]) + expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"') + }) + + test("reserves support module names case-insensitively", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String }))) + .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"]) + }) + + test("keeps searching when a reserved-name fallback is also occupied", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"]) + }) + + test("rejects collisions in the flattened client namespace", () => { + expect(() => + compile( + HttpApi.make("test") + .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String }))) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ), + ), + ).toThrow("Client name collision: status") + }) + + test("emits a usable raw type for top-level groups", () => { + const output = compile( + HttpApi.make("test").add( + HttpApiGroup.make("health", { topLevel: true }).add( + HttpApiEndpoint.get("check", "/health", { success: Schema.String }), + ), + ), + ) + + expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client + Effect.gen(function* () { + const error = yield* generate( + api( + HttpApiEndpoint.get("get", "/url", { + success: Schema.declare((input): input is URL => input instanceof URL), + }), + ), + { + directory: "/generated", + }, + ).pipe(Effect.flip) + + expect(error).toBeInstanceOf(GenerationError) + if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success") + }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))), + ) + + test("rejects required client middleware without an adapter", () => { + class SignedRequest extends HttpApiMiddleware.Service()("SignedRequest", { + requiredForClient: true, + }) {} + + expect(() => + compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))), + ).toThrow("Client middleware requires adapter: SignedRequest") + }) + + test("maps transport and decode failures to one stable client error", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.errors).toContain("ClientError") + expect(output.operations[0]?.errors).not.toContain("HttpClientError") + expect(output.operations[0]?.errors).not.toContain("SchemaError") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "new ClientError({ cause: error })", + ) + }) +}) diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts new file mode 100644 index 0000000000..448db01be8 --- /dev/null +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -0,0 +1,28 @@ +import { Effect, Stream } from "effect" +import { HttpClient } from "effect/unstable/http" +import { ClientError, OpenCode } from "./generated" +import { Missing } from "./fixture" + +export const program = OpenCode.make().pipe( + Effect.map((client) => { + const health = client.session.health() + const list = client.session.list() + const filtered = client.session.list({ archived: true }) + const get = client.session.get({ sessionID: "session" }) + const interrupt = client.session.interrupt({ sessionID: "session" }) + const status = client.status() + const subscribe = client.event.subscribe() + + const _health: Effect.Effect = health + const _list: Effect.Effect, ClientError> = list + const _filtered: Effect.Effect, ClientError> = filtered + const _get: Effect.Effect = get + const _interrupt: Effect.Effect = interrupt + const _status: Effect.Effect = status + const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe + + return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe } + }), +) + +const _requiresHttpClient: Effect.Effect = program diff --git a/packages/httpapi-codegen/test/generated/client-error.ts b/packages/httpapi-codegen/test/generated/client-error.ts new file mode 100644 index 0000000000..bcc65d9bdd --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/httpapi-codegen/test/generated/client.ts b/packages/httpapi-codegen/test/generated/client.ts new file mode 100644 index 0000000000..f67fc00a99 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client.ts @@ -0,0 +1,16 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect } from "effect" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { adaptGroup0, Group0 } from "./session" +import { adaptGroup1, Group1 } from "./event" +import { adaptGroup2, Group2 } from "./system" + +const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2) +const adaptClient = (raw: HttpApiClient.ForApi) => ({ + session: adaptGroup0(raw["session"]), + event: adaptGroup1(raw["event"]), + ...adaptGroup2({ status: raw["status"] }), +}) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) diff --git a/packages/httpapi-codegen/test/generated/event.ts b/packages/httpapi-codegen/test/generated/event.ts new file mode 100644 index 0000000000..764f2fd1c7 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/event.ts @@ -0,0 +1,39 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema, Stream } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0SuccessData = Schema.Struct({ type: Schema.String }) + +const Endpoint0SuccessError = Schema.Never + +export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add( + HttpApiEndpoint.make("GET")("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ + data: Endpoint0SuccessData, + error: Endpoint0SuccessError, + contentType: "text/event-stream", + }).pipe(HttpApiSchema.status(202)), + }), +) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError]) +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => + Stream.unwrap( + raw["subscribe"]({}).pipe( + Effect.mapError(mapEndpoint0Error), + Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))), + ), + ) + +export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts new file mode 100644 index 0000000000..bc0dbc9fa4 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/httpapi-codegen/test/generated/session.ts b/packages/httpapi-codegen/test/generated/session.ts new file mode 100644 index 0000000000..6a1937c49e --- /dev/null +++ b/packages/httpapi-codegen/test/generated/session.ts @@ -0,0 +1,96 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) }) + +const Endpoint1Success = Schema.Array(Schema.String) + +const Endpoint2Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint2Success = Schema.Struct({ data: Schema.String }) + +class Endpoint2Error0Class extends Schema.TaggedErrorClass("Missing")("Missing", { + message: Schema.String, +}) {} +const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 }) + +const Endpoint3Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 }) + +export const Group0 = HttpApiGroup.make("session", { topLevel: false }) + .add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success })) + .add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success })) + .add( + HttpApiEndpoint.make("GET")("get", "/session/:sessionID", { + params: Endpoint2Params, + success: Endpoint2Success, + error: Endpoint2Error0, + }), + ) + .add( + HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", { + params: Endpoint3Params, + success: Endpoint3Success, + }), + ) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] } +const Endpoint1DeclaredError = Schema.Never +const mapEndpoint1Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint1DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) => + raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error)) + +type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] } +const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0]) +const mapEndpoint2Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint2DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) => + raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapEndpoint2Error), + Effect.map((value) => value.data), + ) + +type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] } +const Endpoint3DeclaredError = Schema.Never +const mapEndpoint3Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint3DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) => + raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error)) + +export const adaptGroup0 = (raw: RawGroup) => ({ + health: Endpoint0(raw), + list: Endpoint1(raw), + get: Endpoint2(raw), + interrupt: Endpoint3(raw), +}) diff --git a/packages/httpapi-codegen/test/generated/system.ts b/packages/httpapi-codegen/test/generated/system.ts new file mode 100644 index 0000000000..6ba523d2c0 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/system.ts @@ -0,0 +1,25 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }), +) + +type RawGroup = HttpApiClient.Client + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/write.test.ts b/packages/httpapi-codegen/test/write.test.ts new file mode 100644 index 0000000000..f0704abea2 --- /dev/null +++ b/packages/httpapi-codegen/test/write.test.ts @@ -0,0 +1,160 @@ +import { describe, expect } from "bun:test" +import { Effect, FileSystem, Option } from "effect" +import { write, type Output } from "../src" +import { it } from "./effect" + +describe("HttpApiCodegen.write", () => { + it.effect("writes compiled files beneath the output directory", () => { + const writes: Array<{ readonly path: string; readonly content: string }> = [] + const output: Output = { + operations: [], + files: [{ path: "session.ts", content: "export const session = {}" }], + } + + return Effect.gen(function* () { + yield* write(output, "/generated") + + expect(writes).toEqual([ + { path: "/generated/session.ts", content: "export const session = {}\n" }, + { path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' }, + ]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(false), + makeDirectory: () => Effect.void, + writeFileString: (path, content) => { + writes.push({ path, content }) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("removes only stale files owned by the previous manifest", () => { + const removed: Array = [] + return write( + { + operations: [], + files: [{ path: "session.ts", content: "" }], + }, + "/generated", + ).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")), + makeDirectory: () => Effect.void, + readFileString: () => Effect.succeed('["old.ts", "session.ts"]'), + remove: (path) => { + removed.push(path) + return Effect.void + }, + writeFileString: () => Effect.void, + }), + ), + Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))), + ) + }) + + it.effect("rejects unsafe and duplicate output paths before writing", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "../outside.ts", content: "" }, + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("rejects case-insensitive duplicate output paths", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(error.reason).toBe("Duplicate output path: CLIENT.ts") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("reserves the private manifest path", () => + write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))), + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + ), + ) + + it.effect("rejects existing symbolic-link output targets", () => + write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith("session.ts")), + makeDirectory: () => Effect.void, + stat: () => + Effect.succeed({ + type: "SymbolicLink", + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0), + blksize: Option.none(), + blocks: Option.none(), + }), + }), + ), + ), + ) +}) diff --git a/packages/httpapi-codegen/tsconfig.json b/packages/httpapi-codegen/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/httpapi-codegen/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md new file mode 100644 index 0000000000..449bd8f6da --- /dev/null +++ b/packages/sdk-next/README.md @@ -0,0 +1,27 @@ +# @opencode-ai/sdk-next + +Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate. + +The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client. + +```ts +import { OpenCode } from "@opencode-ai/sdk-next" + +const opencode = yield * OpenCode.create() +const session = yield * opencode.sessions.get({ sessionID }) +``` + +It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. + +The same constructor is available as a service Layer: + +```ts +const program = Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.get({ sessionID }) +}) + +yield * program.pipe(Effect.provide(OpenCode.layer)) +``` + +`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation. diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json new file mode 100644 index 0000000000..55a891a10f --- /dev/null +++ b/packages/sdk-next/package.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/sdk-next", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --timeout 5000", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts new file mode 100644 index 0000000000..ec948e03ca --- /dev/null +++ b/packages/sdk-next/src/index.ts @@ -0,0 +1,16 @@ +export * as OpenCode from "./opencode" +export * as Tool from "./tool" + +export { ClientError } from "@opencode-ai/client/effect" +export { + AbsolutePath, + Agent, + Location, + Model, + Prompt, + Provider, + RelativePath, + Session, + SessionInput, + SessionMessage, +} from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts new file mode 100644 index 0000000000..ad89ae49aa --- /dev/null +++ b/packages/sdk-next/src/opencode.ts @@ -0,0 +1,53 @@ +import { OpenCode } from "@opencode-ai/client/effect" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { createEmbeddedRoutes } from "@opencode-ai/server/routes" +import { Cause, Context, Effect, Layer } from "effect" +import { + HttpClient, + HttpRouter, + HttpServer, + HttpServerError, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" + +export const create = Effect.fn("OpenCode.create")(function* () { + const applicationTools = ApplicationTools.layer + const { handler, permissions, tools } = yield* Effect.all({ + // Reusing this Layer value lets registration and every Location share one memoized host-level registry. + handler: HttpRouter.toHttpEffect( + createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)), + ), + permissions: PermissionSaved.Service, + tools: ApplicationTools.Service, + }).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer))) + const httpClient = HttpClient.make( + Effect.fnUntraced(function* (request) { + const response = yield* handler.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)), + Effect.provideService(ApplicationTools.Service, tools), + Effect.provideService(PermissionSaved.Service, permissions), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)), + ), + ) + return HttpServerResponse.toClientResponse(response, { request }) + }, Effect.scoped), + ) + const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ) + return { + ...client, + tools: { register: tools.register }, + } +}) + +export type Interface = Effect.Success> + +export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} + +export const layer = Layer.effect(Service, create()) diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts new file mode 100644 index 0000000000..4b572a6260 --- /dev/null +++ b/packages/sdk-next/src/tool.ts @@ -0,0 +1,2 @@ +export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool" +export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts new file mode 100644 index 0000000000..e163d12c37 --- /dev/null +++ b/packages/sdk-next/test/embedded.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect, Schema } from "effect" + +test("embedded client uses the real router and handlers", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + yield* opencode.tools.register({ + embedded_tool: Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + + const created = yield* opencode.sessions.create({ + id: sessionID, + agent: Agent.ID.make("build"), + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.switchModel({ sessionID, model }) + const selected = yield* opencode.sessions.get({ sessionID }) + const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) + const admitted = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Do not run" }), + resume: false, + }) + const context = yield* opencode.sessions.context({ sessionID }) + const missing = yield* Effect.flip( + opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }), + ) + + expect(created.id).toBe(sessionID) + expect(selected.model?.id).toBe(model.id) + expect(selected.model?.providerID).toBe(model.providerID) + expect(page.data.some((session) => session.id === sessionID)).toBe(true) + expect(admitted.sessionID).toBe(sessionID) + expect(context.some((message) => message.type === "model-switched")).toBe(true) + expect(missing._tag).toBe("SessionNotFoundError") + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) + +test("embedded client is available as a Layer service", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const created = await Effect.runPromise( + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + }).pipe(Effect.provide(OpenCode.layer), Effect.scoped), + ) + + expect(created.id).toBe(sessionID) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/sdk-next/test/import-boundaries.test.ts b/packages/sdk-next/test/import-boundaries.test.ts new file mode 100644 index 0000000000..f6a7d178e2 --- /dev/null +++ b/packages/sdk-next/test/import-boundaries.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const client = resolve(import.meta.dir, "../../client") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +test("bundles the client and in-memory host", async () => { + const inputs = await bundleInputs() + + expect(within(inputs, client).length).toBeGreaterThan(0) + expect(within(inputs, core).length).toBeGreaterThan(0) + expect(within(inputs, server).length).toBeGreaterThan(0) +}) + +async function bundleInputs() { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"') + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + "--target=bun", + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/sdk-next/tsconfig.json b/packages/sdk-next/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/sdk-next/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index da5cda1399..7db252a9bf 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -12,16 +12,24 @@ import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" export function createRoutes(password?: string) { + return makeRoutes( + password + ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) + : ServerAuth.Config.defaultLayer, + ) +} + +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() })) +} + +function makeRoutes(auth: Layer.Layer) { return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), Layer.provide(PtyEnvironment.defaultLayer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), - Layer.provide( - password - ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) - : ServerAuth.Config.defaultLayer, - ), + Layer.provide(auth), Layer.provide(LocationServiceMap.layer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2.defaultLayer),