Compare commits

...
74 changed files with 5355 additions and 85 deletions
+1
View File
@@ -46,6 +46,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
### General Principles
- Keep things in one function unless composable or reusable
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
+1
View File
@@ -183,6 +183,7 @@
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
+2 -1
View File
@@ -55,6 +55,7 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"solid-js": "catalog:",
"zod": "catalog:"
}
}
+2
View File
@@ -1,5 +1,7 @@
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
export type { RpcApi, RpcClient } from "./rpc.js"
export type * from "./api/api.js"
export type WebSearchApi<E = never> = WebsearchApi<E>
+14
View File
@@ -1573,6 +1573,19 @@ export interface SkillApi<E = never> {
readonly list: SkillListOperation<E>
}
export type RpcCallInput = {
readonly namespace: string
readonly method: string
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly input?: unknown | undefined
}
export type RpcCallOutput = { readonly output?: unknown | undefined }
export type RpcCallOperation<E = never> = (input: RpcCallInput) => Effect.Effect<RpcCallOutput, E>
export interface RpcApi<E = never> {
readonly call: RpcCallOperation<E>
}
export type EventSubscribeOutput = OpenCodeEvent
export type EventSubscribeOperation<E = never> = () => Stream.Stream<EventSubscribeOutput, E>
@@ -2073,6 +2086,7 @@ export interface AppApi<E = never> {
readonly file: FileApi<E>
readonly command: CommandApi<E>
readonly skill: SkillApi<E>
readonly rpc: RpcApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly experimental: ExperimentalApi<E>
+58
View File
@@ -0,0 +1,58 @@
export * as OpenCode from "./client.js"
import { Cause, Context, Effect, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SharedEvents } from "../shared-events.js"
import { ClientError, OpenCode } from "./generated/index.js"
import { RpcClientRuntime } from "./rpc.js"
import type { RpcCallOptions } from "../promise/rpc.js"
const CurrentHeaders = Context.Reference<RpcCallOptions["headers"]>("@opencode-ai/client/effect/rpc/headers", {
defaultValue: () => undefined,
})
export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly baseUrl?: URL | string }) {
const httpClient = yield* HttpClient.HttpClient
const raw = yield* OpenCode.make(options).pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequestEffect(httpClient, (request) =>
Effect.map(CurrentHeaders, (headers) =>
headers ? HttpClientRequest.setHeaders(request, new Headers(headers)) : request,
),
),
),
)
const context = yield* Effect.context()
const native = raw.event.subscribe()
// Async iterators throw a squashed cause; retain the native typed failures and defects intact.
class EventFailure {
constructor(readonly cause: Cause.Cause<Stream.Error<typeof native>>) {}
}
const shared = SharedEvents.make((signal) =>
Stream.toAsyncIterableWith(
native.pipe(
Stream.interruptWhen(RpcClientRuntime.aborted(signal)),
Stream.catchCause((cause) => Stream.fail(new EventFailure(cause))),
),
context,
),
)
const subscribe = () =>
Stream.fromAsyncIterable(shared.subscribe(), (error) => error).pipe(
Stream.catch((error) =>
Stream.failCause(error instanceof EventFailure ? error.cause : Cause.fail(new ClientError({ cause: error }))),
),
)
return {
...raw,
event: { ...raw.event, subscribe },
rpc: Object.assign(
RpcClientRuntime.make(
(input, options) => raw.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)),
subscribe,
),
raw.rpc,
),
}
})
@@ -185,6 +185,8 @@ import type {
CommandListOutput,
SkillListInput,
SkillListOutput,
RpcCallInput,
RpcCallOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
@@ -1166,6 +1168,17 @@ const EndpointSkillList = (raw: RawClient["server.skill"]) => (input?: SkillList
const adaptGroupSkill = (raw: RawClient["server.skill"]) => ({ list: EndpointSkillList(raw) })
const EndpointRpcCall = (raw: RawClient["server.rpc"]) => (input: RpcCallInput) =>
preserveEffect<RpcCallOutput>()(
raw["rpc.call"]({
params: { namespace: input["namespace"], method: input["method"] },
query: { location: input["location"] },
payload: { input: input["input"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupRpc = (raw: RawClient["server.rpc"]) => ({ call: EndpointRpcCall(raw) })
const EndpointEventSubscribe = (raw: RawClient["server.event"]) => () =>
preserveStream<EventSubscribeOutput>()(
Stream.unwrap(
@@ -1564,6 +1577,7 @@ const adaptClient = (raw: RawClient) => ({
file: adaptGroupFile(raw["server.fs"]),
command: adaptGroupCommand(raw["server.command"]),
skill: adaptGroupSkill(raw["server.skill"]),
rpc: adaptGroupRpc(raw["server.rpc"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
experimental: adaptGroupExperimental(raw["server.experimental"]),
+5 -1
View File
@@ -1,8 +1,10 @@
// 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.
import type { Effect } from "effect"
import type { OpenCode } from "./client.js"
export * from "./generated/index"
export { OpenCode } from "./client.js"
export type {
AgentApi,
AppApi,
@@ -15,6 +17,8 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
RpcApi,
RpcClient,
WebSearchApi,
SessionApi,
SkillApi,
@@ -48,4 +52,4 @@ export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
export type OpenCodeClient = Effect.Success<ReturnType<typeof OpenCode.make>>
+94
View File
@@ -0,0 +1,94 @@
export * as RpcClientRuntime from "./rpc.js"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Effect, Schema, Stream } from "effect"
import type { RpcArguments, RpcCallOptions } from "../promise/rpc.js"
import { RpcRuntime } from "../rpc-runtime.js"
import type { RpcCallInput, RpcCallOutput } from "./api/api.js"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
type DecodeError<S> = S extends Schema.Top ? Schema.SchemaError : never
export type RpcClient<
D extends Rpc.Definition,
E = never,
Options = RpcCallOptions,
EventError = E,
> = {
readonly [Name in keyof D["methods"]]: (
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
) => Effect.Effect<
Rpc.Output<D["methods"][Name]["output"]>,
Rpc.MethodError<D["methods"][Name]> | DecodeError<D["methods"][Name]["output"]> | E
>
} & {
readonly events: {
readonly subscribe: <Name extends keyof D["events"] & string>(
name: Name,
) => Stream.Stream<Rpc.EventPayload<D, Name>, DecodeError<D["events"][Name]["schema"]> | EventError>
}
}
export interface RpcApi<E = never, Options = RpcCallOptions, EventError = E> {
<D extends Rpc.Definition>(definition: D): RpcClient<D, E, Options, EventError>
}
export function make<CallError, EventError>(
call: (input: RpcCallInput, options?: RpcCallOptions) => Effect.Effect<RpcCallOutput, CallError>,
subscribe: () => Stream.Stream<OpenCodeEvent, EventError>,
): RpcApi<Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError> {
return <D extends Rpc.Definition>(definition: D) => {
const methods = Object.fromEntries(
Object.entries(definition.methods).map(([name, method]) => [
name,
(input?: unknown, options?: RpcCallOptions) => {
const result = Effect.gen(function* () {
const response = yield* call(
{
namespace: definition.namespace,
method: name,
input,
location: options?.location,
},
options,
)
return yield* RpcRuntime.read(method.output, response.output)
}).pipe(Effect.catch((error) => RpcRuntime.readError(method, error)))
const signal = options?.signal
if (!signal) return result
return Effect.suspend(() =>
signal.aborted
? Effect.interrupt
: Effect.raceFirst(result, Effect.andThen(aborted(signal), Effect.interrupt)),
)
},
]),
)
// SAFETY: Every runtime key comes from this definition, and each value is decoded through its corresponding schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(methods, {
events: {
subscribe: (name: keyof D["events"] & string) => {
const type = RpcRuntime.eventType(definition, name)
if (!Object.hasOwn(definition.events, name)) return Stream.fail(new Error(`Unknown RPC event: ${type}`))
const schema = definition.events[name]
return subscribe().pipe(
Stream.filter((event): event is RpcEvent => event.type === type),
Stream.mapEffect((event) => RpcRuntime.event(definition, name, schema, event)),
)
},
},
}) as RpcClient<D, Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError>
}
}
export function aborted(signal: AbortSignal) {
return Effect.callback<void>((resume) => {
if (signal.aborted) return resume(Effect.void)
const abort = () => resume(Effect.void)
signal.addEventListener("abort", abort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", abort))
})
}
+5 -1
View File
@@ -1,4 +1,8 @@
type Client = ReturnType<typeof import("./generated/client.js").make>
import type { OpenCode } from "./client.js"
type Client = ReturnType<typeof OpenCode.make>
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
+18
View File
@@ -0,0 +1,18 @@
export * as OpenCode from "./client.js"
import { SharedEvents } from "../shared-events.js"
import { OpenCode } from "./generated/index.js"
import type { ClientOptions } from "./generated/client.js"
import { makeRpc } from "./rpc.js"
export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }))
return {
...raw,
rpc: Object.assign(makeRpc(raw, events), raw.rpc),
event: events,
}
}
@@ -181,6 +181,8 @@ import type {
CommandListOutput,
SkillListInput,
SkillListOutput,
RpcCallInput,
RpcCallOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
@@ -1594,6 +1596,21 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
rpc: {
call: (input: RpcCallInput, requestOptions?: RequestOptions) =>
request<RpcCallOutput>(
{
method: "POST",
path: `/api/rpc/${encodeURIComponent(input.namespace)}/${encodeURIComponent(input.method)}`,
query: { location: input["location"] },
body: { input: input["input"] },
successStatus: 200,
declaredStatuses: [400, 500, 401],
empty: false,
},
requestOptions,
),
},
event: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventSubscribeOutput> =>
sse<EventSubscribeOutput>(
@@ -333,6 +333,8 @@ export type SkillInfo = {
content: string
}
export type RpcOutput = { output?: JsonValue }
export type PermissionReply = "once" | "always" | "reject"
export type Pty = {
@@ -459,6 +461,15 @@ export type SessionMessageLocationSwitched = {
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type V2EventRpc = {
id: string
created: number
metadata?: { [x: string]: any } | undefined
type: `${"rpc."}${string}`
location: LocationRef
data: { [x: string]: any }
}
export type V2EventServerConnected = {
id: string
metadata?: { [x: string]: any } | undefined
@@ -2315,6 +2326,7 @@ export type V2Event =
| VcsBranchUpdated
| McpStatusChanged
| McpResourcesChanged
| V2EventRpc
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
@@ -2481,6 +2493,24 @@ export type PermissionNotFoundError = {
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
export type RpcError = {
readonly _tag: "RpcError"
readonly type: string
readonly message: string
readonly data?: unknown | undefined
}
export const isRpcError = (value: unknown): value is RpcError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcError"
export type RpcInternalError = {
readonly _tag: "RpcInternalError"
readonly type: "rpc.internal"
readonly message: string
readonly data?: unknown | undefined
}
export const isRpcInternalError = (value: unknown): value is RpcInternalError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcInternalError"
export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string }
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
@@ -5669,6 +5699,17 @@ export type SkillListOutput = {
data: Array<SkillInfo>
}
export type RpcCallInput = {
readonly namespace: { readonly namespace: string; readonly method: string }["namespace"]
readonly method: { readonly namespace: string; readonly method: string }["method"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly input?: { readonly input?: JsonValue }["input"]
}
export type RpcCallOutput = RpcOutput
export type EventSubscribeOutput = V2Event
export type PtyListInput = {
+8 -1
View File
@@ -1,4 +1,7 @@
import type { OpenCode } from "./client.js"
export * from "./generated/index.js"
export { OpenCode } from "./client.js"
export type {
AgentApi,
CatalogApi,
@@ -10,9 +13,13 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
RpcApi,
RpcCallOptions,
RpcClient,
RpcEventPayload,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types.js"
export type OpenCodeClient = ReturnType<typeof import("./generated/client.js").make>
export type OpenCodeClient = ReturnType<typeof OpenCode.make>
+147
View File
@@ -0,0 +1,147 @@
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { make, RequestOptions } from "./generated/client.js"
import { isRpcError, isRpcInternalError } from "./generated/types.js"
import type { EventSubscribeOutput, LocationGetInput, RpcCallInput } from "./generated/types.js"
type RpcEvent = Extract<EventSubscribeOutput, { type: `rpc.${string}` }>
export interface RpcCallOptions extends RequestOptions {
readonly location?: LocationGetInput["location"]
}
export type RpcArguments<Input, Options> = unknown extends Input
? [input: Input, options?: Options]
: undefined extends Input
? [input?: Input, options?: Options]
: [input: Input, options?: Options]
export type RpcClient<D extends Rpc.PortableDefinition, Options = RpcCallOptions> = {
readonly [Name in keyof D["methods"]]: (
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
) => Promise<Rpc.Output<D["methods"][Name]["output"]>>
} & {
readonly events: {
readonly subscribe: <Name extends keyof D["events"] & string>(
name: Name,
options?: Pick<RequestOptions, "signal">,
) => AsyncIterable<RpcEventPayload<D, Name>>
readonly on: <Name extends keyof D["events"] & string>(
name: Name,
handler: (event: RpcEventPayload<D, Name>) => Promise<void> | void,
options?: Pick<RequestOptions, "signal">,
) => () => void
}
}
type RpcEventPayloadFor<
D extends Rpc.PortableDefinition,
Name extends keyof D["events"] & string,
> = Omit<RpcEvent, "type" | "data"> & {
type: `rpc.${D["namespace"]}.${Name}`
data: Rpc.EventData<D["events"][Name]["schema"]>
}
export type RpcEventPayload<
D extends Rpc.PortableDefinition,
Name extends keyof D["events"] & string = keyof D["events"] & string,
> = { [K in Name]: RpcEventPayloadFor<D, K> }[Name]
export interface RpcApi<Options = RpcCallOptions> {
<D extends Rpc.PortableDefinition>(definition: D): RpcClient<D, Options>
}
export function makeRpc(
raw: ReturnType<typeof make>,
events: { subscribe(options?: Pick<RequestOptions, "signal">): AsyncIterable<EventSubscribeOutput> },
): RpcApi {
return (definition) => {
const subscribe = (
name: string,
options?: Pick<RequestOptions, "signal">,
): AsyncIterable<RpcEventPayload<Rpc.PortableDefinition>> => {
if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.namespace}.${name}`)
const type = eventType(definition, name)
return {
[Symbol.asyncIterator]() {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
const iterator = (async function* () {
try {
for await (const published of events.subscribe({ signal })) {
if (signal.aborted) return
if (published.type !== type) continue
// SAFETY: The exact RPC type was selected above; Promise contracts require no client-side transform.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
yield published as RpcEventPayload<Rpc.PortableDefinition>
}
} catch (error) {
if (!signal.aborted) throw error
} finally {
controller.abort()
}
})()
return {
next: () => iterator.next(),
return: () => {
// Interrupt a pending source read before closing the generator.
controller.abort()
return iterator.return()
},
}
},
}
}
// SAFETY: Every runtime key comes from this definition's method and event maps, which define RpcClient's mapped keys.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(
Object.fromEntries(
Object.keys(definition.methods).map((name) => [
name,
async (input: unknown, options?: RpcCallOptions) => {
try {
const result = await raw.rpc.call(
{
namespace: definition.namespace,
method: name,
// SAFETY: The method schema defines the accepted input; this assertion bridges it to the generic JSON transport.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
input: input as RpcCallInput["input"],
location: options?.location,
},
{ signal: options?.signal, headers: options?.headers },
)
return result.output
} catch (error) {
if (!isRpcError(error) && !isRpcInternalError(error)) throw error
throw error.data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data: error.data }
}
},
]),
),
{
events: {
subscribe,
on: (
name: string,
handler: (event: RpcEventPayload<Rpc.PortableDefinition>) => Promise<void> | void,
options?: Pick<RequestOptions, "signal">,
) => {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
const source = subscribe(name, { signal })
void (async () => {
for await (const event of source) await handler(event)
})().catch((error: unknown) => console.error(error))
return () => controller.abort()
},
},
},
) as RpcClient<typeof definition>
}
}
function eventType(definition: Rpc.PortableDefinition, name: string) {
return `rpc.${definition.namespace}.${name}` as const
}
+60
View File
@@ -0,0 +1,60 @@
export * as RpcRuntime from "./rpc-runtime.js"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import { Effect, Schema } from "effect"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
export function read(schema: Rpc.Method["output"], value: unknown) {
// Standard Schema results have already been parsed by the server.
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
}
export function readError(method: Rpc.Method, error: unknown): Effect.Effect<never, unknown> {
if (!(error instanceof RpcError) && !(error instanceof RpcInternalError)) return Effect.fail(error)
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
return Effect.fail(
error.data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data: error.data },
)
}
return read(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) =>
Effect.fail(
data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data },
),
),
)
}
export const event = Effect.fn("Client.Rpc.event")(function* <
D extends Rpc.Definition,
Name extends keyof D["events"] & string,
>(
definition: D,
name: Name,
schema: Rpc.EventDefinition,
event: RpcEvent,
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
const data = yield* read(schema.schema, event.data)
// SAFETY: The event type was selected by the caller and data was decoded with this event's schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...event,
type: eventType(definition, name),
data,
} as Rpc.EventPayload<D, Name>
})
export function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
definition: D,
name: Name,
): `rpc.${D["namespace"]}.${Name}` {
return `rpc.${definition.namespace}.${name}`
}
+145
View File
@@ -0,0 +1,145 @@
export * as SharedEvents from "./shared-events.js"
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
type Completion = { readonly error: unknown } | Record<string, never>
type Subscriber = {
push: (value: A) => Promise<void>
finish: (completion: Completion) => void
}
type Connection = {
controller: AbortController
subscribers: Set<Subscriber>
connected?: A
read?: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>
}
let current: Connection | undefined
const delivered = Promise.resolve()
function stop(connection: Connection) {
connection.connected = undefined
connection.read?.resolve({ done: true, value: undefined })
connection.controller.abort()
if (current === connection) current = undefined
}
async function run(connection: Connection) {
let iterator: AsyncIterator<A> | undefined
let completion: Completion = {}
try {
if (connection.controller.signal.aborted) return
iterator = connect(connection.controller.signal)[Symbol.asyncIterator]()
while (!connection.controller.signal.aborted) {
// Cancellation must reach return() even when the source has a pending next().
connection.read = Promise.withResolvers<IteratorResult<A>>()
iterator.next().then(connection.read.resolve, connection.read.reject)
const item = await connection.read.promise
connection.read = undefined
if (item.done || connection.controller.signal.aborted) break
if (item.value.type === "server.connected") connection.connected = item.value
await Promise.all(Array.from(connection.subscribers, (subscriber) => subscriber.push(item.value)))
}
} catch (error) {
completion = { error }
} finally {
stop(connection)
try {
await iterator?.return?.()
} catch (error) {
if (!("error" in completion)) completion = { error }
}
connection.subscribers.forEach((subscriber) => subscriber.finish(completion))
}
}
return {
subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable<A> {
return {
[Symbol.asyncIterator]() {
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
let started = false
let completion: Completion | undefined
let connection: Connection | undefined
let offered: { readonly value: A; readonly accepted: ReturnType<typeof Promise.withResolvers<void>> } | undefined
function finish(result: Completion, discard = false) {
completion = result
if (discard || "error" in result) {
offered?.accepted.resolve()
offered = undefined
}
options?.signal?.removeEventListener("abort", abort)
if (connection?.subscribers.delete(subscriber) && !connection.subscribers.size) stop(connection)
pending.splice(0).forEach((request) => {
if ("error" in result) request.reject(result.error)
else request.resolve({ done: true, value: undefined })
})
}
function abort() {
finish({}, true)
}
const subscriber: Subscriber = {
finish,
push(value) {
if (completion) return delivered
const request = pending.shift()
if (request) {
request.resolve({ done: false, value })
return delivered
}
const accepted = Promise.withResolvers<void>()
offered = { value, accepted }
return accepted.promise
},
}
function start() {
if (completion) return
const fresh = !current
connection = current ?? {
controller: new AbortController(),
subscribers: new Set<Subscriber>(),
}
current = connection
connection.subscribers.add(subscriber)
if (connection.connected) void subscriber.push(connection.connected)
if (fresh) void run(connection)
}
return {
next(): Promise<IteratorResult<A>> {
if (offered) {
const current = offered
offered = undefined
current.accepted.resolve()
return Promise.resolve({ done: false, value: current.value })
}
if (completion) {
if ("error" in completion) return Promise.reject(completion.error)
return Promise.resolve({ done: true, value: undefined })
}
if (options?.signal?.aborted) {
abort()
return Promise.resolve({ done: true, value: undefined })
}
const request = Promise.withResolvers<IteratorResult<A>>()
pending.push(request)
if (!started) {
started = true
options?.signal?.addEventListener("abort", abort, { once: true })
start()
}
return request.promise
},
return(): Promise<IteratorResult<A>> {
finish({}, true)
return Promise.resolve({ done: true, value: undefined })
},
}
},
}
},
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
if ("durable" in event.value && event.value.durable)
options.log?.debug?.("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
+2 -1
View File
@@ -51,6 +51,7 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export type CreateDataInput = {
readonly api: () => OpenCodeClient
@@ -58,7 +59,7 @@ export type CreateDataInput = {
readonly event: {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
handler: (event: OpenCodeEventMap[Type]) => void,
) => () => void
readonly listen: (handler: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) => () => void
}
+2
View File
@@ -45,6 +45,7 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
sessionID: "ses_test",
key: "review-notes",
})
const emptyRpcOutput: Awaited<ReturnType<typeof promiseClient.rpc.call>> = {}
void [
effectSession,
@@ -54,6 +55,7 @@ void [
promiseList,
promisePut,
promiseRemove,
emptyRpcOutput,
exactVersion,
compatibleVersion,
]
+34 -21
View File
@@ -14,34 +14,34 @@ 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([])
expect(within(root.all, effect)).toEqual([])
expect(within(root.all, schema)).toEqual([])
expect(within(root.all, protocol)).toEqual([])
expect(within(root.all, core)).toEqual([])
expect(within(root.all, 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([])
expect(within(network.eager, effect).length).toBeGreaterThan(0)
expect(within(network.eager, schema).length).toBeGreaterThan(0)
expect(within(network.eager, protocol).length).toBeGreaterThan(0)
expect(within(network.all, core)).toEqual([])
expect(within(network.all, server)).toEqual([])
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
expect(within(promiseService, effect)).toEqual([])
expect(within(promiseService, schema)).toEqual([])
expect(within(promiseService, protocol)).toEqual([])
expect(within(promiseService, core)).toEqual([])
expect(within(promiseService, server)).toEqual([])
expect(within(promiseService.all, effect)).toEqual([])
expect(within(promiseService.all, schema)).toEqual([])
expect(within(promiseService.all, protocol)).toEqual([])
expect(within(promiseService.all, core)).toEqual([])
expect(within(promiseService.all, server)).toEqual([])
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
expect(within(effectService, effect).length).toBeGreaterThan(0)
expect(within(effectService, protocol).length).toBeGreaterThan(0)
expect(within(effectService, core)).toEqual([])
expect(within(effectService, server)).toEqual([])
expect(within(effectService.eager, effect).length).toBeGreaterThan(0)
expect(within(effectService.eager, protocol).length).toBeGreaterThan(0)
expect(within(effectService.all, core)).toEqual([])
expect(within(effectService.all, server)).toEqual([])
})
})
@@ -70,8 +70,21 @@ async function bundleInputs(specifier: string, target: "browser" | "bun") {
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))
const metadata: {
inputs: Record<string, { imports: Array<{ path: string; kind: string; external?: boolean }> }>
} = await Bun.file(metafile).json()
const inputs = new Map(Object.entries(metadata.inputs).map(([file, input]) => [resolve(directory, file), input]))
const eager = new Set<string>()
const visit = (file: string) => {
if (eager.has(file)) return
eager.add(file)
inputs
.get(file)
?.imports.filter((input) => !input.external && input.kind !== "dynamic-import")
.forEach((input) => visit(resolve(directory, input.path)))
}
visit(entrypoint)
return { all: Array.from(inputs.keys()), eager: Array.from(eager) }
} finally {
await rm(temporary, { recursive: true, force: true })
}
+46
View File
@@ -24,6 +24,7 @@ test("exposes every standard HTTP API group", () => {
"file",
"command",
"skill",
"rpc",
"event",
"pty",
"experimental",
@@ -677,6 +678,51 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
})
})
test("native event signals cancel only their listener and close transport after the last listener", async () => {
const opened = Promise.withResolvers<Request>()
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
opened.resolve(request)
return new Response(
new ReadableStream({
start(controller) {
request.signal.addEventListener("abort", () => controller.error(request.signal.reason), { once: true })
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const first = new AbortController()
const second = new AbortController()
const one = client.event.subscribe({ signal: first.signal })[Symbol.asyncIterator]().next()
const two = client.event.subscribe({ signal: second.signal })[Symbol.asyncIterator]().next()
const request = await opened.promise
expect(request.headers.get("authorization")).toBe("Bearer events")
first.abort()
expect((await one).done).toBe(true)
expect(request.signal.aborted).toBe(false)
second.abort()
expect((await two).done).toBe(true)
expect(request.signal.aborted).toBe(true)
})
test("native pre-aborted event signals do not open a transport", async () => {
let requests = 0
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => {
requests++
return new Response(null)
},
})
expect((await client.event.subscribe({ signal: AbortSignal.abort() })[Symbol.asyncIterator]().next()).done).toBe(true)
expect(requests).toBe(0)
})
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
+495
View File
@@ -0,0 +1,495 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { OpenCode } from "../src/effect/index"
const definition = Rpc.define({
namespace: "example",
methods: {
count: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
},
echo: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
raw: { input: { type: "string" }, output: { type: "number" } },
},
events: {
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
message: { schema: Schema.Struct({ text: Schema.String }) },
},
})
const connected = { id: "evt_connected", type: "server.connected", data: {} }
function rpcEvent(count: unknown, directory = "/project/one", namespace = "example", name = "progress") {
return {
id: "evt_progress",
created: 123,
type: `rpc.${namespace}.${name}`,
location: { directory },
metadata: { origin: "test" },
data: { count },
}
}
function eventSource() {
const requests: HttpClientRequest.HttpClientRequest[] = []
const opened = Promise.withResolvers<{
controller: ReadableStreamDefaultController<Uint8Array>
signal: AbortSignal
}>()
const cancelled = Promise.withResolvers<void>()
return {
requests,
opened: opened.promise,
cancelled: cancelled.promise,
async push(event: unknown) {
const source = await opened.promise
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
},
httpClient: HttpClient.make((request, _url, signal) => {
requests.push(request)
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
opened.resolve({ controller, signal })
},
cancel() {
cancelled.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
),
)
}),
}
}
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
const requests: Array<{ url: string; body: unknown }> = []
const httpClient = HttpClient.make((request) => {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
requests.push({ url: request.url, body })
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
}),
),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
const rpc = client.rpc(definition)
const count = yield* rpc.count({ count: "2" })
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
const empty = yield* rpc.empty()
const raw = yield* rpc.raw("input")
const native = yield* client.rpc.call({ namespace: "example", method: "count", input: null })
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
return { count, primitives, empty, raw, native }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({
count: 42,
primitives: [null, false, 0, "hello", [1, "two"]],
empty: undefined,
raw: 7,
native: { output: "42" },
})
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
})
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
const validations: unknown[] = []
const standard = {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: String(value) + " transformed" }
},
},
}
const service = Rpc.define({
namespace: "standard",
methods: { transform: { input: standard, output: standard } },
events: {
transformed: {
schema: {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: { text: String(value) + " transformed" } }
},
},
},
},
},
})
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.endsWith("/api/event")
? new Response(
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
: Response.json({ output: "done" }),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const rpc = client.rpc(service)
return {
output: yield* rpc.transform("input"),
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
}
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.output).toBe("done")
expect(result.events[0].data).toEqual({ text: "done" })
expect(validations).toEqual([])
})
test("Effect RPC validates decoded outputs in the failure channel", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Schema.isSchemaError(error)).toBe(true)
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
})
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
{ status: 400 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
})
test("Effect RPC removes the internal transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
{ status: 500 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
})
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
const release = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const httpClient = HttpClient.make((request, url) => {
requests.push({ url, headers: request.headers })
if (requests.length === 1) started.resolve()
return Effect.promise(() => release.promise).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
url.pathname.endsWith("/health")
? Response.json({ healthy: true, version: "test", pid: 1 })
: Response.json({ output: "3" }),
),
),
)
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const first = Effect.runPromise(
rpc.count(
{ count: "1" },
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
),
)
await started.promise
const second = Effect.runPromise(
rpc.count(
{ count: "2" },
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
),
)
const native = Effect.runPromise(client.health.get())
release.resolve()
expect(await Promise.all([first, second])).toEqual([3, 3])
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
expect(requests.map((request) => request.headers.authorization)).toEqual([
"Bearer base",
"Bearer base",
"Bearer base",
])
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
"/project/one",
"/project/two",
null,
])
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
})
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
Promise.withResolvers<AbortSignal>(),
Promise.withResolvers<AbortSignal>(),
]
const signals: AbortSignal[] = []
const finalized: number[] = []
const httpClient = HttpClient.make((_request, _url, signal) => {
const index = signals.length
signals.push(signal)
started[index].resolve(signal)
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
})
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const abort = new AbortController()
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
const second = Effect.runFork(rpc.count({ count: "2" }))
await Promise.all(started.map((entry) => entry.promise))
abort.abort()
const exit = await Effect.runPromise(Fiber.await(first))
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
expect(finalized).toEqual([0])
await Effect.runPromise(Fiber.interrupt(second))
expect(signals[1].aborted).toBe(true)
expect(finalized).toEqual([0, 1])
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
expect(signals).toHaveLength(2)
})
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const rpc = client.rpc(definition)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const marker = native.next()
await source.push(connected)
expect((await marker).value).toEqual(connected)
const first = progress.next()
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
expect((await late.next()).value).toEqual(connected)
await native.return?.()
await late.return?.()
await source.push(rpcEvent("ignored", "/project/one", "other"))
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
await source.push(rpcEvent("1"))
expect((await first).value).toEqual({
id: "evt_progress",
created: 123,
type: "rpc.example.progress",
metadata: { origin: "test" },
data: { count: 1 },
location: { directory: "/project/one" },
})
const second = progress.next()
await source.push(rpcEvent("2", "/project/two"))
expect((await second).value).toEqual(
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
)
expect(source.requests).toHaveLength(1)
expect((await source.opened).signal.aborted).toBe(false)
const third = progress.next()
await source.push(rpcEvent("3"))
expect((await third).value.data).toEqual({ count: 3 })
const pending = progress.next()
await progress.return?.()
expect((await pending).done).toBe(true)
await source.cancelled
expect((await source.opened).signal.aborted).toBe(true)
})
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
const first = progress.next()
await source.push(rpcEvent("1"))
expect((await first).value.data).toEqual({ count: 1 })
await Effect.runPromise(Fiber.interrupt(native))
expect((await source.opened).signal.aborted).toBe(false)
const second = progress.next()
await source.push(rpcEvent("2"))
expect((await second).value.data).toEqual({ count: 2 })
await progress.return?.()
await source.cancelled
})
test("shared Effect streams preserve EOF without reconnecting", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
await source.push(connected)
await source.push(rpcEvent("1"))
const connection = await source.opened
connection.controller.close()
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
expect(source.requests).toHaveLength(1)
})
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push({ type: "server.connected" })
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("HTTP source failures reach every Effect consumer", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(connected)
const connection = await source.opened
connection.controller.error(new Error("connection lost"))
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const raw = native.next()
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(rpcEvent("not a number"))
expect((await raw).value.type).toBe("rpc.example.progress")
expect(Schema.isSchemaError(await progress)).toBe(true)
expect((await source.opened).signal.aborted).toBe(false)
const next = native.next()
await source.push(connected)
expect((await next).value.type).toBe("server.connected")
await native.return?.()
await source.cancelled
})
test("shared event source runs with the Effect context captured by make", async () => {
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("Effect RPC rejects inherited event names without opening the source", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.die(new Error("Unexpected request"))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const broad: Rpc.Definition = definition
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
expect(requests).toEqual([])
})
+361
View File
@@ -0,0 +1,361 @@
import { afterEach, expect, test } from "bun:test"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Rpc } from "@opencode-ai/schema/rpc"
import { z } from "zod"
import { OpenCode } from "../src/promise/index"
const cleanup = new Set<() => void>()
afterEach(() => {
cleanup.forEach((close) => close())
cleanup.clear()
})
const Echo = Rpc.define({
namespace: "acme/jobs",
methods: {
echo: {
input: z.string(),
output: z.string(),
errors: { rejected: z.object({ reason: z.string() }) },
},
raw: { input: z.unknown(), output: z.unknown() },
ping: { input: z.undefined(), output: z.undefined() },
},
events: {
updated: { schema: z.object({ count: z.number() }) },
},
})
const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} }
const rpcEvent = (data: unknown, directory = "/first", namespace = Echo.namespace, name = "updated") => ({
id: "evt_rpc",
created: 10,
type: `rpc.${namespace}.${name}`,
location: { directory },
metadata: { source: "test" },
data,
})
function http(fetch: (request: Request) => Response | Promise<Response>) {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })
cleanup.add(() => server.stop(true))
return OpenCode.make({ baseUrl: server.url.href, headers: { authorization: "Bearer default", "x-base": "base" } })
}
function events() {
const requests: Request[] = []
const opened = Promise.withResolvers<ReadableStreamDefaultController<Uint8Array>>()
const cancelled = Promise.withResolvers<void>()
const encoder = new TextEncoder()
let stopped = false
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const abort = () => {
if (stopped) return
stopped = true
controller.error(request.signal.reason)
cancelled.resolve()
}
request.signal.addEventListener("abort", abort, { once: true })
cleanup.add(abort)
opened.resolve(controller)
controller.enqueue(encoder.encode(`data: ${JSON.stringify(connected)}\n\n`))
},
cancel() {
stopped = true
cancelled.resolve()
},
})
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
},
})
return {
client,
requests,
cancelled: cancelled.promise,
async send(value: unknown) {
return (await opened.promise).enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
},
async end() {
stopped = true
return (await opened.promise).close()
},
async fail(error: Error) {
stopped = true
return (await opened.promise).error(error)
},
}
}
test("rpc is callable, retains raw call, and routes method location, headers, and JSON body", async () => {
const requests: Array<{ url: string; method: string; headers: Headers; body: unknown }> = []
const client = http(async (request) => {
const body = await request.json()
requests.push({ url: request.url, method: request.method, headers: request.headers, body })
return Response.json({ output: body.input })
})
expect(typeof client.rpc).toBe("function")
expect(typeof client.rpc.call).toBe("function")
expect(
await client.rpc(Echo).echo("hello", {
location: { directory: "/project with spaces", workspace: "wrk_test" },
headers: { authorization: "Bearer override", "x-call": "call" },
}),
).toBe("hello")
const url = new URL(requests[0].url)
expect(url.pathname).toBe("/api/rpc/acme%2Fjobs/echo")
expect(url.searchParams.get("location[directory]")).toBe("/project with spaces")
expect(url.searchParams.get("location[workspace]")).toBe("wrk_test")
expect(requests[0].body).toEqual({ input: "hello" })
expect(requests[0].method).toBe("POST")
expect(requests[0].headers.get("authorization")).toBe("Bearer override")
expect(requests[0].headers.get("x-base")).toBe("base")
expect(requests[0].headers.get("x-call")).toBe("call")
expect(await client.rpc.call({ namespace: Echo.namespace, method: "echo", input: "raw" })).toEqual({ output: "raw" })
expect(new URL(requests[1].url).search).toBe("")
expect(requests[1].headers.get("authorization")).toBe("Bearer default")
})
test("no-input RPC methods and absent output use empty wrappers", async () => {
const client = http(async (request) => {
expect(await request.json()).toEqual({})
return Response.json({})
})
expect(await client.rpc(Echo).ping()).toBeUndefined()
expect(await client.rpc(Echo).ping(undefined, { location: { directory: "/project" } })).toBeUndefined()
})
test("RPC Standard Schema results are already parsed and are not transformed again", async () => {
const calls = { input: 0, output: 0 }
const input: StandardSchemaV1<string, number> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.input++
return { value: Number(value) }
},
},
}
const output: StandardSchemaV1<number, string> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.output++
return { value: String(value) }
},
},
}
const eventOutput: StandardSchemaV1<{ count: number }, { text: string }> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "number")
return { issues: [{ message: "Expected count" }] }
return { value: { text: String(value.count) } }
},
},
}
const definition = Rpc.define({
namespace: "standard",
methods: { count: { input, output } },
events: { counted: { schema: eventOutput } },
})
const client = http(async (request) => {
expect(await request.json()).toEqual({ input: "41" })
return Response.json({ output: "42" })
})
expect(await client.rpc(definition).count("41")).toBe("42")
const source = events()
const iterator = source.client.rpc(definition).events.subscribe("counted")[Symbol.asyncIterator]()
const next = iterator.next()
await source.send(rpcEvent({ text: "42" }, "/project", definition.namespace, "counted"))
expect((await next).value?.data).toEqual({ text: "42" })
await iterator.return?.()
expect(calls).toEqual({ input: 0, output: 0 })
})
test("RPC method signals cancel an in-flight HTTP request", async () => {
const received = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const client = http(() => {
received.resolve()
return response.promise
})
const controller = new AbortController()
const result = client
.rpc(Echo)
.echo("hello", { signal: controller.signal })
.catch((error: unknown) => error)
await received.promise
controller.abort()
expect(await result).toMatchObject({ name: "ClientError", reason: "Transport" })
response.resolve(Response.json({ output: "late" }))
})
test("RPC pre-aborted methods do not issue HTTP requests", async () => {
let requests = 0
const client = http(() => {
requests++
return Response.json({ output: "hello" })
})
await expect(client.rpc(Echo).echo("hello", { signal: AbortSignal.abort() })).rejects.toBeDefined()
expect(requests).toBe(0)
})
test("RPC declared HTTP failures propagate", async () => {
await expect(
http(() => Response.json({ _tag: "UnauthorizedError", message: "Denied" }, { status: 401 }))
.rpc(Echo)
.echo("hello"),
).rejects.toMatchObject({ _tag: "UnauthorizedError", message: "Denied" })
})
test("RPC method failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcError", type: "rejected", message: "Rejected", data: { reason: "busy" } }
const client = http(() => Response.json(response, { status: 400 }))
const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error)
expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } })
await expect(client.rpc.call({ namespace: Echo.namespace, method: "echo", input: "hello" })).rejects.toEqual(response)
})
test("RPC transport failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" }
await expect(http(() => Response.json(response, { status: 500 })).rpc(Echo).echo("hello")).rejects.toEqual({
type: "rpc.internal",
message: "Failed",
})
})
test("native events and multiple RPC clients share one lazy source across locations", async () => {
const source = events()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const second = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const otherDefinition = Rpc.define({ ...Echo, namespace: "other" })
const other = source.client.rpc(otherDefinition).events.subscribe("updated")[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const firstNext = first.next()
const secondNext = second.next()
const otherNext = other.next()
expect(await native.next()).toEqual({ done: false, value: connected })
expect(source.requests).toHaveLength(1)
expect(source.requests[0].headers.get("authorization")).toBe("Bearer events")
const late = source.client.event.subscribe()[Symbol.asyncIterator]()
expect(await late.next()).toEqual({ done: false, value: connected })
await Promise.all([native.return?.(), late.return?.()])
await source.send(rpcEvent({ ignored: true }, "/first", Echo.namespace, "unknown"))
await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.namespace))
expect((await otherNext).value).toMatchObject({
type: "rpc.other.updated",
location: { directory: "/other" },
data: { count: 9 },
})
await other.return?.()
await source.send(rpcEvent({ count: 42 }))
const expected = {
id: "evt_rpc",
created: 10,
type: `rpc.${Echo.namespace}.updated`,
location: { directory: "/first" },
metadata: { source: "test" },
data: { count: 42 },
}
expect(await firstNext).toEqual({ done: false, value: expected })
expect(await secondNext).toEqual({ done: false, value: expected })
const next = first.next()
await source.send(rpcEvent({ count: 43 }, "/second"))
expect((await next).value).toMatchObject({ location: { directory: "/second" }, data: { count: 43 } })
await Promise.all([first.return?.(), second.return?.()])
await source.cancelled
expect(source.requests[0].signal.aborted).toBe(true)
expect(source.requests).toHaveLength(1)
})
test("RPC iterator return and abort cancel only their pending subscribers", async () => {
const source = events()
const controller = new AbortController()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const secondEvents = source.client.rpc(Echo).events.subscribe("updated", { signal: controller.signal })
const second = secondEvents[Symbol.asyncIterator]()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const firstNext = first.next()
const secondNext = second.next()
await native.next()
expect((await first.return?.())?.done).toBe(true)
expect((await firstNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
controller.abort()
expect((await secondNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
const nativeNext = native.next()
const event = rpcEvent({ count: 42 })
await source.send(event)
expect(await nativeNext).toEqual({ done: false, value: event })
await native.return?.()
await source.cancelled
})
test("RPC callback subscriptions unsubscribe independently", async () => {
const source = events()
const received = Promise.withResolvers<unknown>()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
await native.next()
const unsubscribe = source.client.rpc(Echo).events.on("updated", received.resolve)
await source.send(rpcEvent({ count: 42 }))
expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.namespace}.updated` })
unsubscribe()
unsubscribe()
expect(source.requests[0].signal.aborted).toBe(false)
await native.return?.()
await source.cancelled
})
test("RPC async callback failures stop only that listener and are not unhandled", async () => {
const source = events()
const client = source.client.rpc(Echo)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const failed: number[] = []
cleanup.add(release.resolve)
cleanup.add(
client.events.on("updated", async (event) => {
failed.push(event.data.count)
started.resolve()
await release.promise
throw new Error("Expected async RPC callback failure")
}),
)
const healthy = client.events.subscribe("updated")[Symbol.asyncIterator]()
const first = healthy.next()
await source.send(rpcEvent({ count: 1 }))
await started.promise
expect((await first).value.data.count).toBe(1)
const second = healthy.next()
await source.send(rpcEvent({ count: 2 }))
expect((await second).value.data.count).toBe(2)
expect(failed).toEqual([1])
release.resolve()
await healthy.return?.()
await source.cancelled
expect(failed).toEqual([1])
})
test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => {
const source = events()
const broad: Rpc.PortableDefinition = Echo
expect(() => source.client.rpc(broad).events.subscribe("unknown")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.subscribe("toString")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.on("unknown", () => {})).toThrow("Unknown RPC event")
const aborted = source.client.rpc(Echo).events.subscribe("updated", { signal: AbortSignal.abort() })
const iterator = aborted[Symbol.asyncIterator]()
expect((await iterator.next()).done).toBe(true)
expect(source.requests).toHaveLength(0)
})
+281
View File
@@ -0,0 +1,281 @@
import { expect, test } from "bun:test"
import { SharedEvents } from "../src/shared-events"
type Event = { readonly type: string; readonly value?: number }
function source(cleanup?: Promise<void>) {
const connections: {
signal: AbortSignal
push: (event: Event) => void
close: () => void
fail: (error: unknown) => void
closing: Promise<void>
closed: Promise<void>
}[] = []
const opened: ReturnType<typeof Promise.withResolvers<void>>[] = []
return {
connections,
async at(index: number) {
if (!connections[index]) await (opened[index] ??= Promise.withResolvers<void>()).promise
return connections[index]
},
connect(signal: AbortSignal): AsyncIterable<Event> {
let controller!: ReadableStreamDefaultController<Event>
let ended = false
const closing = Promise.withResolvers<void>()
const closed = Promise.withResolvers<void>()
const stream = new ReadableStream<Event>({
start(value) {
controller = value
},
})
const close = () => {
if (ended) return
ended = true
controller.close()
}
signal.addEventListener("abort", close, { once: true })
connections.push({
signal,
push: (event) => controller.enqueue(event),
close,
fail(error) {
ended = true
controller.error(error)
},
closing: closing.promise,
closed: closed.promise,
})
opened[connections.length - 1]?.resolve()
return (async function* () {
try {
yield* stream
} finally {
signal.removeEventListener("abort", close)
closing.resolve()
await cleanup
closed.resolve()
}
})()
},
}
}
test("creation, subscription, and idle iterators are lazy", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const iterable = shared.subscribe()
const idle = iterable[Symbol.asyncIterator]()
expect(events.connections).toHaveLength(0)
expect(await idle.return!()).toEqual({ done: true, value: undefined })
expect(await idle.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(0)
const active = iterable[Symbol.asyncIterator]()
const next = active.next()
expect(events.connections).toHaveLength(1)
events.connections[0].push({ type: "server.connected" })
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
await active.return!()
await events.connections[0].closed
})
test("pre-aborted subscribers do not open a source", async () => {
const events = source()
const controller = new AbortController()
const iterator = SharedEvents.make(events.connect).subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
controller.abort()
expect(await iterator.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(0)
})
test("multiple consumers share one source and receive live native and RPC events", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
for (const event of [{ type: "server.connected" }, { type: "session.updated" }, { type: "rpc.example.updated", value: 1 }]) {
const reads = [first.next(), second.next()]
events.connections[0].push(event)
expect(await Promise.all(reads)).toEqual([
{ done: false, value: event },
{ done: false, value: event },
])
}
expect(events.connections).toHaveLength(1)
await first.return!()
expect(events.connections[0].signal.aborted).toBe(false)
const next = second.next()
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
expect((await next).value).toEqual({ type: "rpc.example.updated", value: 2 })
await second.return!()
await events.connections[0].closed
})
test("late consumers receive the latest connection marker but no business event replay", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const idle = shared.subscribe()[Symbol.asyncIterator]()
for (const event of [
{ type: "server.connected", value: 1 },
{ type: "server.connected", value: 2 },
{ type: "rpc.example.updated", value: 3 },
]) {
const next = first.next()
events.connections[0].push(event)
await next
}
expect(await idle.next()).toEqual({ done: false, value: { type: "server.connected", value: 2 } })
const next = idle.next()
events.connections[0].push({ type: "rpc.example.updated", value: 4 })
expect(await next).toEqual({ done: false, value: { type: "rpc.example.updated", value: 4 } })
expect(events.connections).toHaveLength(1)
await first.return!()
await idle.return!()
await events.connections[0].closed
})
test("abort removes only its subscriber; last return closes the native source and resolves pending reads", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const controller = new AbortController()
const first = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const firstRead = first.next()
const secondReads = [second.next(), second.next()]
controller.abort()
expect(await firstRead).toEqual({ done: true, value: undefined })
expect(await first.next()).toEqual({ done: true, value: undefined })
expect(events.connections[0].signal.aborted).toBe(false)
await second.return!()
expect(await Promise.all(secondReads)).toEqual([
{ done: true, value: undefined },
{ done: true, value: undefined },
])
expect(events.connections[0].signal.aborted).toBe(true)
await events.connections[0].closed
expect(await second.next()).toEqual({ done: true, value: undefined })
})
test("breaking a native for-await loop closes the last source", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const consumed = (async () => {
for await (const event of shared.subscribe()) {
expect(event.type).toBe("server.connected")
break
}
})()
events.connections[0].push({ type: "server.connected" })
await consumed
expect(events.connections[0].signal.aborted).toBe(true)
await events.connections[0].closed
})
test("rapid resubscription opens a replacement while old cleanup finishes", async () => {
const cleanup = Promise.withResolvers<void>()
const events = source(cleanup.promise)
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const firstRead = first.next()
events.connections[0].push({ type: "server.connected", value: 1 })
await firstRead
await first.return!()
await events.connections[0].closing
const second = shared.subscribe()[Symbol.asyncIterator]()
const third = shared.subscribe()[Symbol.asyncIterator]()
const secondRead = second.next()
const thirdRead = third.next()
const controller = new AbortController()
const cancelled = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const cancelledRead = cancelled.next()
controller.abort()
expect(await cancelledRead).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(2)
const replacement = await events.at(1)
replacement.push({ type: "server.connected", value: 2 })
expect(await Promise.all([secondRead, thirdRead])).toEqual([
{ done: false, value: { type: "server.connected", value: 2 } },
{ done: false, value: { type: "server.connected", value: 2 } },
])
cleanup.resolve()
await events.connections[0].closed
await second.return!()
await third.return!()
await replacement.closed
})
test("source EOF finishes all consumers and permits a fresh subscription without retry", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const reads = [first.next(), second.next()]
events.connections[0].push({ type: "server.connected", value: 1 })
await Promise.all(reads)
const nextReads = [first.next(), second.next()]
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
expect(await Promise.all(nextReads)).toEqual([
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
])
events.connections[0].close()
await events.connections[0].closed
expect(await first.next()).toEqual({ done: true, value: undefined })
expect(await second.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(1)
const fresh = shared.subscribe()[Symbol.asyncIterator]()
const next = fresh.next()
const replacement = await events.at(1)
replacement.push({ type: "server.connected", value: 3 })
expect(await next).toEqual({ done: false, value: { type: "server.connected", value: 3 } })
await fresh.return!()
await replacement.closed
})
test("source failures preserve error identity for every consumer and permit a new subscription", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const failure = { reason: "actual source failure" }
const reads = Promise.allSettled([first.next(), second.next()])
events.connections[0].fail(failure)
expect(await reads).toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
])
await expect(first.next()).rejects.toBe(failure)
expect(events.connections).toHaveLength(1)
const fresh = shared.subscribe()[Symbol.asyncIterator]()
const next = fresh.next()
const replacement = await events.at(1)
replacement.push({ type: "server.connected" })
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
await fresh.return!()
await replacement.closed
})
test("synchronous source creation failures reject subscribers without automatic retry", async () => {
const failure = new Error("connect failed")
const attempts: AbortSignal[] = []
const shared = SharedEvents.make<Event>((signal) => {
attempts.push(signal)
throw failure
})
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(1)
expect(attempts[0].aborted).toBe(true)
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(2)
})
+2
View File
@@ -30,6 +30,7 @@ import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
import { SessionRunnerLLM } from "./session/runner/llm.js"
@@ -62,6 +63,7 @@ const nodes = [
Agent.node,
Command.node,
Reference.node,
Rpc.node,
WebSearch.node,
Integration.node,
Catalog.node,
+2
View File
@@ -19,6 +19,7 @@ import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
import { WebSearch } from "./websearch.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
@@ -195,6 +196,7 @@ export const node = makeLocationNode({
Mcp.node,
Location.node,
Reference.node,
Rpc.node,
Skill.node,
Tool.node,
Vcs.node,
+18 -1
View File
@@ -3,6 +3,7 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import type { Event } from "@opencode-ai/schema/event"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
@@ -20,6 +21,7 @@ import { Mcp } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
import { Rpc } from "../rpc.js"
import { AbsolutePath, type DeepMutable } from "../schema.js"
import { Skill } from "../skill.js"
import { Tool } from "../tool.js"
@@ -32,6 +34,12 @@ import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
type RpcEvent = Event.Payload & {
readonly type: `rpc.${string}`
readonly location: Location.Ref
readonly data: Readonly<Record<string, unknown>>
}
const isRpcEvent = (event: Event.Payload): event is RpcEvent => event.type.startsWith("rpc.")
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
const app = yield* App.Metadata
const agents = yield* Agent.Service
@@ -44,6 +52,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const mcp = yield* Mcp.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const rpc = yield* Rpc.Service
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const vcs = yield* Vcs.Service
@@ -75,6 +84,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
app,
location: locationInfo(),
options: {},
rpc: Object.assign(rpc.client, { register: rpc.register }),
agent: {
get: (input) => {
const ref = locationRef(input)
@@ -191,7 +201,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
transform: commands.transform,
},
event: {
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
subscribe: () =>
bus
.subscribe()
.pipe(
Stream.filter(
(event): event is EventManifest.ServerEvent | RpcEvent => EventManifest.isServer(event) || isRpcEvent(event),
),
),
},
experimental: {
terminal: {
+275
View File
@@ -0,0 +1,275 @@
export * as Rpc from "./rpc.js"
export { define } from "@opencode-ai/schema/rpc"
export type { Definition, EventPayload, Failure } from "@opencode-ai/schema/rpc"
import type { RpcClient, RpcDomain, RpcHandlers } from "@opencode-ai/plugin/effect/rpc"
import type { Rpc } from "@opencode-ai/schema/rpc"
import { Event } from "@opencode-ai/schema/event"
import type { Tool } from "@opencode-ai/schema/tool"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, JsonSchema, Layer, Schema, SchemaRepresentation, Stream } from "effect"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { optional, statics } from "./schema.js"
export interface Interface {
readonly register: RpcDomain["register"]
readonly client: <D extends Rpc.Definition>(definition: D) => RpcClient<D, Rpc.SystemError, never, unknown>
readonly call: (namespace: string, method: string, input: unknown) => Effect.Effect<unknown, Rpc.Failure>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Rpc") {}
class DeclaredError extends Error {
constructor(
readonly type: string,
message: string,
readonly data?: unknown,
) {
super(message)
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const ref = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
const callContext = {
error: (type: string, message: string, data?: unknown) => new DeclaredError(type, message, data),
}
const registrations = new Map<
string,
Array<{
readonly definition: Rpc.Definition
readonly handlers: Readonly<Record<string, Function>>
}>
>()
const definitions = new WeakMap<
Rpc.Definition,
ReadonlyMap<string, { readonly event: Rpc.EventDefinition; readonly definition: Event.Definition }>
>()
const eventsFor = (definition: Rpc.Definition) => {
const existing = definitions.get(definition)
if (existing) return existing
const events = new Map(
Object.entries(definition.events).map(([name, event]) => [
name,
{ event, definition: eventDefinition(definition, name) },
]),
)
definitions.set(definition, events)
return events
}
const register = Effect.fn("Rpc.register")(function* <const D extends Rpc.Definition>(
definition: D,
handlers: RpcHandlers<NoInfer<D>>,
) {
const entry = { definition, handlers }
const dispose = Effect.sync(() => {
const remaining = (registrations.get(definition.namespace) ?? []).filter((candidate) => candidate !== entry)
if (remaining.length === 0) {
registrations.delete(definition.namespace)
return
}
registrations.set(definition.namespace, remaining)
})
yield* Effect.acquireRelease(
Effect.sync(() =>
registrations.set(definition.namespace, [...(registrations.get(definition.namespace) ?? []), entry]),
),
() => dispose,
)
const events = eventsFor(definition)
return {
dispose,
events: {
emit: Effect.fn("Rpc.emit")(function* (...args: Rpc.EventInput<D>) {
const registered = events.get(args[0])
if (!registered)
return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.namespace}.${args[0]}`))
const event = registered.event
// SAFETY: The public event-schema contract guarantees an object encoded/output type.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
const data = (yield* encode(event.schema, args[1])) as Readonly<Record<string, unknown>>
return yield* bus
.publish(registered.definition, data, {
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
})
.pipe(Effect.asVoid)
}),
},
}
})
const call = Effect.fn("Rpc.call")(function* (namespace: string, name: string, input: unknown) {
const entry = registrations.get(namespace)?.at(-1)
if (!entry)
return yield* Effect.fail(failure("rpc.namespace_unavailable", `RPC namespace is unavailable: ${namespace}`))
if (!Object.hasOwn(entry.definition.methods, name) || !Object.hasOwn(entry.handlers, name))
return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${namespace}.${name}`))
const method = entry.definition.methods[name]
const handler = entry.handlers[name]
const parsed = yield* parse(method.input, input).pipe(
Effect.mapError((error) => failure("rpc.invalid_input", errorMessage(error, "Invalid RPC input"))),
)
const result = yield* Effect.suspend(() => {
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(Effect.catch((error) => encodeError(method, error)))
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
})
const client = <D extends Rpc.Definition>(definition: D): RpcClient<D, Rpc.SystemError, never, unknown> => {
const events = eventsFor(definition)
const methods = Object.fromEntries(
Object.entries(definition.methods).map(([name, method]) => [
name,
(input: unknown) =>
call(definition.namespace, name, input).pipe(
Effect.catch((error) => decodeError(method, error)),
Effect.flatMap((value) => read(method.output, value).pipe(Effect.catch((cause) => Effect.die(cause)))),
),
]),
)
// SAFETY: Every runtime key comes from this definition, and each method delegates through its corresponding schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...methods,
events: {
subscribe: <Name extends keyof D["events"] & string>(name: Name) => {
const registered = events.get(name)
if (!registered) return Stream.fail(new Error(`Unknown RPC event: ${definition.namespace}.${name}`))
return bus.subscribe(registered.definition).pipe(
Stream.provideService(Location.Service, location),
Stream.mapEffect((payload) => logicalEvent(definition, name, payload, ref)),
)
},
},
} as RpcClient<D, Rpc.SystemError, never, unknown>
}
return Service.of({ register, call, client })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Location.node] })
const fields = {
id: Event.ID,
created: Schema.Finite,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
location: optional(Location.Ref),
}
const EventData = Schema.Record(Schema.String, Schema.Unknown)
const jsonSchemas = new WeakMap<JsonSchema.JsonSchema, Schema.Codec<unknown>>()
function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
definition: D,
name: Name,
): `rpc.${D["namespace"]}.${Name}` {
return `rpc.${definition.namespace}.${name}`
}
function eventDefinition(definition: Rpc.Definition, name: string): Event.Definition {
const type = eventType(definition, name)
const data = EventData
return Schema.Struct({ ...fields, type: Schema.Literal(type), data }).pipe(
statics(() => ({ type, durability: "ephemeral" as const, durable: undefined, data })),
) satisfies Event.EphemeralDefinition<string, typeof data>
}
function parse(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
if (Schema.isSchema(schema)) return Schema.decodeUnknownEffect(schema)(value)
if (isStandardSchema(schema)) {
return Effect.gen(function* () {
const parsed = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (cause) => cause })
const result =
parsed instanceof Promise ? yield* Effect.tryPromise({ try: () => parsed, catch: (cause) => cause }) : parsed
if (result.issues) return yield* Effect.fail(new Error(result.issues.map((issue) => issue.message).join("\n")))
return result.value
})
}
return Effect.try({
try: () => {
const existing = jsonSchemas.get(schema)
if (existing) return existing
const codec = Schema.make<Schema.Codec<unknown>>(
SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)).ast,
)
jsonSchemas.set(schema, codec)
return codec
},
catch: (cause) => cause,
}).pipe(Effect.flatMap((codec) => Schema.decodeUnknownEffect(codec)(value)))
}
function encode(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
return Schema.isSchema(schema) ? Schema.encodeUnknownEffect(schema)(value) : parse(schema, value)
}
function encodeError(method: Rpc.Method, error: unknown): Effect.Effect<never, Rpc.Failure> {
if (!(error instanceof DeclaredError)) return Effect.die(error)
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
return Effect.die(new Error(`Undeclared RPC error: ${error.type}`))
}
return encode(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}
function decodeError(method: Rpc.Method, error: Rpc.Failure): Effect.Effect<never, Rpc.Failure> {
if (!method.errors || !Object.hasOwn(method.errors, error.type)) return Effect.fail(error)
return read(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}
function failure(type: string, message: string, data?: unknown): Rpc.Failure {
return data === undefined ? { type, message } : { type, message, data }
}
function errorMessage(error: unknown, fallback: string) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
return fallback
}
function isStandardSchema(schema: Tool.ValueSchema): schema is Extract<Tool.ValueSchema, StandardSchemaV1> {
return "~standard" in schema
}
function read(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
// Standard Schema results were already parsed by the publisher; don't apply transforms twice.
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
}
const logicalEvent = Effect.fn("Rpc.logicalEvent")(function* <
D extends Rpc.Definition,
Name extends keyof D["events"] & string,
>(
definition: D,
name: Name,
payload: Event.Payload,
ref: Location.Ref,
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
const event = definition.events[name]
const data = yield* read(event.schema, payload.data)
// SAFETY: The private Bus definition owns the envelope and location.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...payload,
type: eventType(definition, name),
data,
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
} as Rpc.EventPayload<D, Name>
})
+2
View File
@@ -22,6 +22,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
import { Rpc } from "@opencode-ai/core/rpc"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
@@ -79,6 +80,7 @@ export const PluginTestLayer = LayerNode.compile(
Permission.node,
PluginHooks.node,
Reference.node,
Rpc.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
+8
View File
@@ -29,6 +29,14 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
}),
options: {},
rpc:
overrides.rpc ??
Object.assign(
() => {
throw new Error("unused rpc.client")
},
{ register: () => Effect.die("unused rpc.register") },
),
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
@@ -0,0 +1,106 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { Rpc } from "@opencode-ai/core/rpc"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { PluginTestLayer } from "./fixture"
import { Effect, Exit, Schema } from "effect"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginTestLayer)
const Echo = Rpc.define({
namespace: "shared-echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
fail: {
input: Schema.String,
output: Schema.String,
errors: { missing: Schema.Struct({ attempts: Schema.FiniteFromString }) },
},
},
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
it.effect("Effect plugins register, call, and publish namespaces independently of plugin identity", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const events: string[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type !== "rpc.shared-echo.updated") return
expect(event.location).toEqual({ directory: location.directory })
if (typeof event.data === "object" && event.data && "text" in event.data && typeof event.data.text === "string")
events.push(event.data.text)
}),
)
yield* plugins.activate([
{
id: "implementer",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
echo: (value) => Effect.succeed(`${value}!`),
fail: (value, context) => Effect.fail(context.error("missing", "Missing", { attempts: Number(value) })),
})
yield* registration.events.emit("updated", { text: "ready" })
}).pipe(Effect.orDie),
},
{
id: "consumer",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
expect(yield* ctx.rpc(Echo).fail("2").pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
}).pipe(Effect.orDie),
},
])
expect(events).toEqual(["ready"])
expect(yield* rpc.client(Echo).echo("hello")).toBe("hello!")
yield* plugins.activate([])
expect(Exit.isFailure(yield* rpc.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
yield* unsubscribe
}),
)
it.effect("failed plugin setup removes RPC overrides and restores the previous implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
yield* plugins.activate([
{
id: "implementer",
version: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("original"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.asVoid, Effect.orDie),
},
])
yield* plugins.activate([
{
id: "implementer",
version: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("replacement"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.andThen(Effect.die(new Error("setup failed"))), Effect.orDie),
},
])
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
@@ -0,0 +1,291 @@
import { describe, expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { RpcEventPayload } from "@opencode-ai/plugin/promise/rpc"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Logger } from "effect"
import { z } from "zod"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
describe("Promise plugin RPC", () => {
it.live("adapts calls, schema transforms, failures, and registration disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
namespace: "promise-rpc-calls",
methods: {
standard: { input: z.string().transform(Number), output: z.number().transform(String) },
ping: { input: z.undefined(), output: z.null() },
errorShapedOutput: {
input: z.undefined(),
output: z.object({ type: z.string(), message: z.string(), data: z.object({ value: z.number() }) }),
},
returned: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
thrown: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
defect: { input: z.undefined(), output: z.null() },
},
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-calls-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {
standard: async (input) => {
expect(input).toBe(42)
return input + 1
},
ping: async () => null,
errorShapedOutput: async () => ({ type: "ordinary", message: "Success", data: { value: 1 } }),
returned: async (_input, context) =>
context.error("rejected", "returned failure", { attempts: "1" }),
thrown: async (_input, context) => {
throw context.error("rejected", "thrown failure", { attempts: "2" })
},
defect: async () => {
throw new Error("handler defect")
},
})
const client = ctx.rpc(service)
expect(await client.standard("42")).toBe("43")
expect(await client.ping()).toBeNull()
expect(await client.errorShapedOutput()).toEqual({
type: "ordinary",
message: "Success",
data: { value: 1 },
})
await expect(client.returned()).rejects.toEqual({
type: "rejected",
message: "returned failure",
data: { attempts: 1 },
})
await expect(client.thrown()).rejects.toEqual({
type: "rejected",
message: "thrown failure",
data: { attempts: 2 },
})
await expect(client.defect()).rejects.toThrow("handler defect")
await registration.dispose()
await registration.dispose()
await expect(client.ping()).rejects.toBeDefined()
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }])
}),
)
it.live("cancels only the selected call and passes its AbortSignal to Promise handlers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
namespace: "promise-rpc-cancel",
methods: { wait: { input: z.string(), output: z.string() } },
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-cancel-plugin",
setup: async (ctx) => {
const started = Promise.withResolvers<void>()
const cancelled = Promise.withResolvers<void>()
const signals = new Map<string, AbortSignal>()
await ctx.rpc.register(service, {
wait: async (input, call) => {
signals.set(input, call.signal)
if (input === "complete") return input
started.resolve()
await new Promise<void>((resolve) => {
call.signal.addEventListener(
"abort",
() => {
cancelled.resolve()
resolve()
},
{ once: true },
)
})
return input
},
})
const client = ctx.rpc(service)
const controller = new AbortController()
const pending = client.wait("cancel", { signal: controller.signal })
const rejected = pending.then(
() => false,
() => true,
)
await started.promise
expect(await client.wait("complete")).toBe("complete")
controller.abort()
expect(await rejected).toBe(true)
await cancelled.promise
expect(signals.get("cancel")?.aborted).toBe(true)
expect(signals.get("complete")?.aborted).toBe(false)
expect(await client.wait("complete")).toBe("complete")
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }])
}),
)
it.live("awaits async callbacks and logs failures without stopping other plugin listeners", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
namespace: "promise-rpc-async-listeners",
methods: {},
events: { updated: { schema: z.object({ value: z.number() }) } },
})
const error = new Error("Expected async plugin callback failure")
const reported = Promise.withResolvers<void>()
const logger = Logger.make((entry) => {
if (Array.isArray(entry.message) && entry.message.includes(error)) reported.resolve()
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-async-listeners-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const third = Promise.withResolvers<void>()
const failed: number[] = []
const healthy: number[] = []
client.events.on("updated", async (event) => {
failed.push(event.data.value)
started.resolve()
await release.promise
throw error
})
client.events.on("updated", (event) => {
healthy.push(event.data.value)
if (event.data.value === 2) second.resolve()
if (event.data.value === 3) third.resolve()
})
await registration.events.emit("updated", { value: 1 })
await started.promise
await registration.events.emit("updated", { value: 2 })
await second.promise
expect(failed).toEqual([1])
release.resolve()
await reported.promise
await registration.events.emit("updated", { value: 3 })
await third.promise
expect(failed).toEqual([1])
expect(healthy).toEqual([1, 2, 3])
},
}),
)
yield* plugins
.activate([{ ...adapted, version: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }])
yield* plugins.activate([])
}),
)
it.live("isolates event listeners and closes pending and idle iterators on plugin unload", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
namespace: "promise-rpc-events",
methods: {},
events: {
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
const subscriptions = Promise.withResolvers<{
pending: Promise<IteratorResult<RpcEventPayload<typeof service, "counted">>>
idle: AsyncIterator<RpcEventPayload<typeof service, "counted">>
nativeIdle: AsyncIterator<unknown>
}>()
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-events-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const first: string[] = []
const second: string[] = []
const firstSeen = Promise.withResolvers<void>()
const secondSeen = Promise.withResolvers<void>()
const nextSeen = Promise.withResolvers<void>()
const unsubscribe = client.events.on("counted", (event) => {
first.push(event.data.text)
firstSeen.resolve()
})
client.events.on("counted", (event) => {
second.push(event.data.text)
if (event.data.text === "1") secondSeen.resolve()
if (event.data.text === "2") nextSeen.resolve()
})
const controller = new AbortController()
const iterator = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
const next = iterator.next()
const idle = client.events.subscribe("counted")[Symbol.asyncIterator]()
const idleNext = idle.next()
const nativeController = new AbortController()
const native = ctx.event.subscribe({ signal: nativeController.signal })[Symbol.asyncIterator]()
const nativeNext = native.next()
const nativeIdle = ctx.event.subscribe()[Symbol.asyncIterator]()
const nativeIdleNext = nativeIdle.next()
await registration.events.emit("counted", { count: 1 })
await Promise.all([firstSeen.promise, secondSeen.promise])
const event = (await next).value
expect(event.type).toBe("rpc.promise-rpc-events.counted")
expect(event.data).toEqual({ text: "1" })
expect(typeof event.location.directory).toBe("string")
expect((await idleNext).value.data).toEqual({ text: "1" })
expect((await nativeNext).value.type).toBe("rpc.promise-rpc-events.counted")
expect((await nativeIdleNext).value.type).toBe("rpc.promise-rpc-events.counted")
nativeController.abort()
expect((await native.next()).done).toBe(true)
unsubscribe()
unsubscribe()
controller.abort()
expect((await iterator.next()).done).toBe(true)
await registration.events.emit("counted", { count: 2 })
await nextSeen.promise
expect(first).toEqual(["1"])
expect(second).toEqual(["1", "2"])
const aborted = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
expect((await aborted.next()).done).toBe(true)
subscriptions.resolve({
pending: client.events.subscribe("counted")[Symbol.asyncIterator]().next(),
idle,
nativeIdle,
})
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, status: "active" }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
expect((yield* Effect.promise(() => active.pending)).done).toBe(true)
expect((yield* Effect.promise(() => active.idle.next())).done).toBe(true)
expect((yield* Effect.promise(() => active.nativeIdle.next())).done).toBe(true)
}),
)
})
+378
View File
@@ -0,0 +1,378 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Rpc } from "@opencode-ai/core/rpc"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import type { Event } from "@opencode-ai/schema/event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { z } from "zod"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [
[Location.node, Layer.succeed(Location.Service, location(ref))],
]),
)
const Echo = Rpc.define({
namespace: "test.rpc",
methods: { echo: { input: z.string(), output: z.string() } },
events: { updated: { schema: z.object({ text: z.string() }) } },
})
describe("Rpc", () => {
it.effect("creates handles before registration and resolves on every execution", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const request = client.echo("hello")
expect(yield* request.pipe(Effect.flip)).toEqual({
type: "rpc.namespace_unavailable",
message: "RPC namespace is unavailable: test.rpc",
})
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
expect(yield* request).toBe("hello")
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(`${value}!`) })
expect(yield* request).toBe("hello!")
expect(yield* rpc.call(Echo.namespace, "missing", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.missing",
})
expect(yield* rpc.call(Echo.namespace, "toString", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.toString",
})
}),
)
it.effect("uses the latest whole registration and reveals previous implementations on disposal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
const second = yield* rpc.register(Echo, { echo: () => Effect.succeed("second") })
const third = yield* rpc.register(Echo, { echo: () => Effect.succeed("third") })
expect(yield* client.echo("hello")).toBe("third")
yield* second.dispose
expect(yield* client.echo("hello")).toBe("third")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* first.dispose
expect(Exit.isFailure(yield* client.echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("removes registrations when their owning scope closes", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
yield* rpc.register(Echo, { echo: () => Effect.succeed("original") })
const scope = yield* Scope.make()
yield* rpc.register(Echo, { echo: () => Effect.succeed("override") }).pipe(Scope.provide(scope))
expect(yield* rpc.client(Echo).echo("hello")).toBe("override")
yield* Scope.close(scope, Exit.void)
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
it.effect("validates inputs before running handlers and validates returned results", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const received: string[] = []
yield* rpc.register(Echo, {
echo: (value) =>
Effect.sync(() => {
received.push(value)
return value
}),
})
expect(Exit.isFailure(yield* rpc.call(Echo.namespace, "echo", 42).pipe(Effect.exit))).toBe(true)
expect(received).toEqual([])
const Checked = Rpc.define({
namespace: "checked",
methods: { echo: { input: z.string(), output: z.string().min(3) } },
events: {},
})
yield* rpc.register(Checked, { echo: () => Effect.succeed("a") })
expect(Exit.isFailure(yield* rpc.client(Checked).echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("leaves local transport values to the declared schema", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Identity = Rpc.define({
namespace: "identity",
methods: { echo: { input: Schema.Unknown, output: Schema.Unknown } },
events: {},
})
yield* rpc.register(Identity, { echo: Effect.succeed })
const value = new Date(0)
expect(yield* rpc.client(Identity).echo(value)).toBe(value)
}),
)
it.effect("applies Standard Schema transforms once for inputs, outputs, and events", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const counts = { input: 0, output: 0, event: 0 }
const Transformed = Rpc.define({
namespace: "transformed",
methods: {
count: {
input: z.string().transform((value) => {
counts.input++
return Number(value)
}),
output: z.number().transform((value) => {
counts.output++
return String(value)
}),
},
},
events: {
counted: {
schema: z.object({ count: z.number() }).transform(({ count }) => {
counts.event++
return { text: String(count) }
}),
},
},
})
const registration = yield* rpc.register(Transformed, { count: (value) => Effect.succeed(value + 1) })
const client = rpc.client(Transformed)
expect(yield* client.count("41")).toBe("42")
const events = yield* client.events
.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ text: "42" })
expect(counts).toEqual({ input: 1, output: 1, event: 1 })
}),
)
it.effect("keeps encoded dispatch and decoded local results consistent for Effect codecs", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Codec = Rpc.define({
namespace: "codec",
methods: { count: { input: Schema.FiniteFromString, output: Schema.FiniteFromString } },
events: { counted: { schema: Schema.Struct({ count: Schema.FiniteFromString }) } },
})
const registration = yield* rpc.register(Codec, { count: (value) => Effect.succeed(value + 1) })
expect(yield* rpc.call(Codec.namespace, "count", "41")).toBe("42")
expect(yield* rpc.client(Codec).count("41")).toBe(42)
const events = yield* rpc
.client(Codec)
.events.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ count: 42 })
}),
)
it.effect("validates declared error data and decodes it for local clients", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Failing = Rpc.define({
namespace: "failing",
methods: {
standard: {
input: z.undefined(),
output: z.string(),
errors: { missing: z.object({ attempts: z.string().transform(Number) }) },
},
effect: {
input: Schema.Undefined,
output: Schema.String,
errors: { invalid: Schema.Struct({ count: Schema.FiniteFromString }) },
},
},
events: {},
})
yield* rpc.register(Failing, {
standard: (_input, context) =>
Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
})
expect(yield* rpc.call(Failing.namespace, "standard", undefined).pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.client(Failing).standard().pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.call(Failing.namespace, "effect", undefined).pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: "3" },
})
expect(yield* rpc.client(Failing).effect().pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: 3 },
})
}),
)
it.effect("keeps other event consumers running after one subscription ends", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const registration = yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
const client = rpc.client(Echo)
const first = yield* client.events.subscribe("updated").pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const second = yield* client.events
.subscribe("updated")
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("updated", { text: "first" })
const received = yield* Fiber.join(first)
expect(received.map((event) => event.data.text)).toEqual(["first"])
Reflect.set(received[0].location, "directory", "/consumer-mutated")
yield* registration.events.emit("updated", { text: "second" })
expect((yield* Fiber.join(second)).map((event) => event.data.text)).toEqual(["first", "second"])
}),
)
it.effect("validates plain JSON Schema inputs and outputs without type inference", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Raw = Rpc.define({
namespace: "raw",
methods: { count: { input: { type: "integer", minimum: 0 }, output: { type: "integer", minimum: 1 } } },
events: {
counted: {
schema: {
type: "object",
properties: { count: { type: "integer", minimum: 1 } },
required: ["count"],
additionalProperties: false,
},
},
},
})
const registration = yield* rpc.register(Raw, { count: (value) => Effect.succeed(value) })
expect(yield* rpc.call(Raw.namespace, "count", 42)).toBe(42)
expect(Exit.isFailure(yield* rpc.call(Raw.namespace, "count", "42").pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* rpc.call(Raw.namespace, "count", 0).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true)
}),
)
it.effect("supports methods with no input and no returned value", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Empty = Rpc.define({
namespace: "empty",
methods: { ping: { input: z.undefined(), output: z.undefined() } },
events: {},
})
yield* rpc.register(Empty, { ping: () => Effect.undefined })
expect(yield* rpc.client(Empty).ping()).toBeUndefined()
}),
)
it.effect("keeps in-flight calls on their original implementation after removal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registration = yield* rpc.register(Echo, {
echo: (value) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(value)),
})
const call = yield* rpc.client(Echo).echo("original").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* registration.dispose
yield* rpc.register(Echo, { echo: () => Effect.succeed("replacement") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("replacement")
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(call)).toBe("original")
}),
)
it.effect("interrupts the running Effect handler when its call is cancelled", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
yield* rpc.register(Echo, {
echo: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
),
})
const call = yield* rpc.client(Echo).echo("hello").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(call)
yield* Deferred.await(stopped)
}),
)
it.effect("isolates registrations and subscriptions while publishing location-tagged events on the shared bus", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") })
const otherContext = yield* Layer.build(
LayerNode.compile(Rpc.node, [
[Bus.node, Layer.succeed(Bus.Service, bus)],
[Location.node, Layer.succeed(Location.Service, location(otherRef))],
]).pipe(Layer.fresh),
)
const other = Context.get(otherContext, Rpc.Service)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
expect(Exit.isFailure(yield* other.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
const second = yield* other.register(Echo, { echo: () => Effect.succeed("second") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("first")
expect(yield* other.client(Echo).echo("hello")).toBe("second")
const all: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
all.push(event)
}),
)
const localEvents = yield* rpc
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const otherEvents = yield* other
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* second.events.emit("updated", { text: "second" })
yield* first.events
.emit("updated", { text: "first" })
.pipe(Effect.provideService(Location.Service, location(otherRef)))
expect((yield* Fiber.join(localEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "first" },
location: ref,
})
expect((yield* Fiber.join(otherEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "second" },
location: otherRef,
})
expect(all.map((event) => event.location)).toEqual([otherRef, ref])
yield* unsubscribe
}),
)
})
+1 -1
View File
@@ -1200,7 +1200,7 @@ function codegenAsts(roots: ReadonlyArray<SchemaAST.AST>) {
"id" in representation &&
representation.id === "effect/schema/Json"
) {
return Schema.Json.ast
return ast.context?.isOptional ? Schema.optionalKey(Schema.Json).ast : Schema.Json.ast
}
if (ast.annotations?.["~constructor"] !== undefined && ast.typeParameters[0] !== undefined) {
const identifier = SchemaAST.resolveIdentifier(ast)
@@ -582,6 +582,28 @@ describe("HttpApiCodegen.generate", () => {
)
})
test("preserves optional keys when HTTP normalization converts unknown to JSON", () => {
const OptionalUnknown = Schema.optionalKey(Schema.Unknown).pipe(
Schema.decodeTo(Schema.optional(Schema.Unknown), {
decode: SchemaGetter.passthrough({ strict: false }),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/rpc", {
success: Schema.Struct({ output: OptionalUnknown }).annotate({ identifier: "RpcOutput" }),
}),
),
),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'export type RpcOutput = { readonly "output"?: JsonValue }',
)
})
test("supports name-discriminated Promise errors", () => {
class NamedError extends Schema.Error<NamedError>("NamedError")(
{ name: Schema.Literal("NamedError"), message: Schema.String },
+1 -1
View File
@@ -6,7 +6,7 @@
"license": "MIT",
"scripts": {
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit",
"typecheck": "tsgo --noEmit -p tsconfig.tests.json",
"build": "tsc -p tsconfig.build.json"
},
"exports": {
+1
View File
@@ -12,6 +12,7 @@ export { Model } from "@opencode-ai/schema/model"
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+2
View File
@@ -13,6 +13,7 @@ import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
import type { ReferenceDomain } from "./reference.js"
import type { RpcDomain } from "./rpc.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
@@ -39,6 +40,7 @@ export interface Context {
readonly permission: PermissionDomain
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
+29
View File
@@ -0,0 +1,29 @@
import type { RpcApi } from "@opencode-ai/client/effect/api"
export type { RpcClient } from "@opencode-ai/client/effect/api"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { Effect, Scope } from "effect"
import type { Registration } from "./registration.js"
export interface RpcCallContext<M extends Rpc.Method> {
readonly error: Rpc.ErrorFactory<M>
}
export type RpcHandlers<D extends Rpc.Definition> = {
readonly [Name in keyof D["methods"]]: (
input: Rpc.Output<D["methods"][Name]["input"]>,
context: RpcCallContext<D["methods"][Name]>,
) => Effect.Effect<Rpc.HandlerOutput<D["methods"][Name]["output"]>, Rpc.HandlerError<D["methods"][Name]>>
}
export interface RpcRegistration<D extends Rpc.Definition> extends Registration {
readonly events: {
readonly emit: (...args: Rpc.EventInput<D>) => Effect.Effect<void, unknown>
}
}
export interface RpcDomain extends RpcApi<Rpc.SystemError, never, unknown> {
readonly register: <const D extends Rpc.Definition>(
definition: D,
handlers: RpcHandlers<NoInfer<D>>,
) => Effect.Effect<RpcRegistration<D>, unknown, Scope.Scope>
}
+154 -5
View File
@@ -1,14 +1,23 @@
import { Tool } from "@opencode-ai/schema/tool"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { RpcCallOptions, RpcEventPayload } from "@opencode-ai/client/promise/api"
import { Effect, Schema, SchemaAST, Stream } from "effect"
import type { Scope } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
import { define } from "../effect/plugin.js"
import type { Context, Plugin } from "./plugin.js"
import type { Plugin } from "./plugin.js"
import type { Info } from "./tool.js"
import type { RpcDomain, RpcHandlers } from "./rpc.js"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
type PromiseContext = Parameters<Plugin["setup"]>[0]
type PromiseEvent = ReturnType<PromiseContext["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
type HostRpc = Parameters<Parameters<typeof define>[0]["effect"]>[0]["rpc"]
type StreamAdapter = <A, E>(
stream: Stream.Stream<A, E>,
options?: { readonly signal?: AbortSignal },
) => AsyncIterable<A>
interface CompiledEndpoint {
readonly decode: ReadonlyArray<(input: unknown) => Effect.Effect<unknown, Schema.SchemaError>>
@@ -18,6 +27,143 @@ interface CompiledEndpoint {
const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
interface HostRpcCallContext {
readonly error: (type: string, message: string, data?: unknown) => unknown
}
class ReturnedRpcError extends Error {
constructor(
readonly type: string,
message: string,
readonly data?: unknown,
) {
super(message)
}
}
const makeStreams = Effect.fn("Plugin.Event.makeStreams")(function* () {
const context = yield* Effect.context<Scope.Scope>()
const subscriptions = new Set<() => Promise<IteratorResult<unknown>>>()
// Async iterators own separate scopes, so close them when the plugin unloads.
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.all(Array.from(subscriptions, (close) => close()))))
return (<A, E>(stream: Stream.Stream<A, E>, options?: { readonly signal?: AbortSignal }): AsyncIterable<A> => ({
[Symbol.asyncIterator]() {
const iterator = Stream.toAsyncIterableWith(stream, context)[Symbol.asyncIterator]()
const close = () => {
subscriptions.delete(close)
options?.signal?.removeEventListener("abort", abort)
return iterator.return?.() ?? Promise.resolve({ done: true as const, value: undefined })
}
const abort = () => {
void close()
}
subscriptions.add(close)
options?.signal?.addEventListener("abort", abort, { once: true })
if (options?.signal?.aborted) abort()
return {
next: () =>
iterator.next().then(
(result) => (result.done ? close().then(() => result) : result),
(error: unknown) => close().then(() => Promise.reject(error)),
),
return: close,
}
},
})) satisfies StreamAdapter
})
const rpcFromEffect = Effect.fn("Plugin.Rpc.fromEffect")(function* (host: HostRpc, streams: StreamAdapter) {
const context = yield* Effect.context<Scope.Scope>()
const run = Effect.runPromiseWith(context)
const client = (definition: Rpc.PortableDefinition) => {
const local = host(definition)
const subscribe = (
name: string,
options?: Pick<RpcCallOptions, "signal">,
): AsyncIterable<RpcEventPayload<Rpc.PortableDefinition>> => streams(local.events.subscribe(name), options)
return Object.assign(
Object.fromEntries(
Object.keys(definition.methods).map((name) => [
name,
(input: unknown, options?: Pick<RpcCallOptions, "signal">) => {
// SAFETY: The local client was built from this definition, so every declared key is an Effect method.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
const method = local[name] as (input: unknown) => Effect.Effect<unknown, unknown>
return run(method(input), { signal: options?.signal })
},
]),
),
{
events: {
subscribe,
on: (
name: string,
handler: (event: RpcEventPayload<Rpc.PortableDefinition>) => Promise<void> | void,
options?: Pick<RpcCallOptions, "signal">,
) => {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
void (async () => {
for await (const event of subscribe(name, { signal })) await handler(event)
})().catch((error: unknown) => run(Effect.logError(error)))
return () => controller.abort()
},
},
},
)
}
const register = (definition: Rpc.PortableDefinition, handlers: RpcHandlers<Rpc.PortableDefinition>) =>
run(
host.register(
definition,
// SAFETY: Each entry preserves its definition key; Core restores that method's erased schema and error types.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Object.fromEntries(
Object.entries(handlers).map(([name, handler]) => [
name,
(input: unknown, context: HostRpcCallContext) =>
Effect.tryPromise({
try: (signal) => {
// SAFETY: Promise RPC handlers return Promise values before this adapter erases their concrete types.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Reflect.apply(handler, undefined, [
input,
{
signal,
error: (type: string, message: string, data?: unknown) =>
new ReturnedRpcError(type, message, data),
},
]) as Promise<unknown>
},
catch: (error) => hostRpcError(context, error),
}).pipe(
Effect.flatMap((result) =>
result instanceof ReturnedRpcError
? Effect.fail(hostRpcError(context, result))
: Effect.succeed(result),
),
),
]),
) as never,
),
).then((registration) => ({
dispose: () => run(registration.dispose),
events: { emit: (...args: Rpc.EventInput<Rpc.PortableDefinition>) => run(registration.events.emit(...args)) },
}))
// SAFETY: Client and register implement RpcDomain from the same portable definitions and schema adapters.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(client, { register }) as RpcDomain
})
function hostRpcError(context: HostRpcCallContext, error: unknown) {
if (!(error instanceof ReturnedRpcError)) return error
return context.error(error.type, error.message, error.data)
}
function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
const cached = compiledEndpoints.get(endpoint)
if (cached) return cached
@@ -91,6 +237,7 @@ export function fromPromise(plugin: Plugin) {
const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const context = yield* Effect.context<Scope.Scope>()
const streams = yield* makeStreams()
// Run a hook registration on the plugin scope and resolve once it is registered.
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
@@ -135,7 +282,7 @@ export function fromPromise(plugin: Plugin) {
}),
)
const context2: Context = {
const context2: PromiseContext = {
app: host.app,
location: host.location,
options: host.options,
@@ -181,12 +328,13 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.command.reload()),
},
event: {
subscribe: () =>
Stream.toAsyncIterable(
subscribe: (options) =>
streams(
host.event.subscribe().pipe(
Stream.mapEffect((event) => Schema.encodeUnknownEffect(OpenCodeEvent)(event)),
Stream.map((event) => event as unknown as PromiseEvent),
),
options,
),
},
experimental: {
@@ -295,6 +443,7 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
rpc: yield* rpcFromEffect(host.rpc, streams),
skill: {
list: adaptApiMethod(SkillEndpoints["skill.list"], host.skill.list),
transform: transform(host.skill),
+1
View File
@@ -13,6 +13,7 @@ export { Model } from "@opencode-ai/schema/model"
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
+2
View File
@@ -13,6 +13,7 @@ import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
import type { ReferenceDomain } from "./reference.js"
import type { RpcDomain } from "./rpc.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
@@ -39,6 +40,7 @@ export interface Context {
readonly permission: PermissionDomain
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
+31
View File
@@ -0,0 +1,31 @@
import type { RpcApi, RpcCallOptions } from "@opencode-ai/client/promise/api"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { Registration } from "./registration.js"
export type { RpcEventPayload } from "@opencode-ai/client/promise/api"
export interface RpcCallContext<M extends Rpc.Method> {
readonly signal: AbortSignal
readonly error: Rpc.ErrorFactory<M>
}
export type RpcHandlers<D extends Rpc.PortableDefinition> = {
readonly [Name in keyof D["methods"]]: (
input: Rpc.Output<D["methods"][Name]["input"]>,
context: RpcCallContext<D["methods"][Name]>,
) => Promise<Rpc.HandlerOutput<D["methods"][Name]["output"]> | Rpc.HandlerError<D["methods"][Name]>>
}
export interface RpcRegistration<D extends Rpc.PortableDefinition> extends Registration {
readonly events: {
readonly emit: (...args: Rpc.EventInput<D>) => Promise<void>
}
}
export interface RpcDomain
extends RpcApi<Pick<RpcCallOptions, "signal"> & { readonly location?: never; readonly headers?: never }> {
readonly register: <const D extends Rpc.PortableDefinition>(
definition: D,
handlers: RpcHandlers<NoInfer<D>>,
) => Promise<RpcRegistration<D>>
}
+1
View File
@@ -0,0 +1 @@
export { Rpc } from "@opencode-ai/schema/rpc"
+3 -1
View File
@@ -58,10 +58,12 @@ interface LocationCollection<Value> {
invalidate(location?: LocationRef): void
}
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export interface Data {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
handler: (event: OpenCodeEventMap[Type]) => void,
) => () => void
readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void
readonly session: {
@@ -11,6 +11,7 @@ import { Model } from "@opencode-ai/schema/model"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
@@ -18,6 +19,8 @@ import { WebSearch } from "@opencode-ai/schema/websearch"
const Plugin = await import("../src/effect/index")
const PromisePlugin = await import("../src/promise/index")
const TuiPlugin = await import("../src/tui/index")
const PromiseEvent = await import("../src/promise/event")
const PromiseRpc = await import("../src/promise/rpc")
test.each([
["effect", Plugin],
@@ -34,6 +37,7 @@ test.each([
expect(entrypoint.PersistentPty).toBe(PersistentPty)
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Rpc).toBe(Rpc)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.Vcs).toBe(Vcs)
expect(entrypoint.WebSearch).toBe(WebSearch)
@@ -50,6 +54,7 @@ test.each([
"Plugin",
"Provider",
"Reference",
"Rpc",
"Skill",
"Vcs",
"WebSearch",
@@ -67,3 +72,8 @@ test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
})
test("Promise domain modules do not expose Effect adapter internals", () => {
expect(Object.keys(PromiseEvent)).toEqual([])
expect(Object.keys(PromiseRpc)).toEqual([])
})
+171
View File
@@ -0,0 +1,171 @@
import type { OpenCodeClient, RpcApi } from "@opencode-ai/client/effect"
import type { RpcHandlers, RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Schema, Stream } from "effect"
import type { Scope } from "effect"
import { Acme, EffectAcme } from "./rpc.fixture.js"
import type { Assert, Equal } from "./rpc.fixture.js"
declare const client: { readonly rpc: RpcApi<"transport-failure"> }
declare const ctx: Plugin.Context
declare const actualClient: OpenCodeClient
declare const name: "updated" | "progress"
declare const emission: Rpc.EventInput<typeof Acme>
const acme = client.rpc(Acme)
const search = acme.search({ query: "hello" })
const count = acme.count({ count: "42" })
const codec = acme.codec({ count: "42" })
const raw = acme.raw({ value: "hello" })
const ping = acme.ping()
const updates = acme.events.subscribe("updated")
const actualCall = actualClient.rpc(Acme).codec({ count: "42" })
const effectCall = actualClient.rpc(EffectAcme).codec({ count: "42" })
const effectUpdates = actualClient.rpc(EffectAcme).events.subscribe("progress")
const localCall = ctx.rpc(Acme).search({ query: "hello" })
export type Checks = [
Assert<Equal<Effect.Success<typeof search>, { text: string }>>,
Assert<
Equal<
Effect.Error<typeof search>,
| "transport-failure"
| { readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } }
| { readonly type: "unavailable"; readonly message: string; readonly data?: undefined }
>
>,
Assert<Equal<Effect.Services<typeof search>, never>>,
Assert<Equal<Effect.Success<typeof count>, string>>,
Assert<Equal<Effect.Success<typeof codec>, number>>,
Assert<Equal<Effect.Success<typeof raw>, unknown>>,
Assert<Equal<Effect.Success<typeof ping>, null>>,
Assert<Equal<Stream.Success<typeof updates>, Rpc.EventPayload<typeof Acme, "updated">>>,
Assert<Equal<Stream.Error<typeof updates>, "transport-failure">>,
Assert<Equal<Stream.Services<typeof updates>, never>>,
Assert<Equal<Effect.Success<typeof actualCall>, number>>,
Assert<Equal<Effect.Services<typeof actualCall>, never>>,
Assert<Equal<Effect.Success<typeof effectCall>, number>>,
Assert<Equal<Extract<Effect.Error<typeof effectCall>, Schema.SchemaError>, Schema.SchemaError>>,
Assert<Equal<Extract<Stream.Error<typeof effectUpdates>, Schema.SchemaError>, Schema.SchemaError>>,
Assert<
Equal<
Extract<Effect.Error<typeof effectCall>, { readonly type: "invalid_count" }>,
{ readonly type: "invalid_count"; readonly message: string; readonly data: { readonly count: number } }
>
>,
Assert<
Equal<
Extract<Effect.Error<typeof localCall>, { readonly type: "not_found" }>,
{ readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } }
>
>,
]
acme.search({ query: "hello" }, { location: { directory: "/project" } })
ctx.rpc(Acme).search({ query: "hello" })
// @ts-expect-error Effect callers supply the schema's accepted input representation too.
acme.count({ count: 42 })
// @ts-expect-error Unknown method names are rejected.
acme.missing()
// @ts-expect-error Plugin handles cannot override their location.
ctx.rpc(Acme).search({ query: "hello" }, { location: { directory: "/other" } })
// @ts-expect-error Effect event clients expose Streams, not callback convenience wrappers.
acme.events.on("updated", () => {})
// @ts-expect-error Only declared local event names can be subscribed to.
acme.events.subscribe("missing")
const handlers: RpcHandlers<typeof Acme> = {
search: (input, context) => {
context.error("not_found", "Missing", { query: input.query, attempts: "1" })
context.error("unavailable", "Unavailable")
return Effect.succeed({ text: input.query })
},
count: (input) => {
input.count satisfies number
return Effect.succeed(input.count)
},
codec: (input) => {
input.count satisfies number
return Effect.succeed(input.count)
},
raw: () => Effect.succeed(1),
ping: () => Effect.succeed(null),
}
const registration = ctx.rpc.register(Acme, handlers)
export type RegistrationChecks = [
Assert<Equal<Effect.Success<typeof registration>, RpcRegistration<typeof Acme>>>,
Assert<Equal<Effect.Error<typeof registration>, unknown>>,
Assert<Equal<Effect.Services<typeof registration>, Scope.Scope>>,
]
ctx.rpc.register(Acme, {
...handlers,
search: (input) => {
input.query satisfies string
return Effect.succeed({ text: input.query })
},
})
ctx.rpc.register(Acme, {
...handlers,
search: (input, context) =>
Effect.fail(context.error("not_found", "Missing", { query: input.query, attempts: "1" })),
})
ctx.rpc.register(Acme, {
...handlers,
// @ts-expect-error Error names must be declared by the method.
search: (_input, context) => Effect.fail(context.error("missing", "Missing", {})),
})
ctx.rpc.register(Acme, {
...handlers,
search: (input, context) =>
Effect.fail(
context.error("not_found", "Missing", {
query: input.query,
// @ts-expect-error Error data uses the schema's handler-side representation.
attempts: 1,
}),
),
})
// @ts-expect-error Wrong result types cannot widen the shared definition.
ctx.rpc.register(Acme, { ...handlers, search: () => Effect.succeed({ text: 42 }) })
// @ts-expect-error Effect handlers cannot return Promises.
ctx.rpc.register(Acme, { ...handlers, search: async () => ({ text: "hello" }) })
// @ts-expect-error All declared handlers are required.
ctx.rpc.register(Acme, { search: handlers.search })
Effect.gen(function* () {
const active = yield* registration
yield* active.events.emit("updated", { itemID: "123", text: "hello" })
yield* active.events.emit("counted", { count: 42 })
yield* active.events.emit(...emission)
yield* active.dispose
// @ts-expect-error Published payloads are inferred from the selected event schema.
yield* active.events.emit("progress", { percent: "50" })
// @ts-expect-error Only local event names are accepted for publishing.
yield* active.events.emit("rpc.acme.updated", { itemID: "123", text: "hello" })
// @ts-expect-error A union name must stay correlated with its publishing payload.
yield* active.events.emit(name, { percent: 50 })
})
Stream.map(updates, (event) => {
event.type satisfies "rpc.acme.updated"
event.location.directory satisfies string
return event.data.text satisfies string
})
// @ts-expect-error Effect custom event data must also be an object.
Rpc.define({ namespace: "invalid-event", methods: {}, events: { updated: { schema: Schema.String } } })
Rpc.define({
namespace: "invalid-array-event",
methods: {},
// @ts-expect-error Effect custom event data cannot be an array.
events: { updated: { schema: Schema.Array(Schema.String) } },
})
+210
View File
@@ -0,0 +1,210 @@
import { OpenCode } from "@opencode-ai/client"
import type { RpcCallOptions, RpcEventPayload } from "@opencode-ai/client"
import { Rpc } from "@opencode-ai/plugin/rpc"
import type { RpcHandlers } from "@opencode-ai/plugin/promise/rpc"
import type { Plugin } from "@opencode-ai/plugin"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { z } from "zod"
import { Acme, EffectAcme } from "./rpc.fixture.js"
import type { Assert, Equal } from "./rpc.fixture.js"
const client = OpenCode.make({ baseUrl: "http://localhost" })
declare const ctx: Plugin.Context
const acme = client.rpc(Acme)
const search = acme.search({ query: "hello" })
const count = acme.count({ count: "42" })
const codec = acme.codec({ count: "42" })
const raw = acme.raw({ value: "hello" })
const ping = acme.ping()
export type Checks = [
Assert<Equal<typeof Acme.namespace, "acme">>,
Assert<Equal<keyof typeof Acme.methods, "search" | "count" | "codec" | "raw" | "ping">>,
Assert<Equal<typeof search, Promise<{ text: string }>>>,
Assert<Equal<typeof count, Promise<string>>>,
Assert<Equal<typeof codec, Promise<number>>>,
Assert<Equal<typeof raw, Promise<unknown>>>,
Assert<Equal<typeof ping, Promise<null>>>,
Assert<Equal<Rpc.Input<typeof Acme.methods.count.input>, { count: string }>>,
Assert<Equal<Rpc.Output<typeof Acme.methods.count.input>, { count: number }>>,
Assert<Equal<Rpc.HandlerOutput<typeof Acme.methods.count.output>, number>>,
Assert<Equal<Rpc.HandlerOutput<typeof Acme.methods.codec.output>, number>>,
Assert<Equal<Rpc.EventPayload<typeof Acme, "updated">["type"], "rpc.acme.updated">>,
Assert<Equal<RpcEventPayload<typeof Acme, "updated">["location"], { directory: string; workspaceID?: string }>>,
Assert<Equal<Rpc.Input<StandardSchemaV1<string, number>>, string>>,
Assert<Equal<Rpc.Output<StandardSchemaV1<string, number>>, number>>,
Assert<Equal<Rpc.HandlerOutput<StandardSchemaV1<string, number>>, string>>,
]
await acme.search({ query: "hello" }, { location: { directory: "/project", workspace: "workspace" } })
await acme.search({ query: "hello" }, { signal: new AbortController().signal, headers: { "x-test": "yes" } })
await acme.ping(undefined, { location: { directory: "/project" } })
await ctx.rpc(Acme).search({ query: "hello" }, { signal: new AbortController().signal })
// @ts-expect-error Native event subscriptions share base headers, not subscriber overrides.
client.event.subscribe({ headers: { authorization: "override" } })
// @ts-expect-error Query must be a string.
await acme.search({ query: 1 })
// @ts-expect-error Required method inputs cannot be omitted.
await acme.search()
// @ts-expect-error Callers supply the input representation, not the parsed value.
await acme.count({ count: 42 })
// @ts-expect-error Standard Schema callers supply the accepted input representation.
await acme.codec({ count: 42 })
// @ts-expect-error Only declared methods are callable.
await acme.missing({})
// @ts-expect-error Location is call metadata, not injected into the declared input.
await acme.search({ query: "hello", location: { directory: "/project" } })
// @ts-expect-error Plugin handles cannot select another location.
await ctx.rpc(Acme).search({ query: "hello" }, { location: { directory: "/other" } })
// @ts-expect-error Plugin handles cannot use headers to override their location either.
await ctx.rpc(Acme).search({ query: "hello" }, { headers: { "x-opencode-directory": "/other" } })
declare const remoteOptions: RpcCallOptions
// @ts-expect-error Passing options through a variable must not enable local routing overrides.
await ctx.rpc(Acme).search({ query: "hello" }, remoteOptions)
const handlers: RpcHandlers<typeof Acme> = {
search: async (input, call) => {
input.query satisfies string
call.signal satisfies AbortSignal
if (input.query === "missing")
return call.error("not_found", "Missing", { query: input.query, attempts: "1" })
if (input.query === "unavailable") throw call.error("unavailable", "Unavailable")
return { text: input.query }
},
count: async (input) => {
input.count satisfies number
// @ts-expect-error Handlers receive the parsed representation.
input.count satisfies string
return input.count
},
codec: async (input) => {
input.count satisfies number
return input.count
},
raw: async (input) => {
// @ts-expect-error Plain JSON Schema does not infer an input shape.
input.value
return 1
},
ping: async () => null,
}
// @ts-expect-error Error names must be declared by the method.
handlers.search({ query: "missing" }, { signal: AbortSignal.abort(), error: () => ({ type: "missing" }) })
// @ts-expect-error Promise clients accept portable Standard or JSON schemas, not Effect Schema.
client.rpc(EffectAcme)
// @ts-expect-error Promise plugins cannot register Effect Schema contracts.
await ctx.rpc.register(EffectAcme, { codec: async ({ count }) => count })
const registration = await ctx.rpc.register(Acme, handlers)
await registration.events.emit("updated", { itemID: "123", text: "hello" })
await registration.events.emit("progress", { percent: 50 })
await registration.events.emit("counted", { count: 42 })
await registration.dispose()
await ctx.rpc.register(Acme, {
...handlers,
search: async ({ query }) => {
query satisfies string
return { text: query }
},
})
// @ts-expect-error The definition cannot widen to accommodate an incorrect handler result.
await ctx.rpc.register(Acme, { ...handlers, search: async () => ({ text: 42 }) })
// @ts-expect-error Every declared method must have a handler.
await ctx.rpc.register(Acme, { search: handlers.search })
// @ts-expect-error Additional handlers are not declared by the namespace.
await ctx.rpc.register(Acme, { ...handlers, missing: async () => null })
// @ts-expect-error Promise handlers must not return synchronous values.
await ctx.rpc.register(Acme, { ...handlers, ping: () => null })
// @ts-expect-error Standard Schema output transforms consume their input type.
await ctx.rpc.register(Acme, { ...handlers, count: async () => "42" })
// @ts-expect-error Effect output codecs encode the decoded result type.
await ctx.rpc.register(Acme, { ...handlers, codec: async () => "42" })
// @ts-expect-error Event payloads must match their schema.
await registration.events.emit("updated", { itemID: 123, text: "hello" })
// @ts-expect-error Publishing accepts only declared local event names.
await registration.events.emit("missing", {})
// @ts-expect-error Publishing applies the output schema, rather than accepting its transformed result.
await registration.events.emit("counted", { count: "42" })
const unsubscribe = acme.events.on("updated", (event) => {
event.type satisfies "rpc.acme.updated"
event.data.text satisfies string
event.location.directory satisfies string
// @ts-expect-error Payloads are selected by the event name.
event.data.percent
})
unsubscribe satisfies () => void
declare const withoutLocation: Omit<RpcEventPayload<typeof Acme, "updated">, "location">
// @ts-expect-error Custom events always carry their emitting location.
withoutLocation satisfies RpcEventPayload<typeof Acme, "updated">
for await (const event of acme.events.subscribe("counted")) {
event.data.text satisfies string
}
declare const name: "updated" | "progress"
// @ts-expect-error A union name cannot publish a payload matching only one possible event.
await registration.events.emit(name, { percent: 50 })
declare const emission: Rpc.EventInput<typeof Acme>
await registration.events.emit(...emission)
for await (const event of acme.events.subscribe(name)) {
if (event.type === "rpc.acme.updated") {
event.data.text satisfies string
continue
}
event.data.percent satisfies number
}
// @ts-expect-error Subscriptions use local names, not fully prefixed wire types.
acme.events.subscribe("rpc.acme.updated")
// @ts-expect-error Unknown event names are rejected by the convenience wrapper too.
acme.events.on("missing", () => {})
// @ts-expect-error Event subscriptions do not accept per-subscriber headers.
acme.events.subscribe("updated", { headers: { "x-test": "yes" } })
// @ts-expect-error Event subscriptions are not location-filtered externally.
acme.events.on("updated", () => {}, { location: { directory: "/project" } })
// @ts-expect-error Every method requires an output schema.
Rpc.define({ namespace: "invalid", methods: { search: { input: Acme.methods.search.input } }, events: {} })
Rpc.define({
namespace: "invalid-error",
methods: {
search: {
input: z.string(),
output: z.string(),
// @ts-expect-error Error names beginning with rpc. are reserved for framework failures.
errors: { "rpc.internal": z.undefined() },
},
},
events: {},
})
// @ts-expect-error The subclient's events member is reserved, not an RPC method.
Rpc.define({ namespace: "invalid", methods: { events: Acme.methods.search }, events: {} })
// @ts-expect-error Custom event data must be an object.
Rpc.define({ namespace: "invalid-event", methods: {}, events: { updated: { schema: z.string() } } })
// @ts-expect-error Custom event data cannot be an array.
Rpc.define({ namespace: "invalid-array-event", methods: {}, events: { updated: { schema: z.array(z.string()) } } })
// @ts-expect-error Plain JSON Schema events must declare an object root.
Rpc.define({ namespace: "invalid-json-event", methods: {}, events: { updated: { schema: { type: "string" } } } })
const LocationInput = Rpc.define({
namespace: "location-input",
methods: {
echo: {
input: z.object({ location: z.string() }),
output: z.object({ location: z.string() }),
},
},
events: {},
})
await client.rpc(LocationInput).echo({ location: "a plugin-defined field" }, { location: { directory: "/project" } })
+54
View File
@@ -0,0 +1,54 @@
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Schema } from "effect"
import type { Types } from "effect"
import { z } from "zod"
export const Acme = Rpc.define({
namespace: "acme",
methods: {
search: {
input: z.object({ query: z.string() }),
output: z.object({ text: z.string() }),
errors: {
not_found: z.object({ query: z.string(), attempts: z.string().transform(Number) }),
unavailable: z.undefined(),
},
},
count: {
input: z.object({ count: z.string().transform(Number) }),
output: z.number().transform(String),
},
codec: {
input: z.object({ count: z.string().transform(Number) }),
output: z.number(),
},
raw: {
input: { type: "object", properties: { value: { type: "string" } }, required: ["value"] },
output: { type: "integer" },
},
ping: {
input: z.undefined(),
output: z.null(),
},
},
events: {
updated: { schema: z.object({ itemID: z.string(), text: z.string() }) },
progress: { schema: z.object({ percent: z.number() }) },
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
export const EffectAcme = Rpc.define({
namespace: "effect-acme",
methods: {
codec: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { invalid_count: Schema.Struct({ count: Schema.FiniteFromString }) },
},
},
events: { progress: { schema: Schema.Struct({ percent: Schema.Number }) } },
})
export type Equal<A, B> = Types.Equals<A, B>
export type Assert<T extends true> = T
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { fileURLToPath } from "node:url"
import { Acme } from "./rpc.fixture.js"
test("definitions preserve their schemas and namespace without registering anything", () => {
expect(Rpc.define(Acme)).toBe(Acme)
expect(Acme.namespace).toBe("acme")
expect(Object.keys(Acme.events)).toEqual(["updated", "progress", "counted"])
})
test("defining an RPC contract does not invoke its schema parser", () => {
const schema = {
"~standard": {
version: 1 as const,
vendor: "test",
validate: () => {
throw new Error("Definition must not parse values")
},
},
}
const definition = Rpc.define({
namespace: "portable",
methods: { echo: { input: schema, output: schema, errors: { rejected: schema } } },
events: { updated: { schema } },
})
expect(definition.methods.echo.input).toBe(schema)
expect(definition.methods.echo.output).toBe(schema)
expect(definition.methods.echo.errors.rejected).toBe(schema)
expect(definition.events.updated.schema).toBe(schema)
})
test("framework RPC error names are reserved", () => {
const schema = { type: "null" }
const errors = Object.fromEntries([["rpc.internal", schema]])
expect(() =>
Rpc.define({ namespace: "reserved", methods: { call: { input: schema, output: schema, errors } }, events: {} }),
).toThrow('RPC error names starting with "rpc." are reserved: rpc.internal')
})
test("the shared definition entrypoint bundles without Effect or host runtime dependencies", async () => {
const inputs = new Set<string>()
const result = await Bun.build({
entrypoints: [fileURLToPath(import.meta.resolve("@opencode-ai/plugin/rpc"))],
target: "browser",
plugins: [
{
name: "rpc-import-boundary",
setup(build) {
build.onLoad({ filter: /.*/ }, (args) => {
inputs.add(args.path)
return undefined
})
},
},
],
})
expect(result.success).toBe(true)
expect([...inputs].sort((a, b) => a.localeCompare(b))).toEqual(
[import.meta.resolve("@opencode-ai/plugin/rpc"), import.meta.resolve("@opencode-ai/schema/rpc")]
.map((url) => fileURLToPath(url))
.sort((a, b) => a.localeCompare(b)),
)
})
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src", "test/**/*.types.ts"]
}
+196 -1
View File
@@ -8950,6 +8950,132 @@
"summary": "List skills"
}
},
"/api/rpc/{namespace}/{method}": {
"post": {
"tags": ["rpc"],
"operationId": "v2.rpc.call",
"parameters": [
{
"name": "namespace",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "method",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Rpc.Output",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Output"
}
}
}
},
"400": {
"description": "RpcError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/RpcErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"500": {
"description": "RpcInternalError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RpcInternalErrorEncoded"
}
}
}
}
},
"description": "Dispatch a method to the currently registered RPC namespace at the requested location.",
"summary": "Call a plugin RPC",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Input"
}
}
},
"required": true
}
}
},
"/api/event": {
"get": {
"tags": ["event"],
@@ -9069,7 +9195,7 @@
}
}
},
"description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"description": "Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"summary": "Subscribe to events"
}
},
@@ -16982,6 +17108,71 @@
}
]
},
"Rpc.Input": {
"type": "object",
"properties": {
"input": {}
},
"additionalProperties": false
},
"Rpc.Output": {
"type": "object",
"properties": {
"output": {}
},
"additionalProperties": false
},
"RpcErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcError"]
},
"type": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"RpcInternalErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcInternalError"]
},
"type": {
"type": "string",
"enum": ["rpc.internal"]
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServiceHealth": {
"type": "object",
"properties": {
@@ -19157,6 +19348,10 @@
"name": "skill",
"description": "Experimental skill routes."
},
{
"name": "rpc",
"description": "Plugin RPC routes."
},
{
"name": "event",
"description": "Experimental event stream routes."
+3
View File
@@ -11,6 +11,7 @@ import { FileSystemGroup } from "./groups/fs.js"
import { makeFormGroup } from "./groups/form.js"
import { CommandGroup } from "./groups/command.js"
import { SkillGroup } from "./groups/skill.js"
import { RpcGroup } from "./groups/rpc.js"
import { EventGroup, makeEventGroup } from "./groups/event.js"
import type { Definition } from "@opencode-ai/schema/event"
import { AgentGroup } from "./groups/agent.js"
@@ -49,6 +50,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof FileSystemGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof CommandGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof SkillGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof RpcGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
@@ -168,6 +170,7 @@ const makeApiFromGroup = <
.add(FileSystemGroup.middleware(locationMiddleware))
.add(CommandGroup.middleware(locationMiddleware))
.add(SkillGroup.middleware(locationMiddleware))
.add(RpcGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(PersistentPtyGroup)
+1
View File
@@ -53,6 +53,7 @@ export const groupNames = {
"server.fs": "file",
"server.command": "command",
"server.skill": "skill",
"server.rpc": "rpc",
"server.event": "event",
"server.pty": "pty",
"server.experimental": "experimental",
+20
View File
@@ -11,6 +11,26 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
{ httpApiStatus: 400 },
) {}
export class RpcError extends Schema.TaggedError<RpcError>()(
"RpcError",
{
type: Schema.String,
message: Schema.String,
data: Schema.optional(Schema.Unknown),
},
{ httpApiStatus: 400 },
) {}
export class RpcInternalError extends Schema.TaggedError<RpcInternalError>()(
"RpcInternalError",
{
type: Schema.Literal("rpc.internal"),
message: Schema.String,
data: Schema.optional(Schema.Unknown),
},
{ httpApiStatus: 500 },
) {}
export class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
+12 -2
View File
@@ -11,9 +11,19 @@ const fields = {
location: Schema.optional(Location.Ref),
}
const rpcEvent = Schema.Struct({
id: Event.ID,
created: Schema.Finite,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.TemplateLiteral(["rpc.", Schema.String]),
location: Location.Ref,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "V2Event.rpc" })
const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
Schema.Union([
...definitions,
rpcEvent,
...(definitions.some((definition) => definition.type === "server.connected")
? []
: [
@@ -38,7 +48,7 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description:
"Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
}),
),
)
@@ -55,4 +65,4 @@ export const OpenCodeEvent = event.schema
export type OpenCodeEvent = typeof OpenCodeEvent.Type
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
export const isOpenCodeEvent = (event: { readonly type: string }): event is OpenCodeEvent =>
event.type === "server.connected" || EventManifest.isServer(event)
event.type === "server.connected" || EventManifest.isServer(event) || event.type.startsWith("rpc.")
+28
View File
@@ -0,0 +1,28 @@
import { optional } from "@opencode-ai/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { RpcError, RpcInternalError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const RpcInput = Schema.Struct({ input: optional(Schema.Unknown) }).annotate({ identifier: "Rpc.Input" })
export const RpcOutput = Schema.Struct({ output: optional(Schema.Unknown) }).annotate({ identifier: "Rpc.Output" })
export const RpcGroup = HttpApiGroup.make("server.rpc")
.add(
HttpApiEndpoint.post("rpc.call", "/api/rpc/:namespace/:method", {
params: { namespace: Schema.String, method: Schema.String },
query: LocationQuery,
payload: RpcInput,
success: RpcOutput,
error: [RpcError, RpcInternalError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.rpc.call",
summary: "Call a plugin RPC",
description: "Dispatch a method to the currently registered RPC namespace at the requested location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "rpc", description: "Plugin RPC routes." }))
+31 -1
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { isOpenCodeEvent, type OpenCodeEvent, type OpenCodeEventEncoded } from "../src/groups/event.js"
import { Schema } from "effect"
import { isOpenCodeEvent, OpenCodeEvent, type OpenCodeEventEncoded } from "../src/groups/event.js"
type JsonShape<Value> = Value extends string | number | boolean | null
? Value
@@ -21,11 +22,40 @@ type JsonShape<Value> = Value extends string | number | boolean | null
// requiring every runtime event shape to fit its encoded wire contract.
const wireReady: [JsonShape<OpenCodeEvent>] extends [JsonShape<OpenCodeEventEncoded>] ? true : false = true
// This fails to compile if the dynamic RPC branch absorbs native discriminants.
const nativeDataNarrows = (event: OpenCodeEvent) => {
if (event.type !== "session.created") return
const sessionID: string = event.data.sessionID
return sessionID
}
test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
expect(isOpenCodeEvent({ type: "rpc.acme.updated" })).toBe(true)
expect(isOpenCodeEvent({ type: "acme.updated" })).toBe(false)
})
test("decodes direct plugin RPC events", () => {
const event = {
id: "evt_rpc",
created: 1,
type: "rpc.acme.updated",
location: { directory: "/project" },
data: { itemID: "item-1", text: "hello" },
}
expect(Schema.decodeUnknownSync(OpenCodeEvent)(event)).toMatchObject(event)
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, location: undefined })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, type: "acme.updated" })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: "value" })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: [] })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: null })).toThrow()
})
test("keeps native event data discriminated by type", () => {
expect(nativeDataNarrows).toBeFunction()
})
test("keeps public event runtime values within the encoded contract", () => {
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { ClientApi, groupNames } from "../src/client.js"
import { RpcError, RpcInternalError } from "../src/errors.js"
import { RpcInput, RpcOutput } from "../src/groups/rpc.js"
test("RPC wrappers preserve JSON primitives and omit undefined fields", () => {
expect(Schema.encodeSync(RpcInput)({ input: undefined })).toEqual({})
expect(Schema.encodeSync(RpcOutput)({ output: undefined })).toEqual({})
expect(Schema.decodeUnknownSync(RpcInput)({})).toEqual({})
expect(Schema.decodeUnknownSync(RpcOutput)({})).toEqual({})
for (const value of [null, false, 123, "text", [1, 2], { location: "ordinary payload" }]) {
expect(Schema.decodeUnknownSync(RpcInput)({ input: value })).toEqual({ input: value })
expect(Schema.decodeUnknownSync(RpcOutput)({ output: value })).toEqual({ output: value })
}
})
test("RPC errors use the standard transport wrapper", () => {
expect(Schema.encodeSync(RpcError)(new RpcError({ type: "not_found", message: "Missing", data: { id: "1" } }))).toEqual(
{
_tag: "RpcError",
type: "not_found",
message: "Missing",
data: { id: "1" },
},
)
expect(Schema.encodeSync(RpcError)(new RpcError({ type: "internal", message: "Failed" }))).toEqual({
_tag: "RpcError",
type: "internal",
message: "Failed",
})
expect(
Schema.decodeUnknownSync(RpcError)({ _tag: "RpcError", type: "not_found", message: "Missing", data: {} }),
).toBeInstanceOf(RpcError)
expect(
Schema.encodeSync(RpcInternalError)(new RpcInternalError({ type: "rpc.internal", message: "Failed" })),
).toEqual({ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" })
})
test("exposes one generic RPC operation with location routing and ordinary transport errors", () => {
expect(groupNames["server.rpc"]).toBe("rpc")
expect(Object.keys(ClientApi.groups["server.rpc"].endpoints)).toEqual(["rpc.call"])
const document = OpenApi.fromApi(ClientApi)
expect(Object.keys(document.paths).filter((path) => path.startsWith("/api/rpc/"))).toEqual([
"/api/rpc/{namespace}/{method}",
])
const operation = document.paths["/api/rpc/{namespace}/{method}"]?.post
expect(operation?.operationId).toBe("v2.rpc.call")
expect(operation?.parameters).toContainEqual(
expect.objectContaining({ name: "namespace", in: "path", required: true }),
)
expect(operation?.parameters).toContainEqual(expect.objectContaining({ name: "method", in: "path", required: true }))
expect(operation?.parameters).toContainEqual(
expect.objectContaining({ name: "location", in: "query", style: "deepObject", explode: true }),
)
expect(operation?.responses).toHaveProperty("200")
expect(operation?.responses).toHaveProperty("400")
expect(operation?.responses).toHaveProperty("401")
expect(operation?.responses).toHaveProperty("500")
})
+1
View File
@@ -18,6 +18,7 @@ export { Project } from "./project.js"
export { Worktree } from "./worktree.js"
export { Provider } from "./provider.js"
export { Reference } from "./reference.js"
export { Rpc } from "./rpc.js"
export { WebSearch } from "./websearch.js"
export { Session } from "./session.js"
export { Vcs } from "./vcs.js"
+167
View File
@@ -0,0 +1,167 @@
export * as Rpc from "./rpc.js"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import type { JsonSchema, Schema } from "effect"
import type { Event } from "./event.js"
import type { Location } from "./location.js"
import type { Tool } from "./tool.js"
export type ErrorMap = Readonly<Record<string, Tool.ValueSchema>> & {
readonly [Name in `rpc.${string}`]?: never
}
export interface Method {
readonly input: Tool.ValueSchema
readonly output: Tool.ValueSchema
readonly errors?: ErrorMap
}
export type PortableValueSchema = StandardSchemaV1<unknown, unknown> | JsonSchema.JsonSchema
export interface PortableMethod extends Method {
readonly input: PortableValueSchema
readonly output: PortableValueSchema
readonly errors?: Readonly<Record<string, PortableValueSchema>> & {
readonly [Name in `rpc.${string}`]?: never
}
}
type EventDataObject = Readonly<Record<string, unknown>>
type EventValueSchema =
| Schema.Codec<EventDataObject, EventDataObject>
| StandardSchemaV1<unknown, EventDataObject>
| (JsonSchema.JsonSchema & { readonly type: "object" })
type PortableEventValueSchema =
| StandardSchemaV1<unknown, EventDataObject>
| (JsonSchema.JsonSchema & { readonly type: "object" })
export interface EventDefinition {
readonly schema: EventValueSchema
}
export type PortableEventDefinition = EventDefinition & { readonly schema: PortableEventValueSchema }
export interface Definition {
readonly namespace: string
readonly methods: Readonly<Record<string, Method>> & { readonly events?: never }
readonly events: Readonly<Record<string, EventDefinition>>
}
export interface PortableDefinition extends Definition {
readonly methods: Readonly<Record<string, PortableMethod>> & { readonly events?: never }
readonly events: Readonly<Record<string, PortableEventDefinition>>
}
export function define<const D extends Definition>(definition: D) {
const reserved = Object.values(definition.methods)
.flatMap((method) => Object.keys(method.errors ?? {}))
.find((name) => name.startsWith("rpc."))
if (reserved) throw new Error(`RPC error names starting with "rpc." are reserved: ${reserved}`)
return definition
}
export type Input<S extends Tool.ValueSchema> = S extends Schema.Top
? S["Encoded"]
: S extends StandardSchemaV1
? StandardSchemaV1.InferInput<S>
: unknown
export type Output<S extends Tool.ValueSchema> = S extends Schema.Top
? S["Type"]
: S extends StandardSchemaV1
? StandardSchemaV1.InferOutput<S>
: unknown
// Effect codecs encode handler results; Standard Schema parses them forward.
export type HandlerOutput<S extends Tool.ValueSchema> = S extends Schema.Top ? Output<S> : Input<S>
type MethodErrors<M extends Method> = M extends {
readonly errors: infer Errors extends ErrorMap
}
? Errors
: never
type ErrorSchema<M extends Method, Name extends ErrorName<M>> = MethodErrors<M>[Name]
type ErrorData<Data> = unknown extends Data
? { readonly data: Data }
: undefined extends Data
? { readonly data?: Data }
: { readonly data: Data }
type ErrorDataArguments<Data> = unknown extends Data
? [data: Data]
: undefined extends Data
? [data?: Data]
: [data: Data]
type Simplify<A> = { readonly [K in keyof A]: A[K] }
declare const HandlerErrorTypeId: unique symbol
export interface Failure<Type extends string = string, Data = unknown> {
readonly type: Type
readonly message: string
readonly data?: Data
}
export type SystemError = Failure<
| "rpc.namespace_unavailable"
| "rpc.method_not_found"
| "rpc.invalid_input"
| "rpc.invalid_output"
| "rpc.internal",
never
>
export type ErrorName<M extends Method> = M extends {
readonly errors: infer Errors extends ErrorMap
}
? Exclude<keyof Errors & string, `rpc.${string}`>
: never
export type HandlerErrorFor<M extends Method, Name extends ErrorName<M>> = Simplify<
{
readonly type: Name
readonly message: string
readonly [HandlerErrorTypeId]: true
} & ErrorData<HandlerOutput<ErrorSchema<M, Name>>>
>
export type HandlerError<M extends Method> = {
readonly [Name in ErrorName<M>]: HandlerErrorFor<M, Name>
}[ErrorName<M>]
export type MethodErrorFor<M extends Method, Name extends ErrorName<M>> = Simplify<
{
readonly type: Name
readonly message: string
} & ErrorData<Output<ErrorSchema<M, Name>>>
>
export type MethodError<M extends Method> = {
readonly [Name in ErrorName<M>]: MethodErrorFor<M, Name>
}[ErrorName<M>]
export type ErrorArguments<M extends Method, Name extends ErrorName<M>> = [
type: Name,
message: string,
...data: ErrorDataArguments<HandlerOutput<ErrorSchema<M, Name>>>,
]
export type ErrorFactory<M extends Method> = <Name extends ErrorName<M>>(
...args: ErrorArguments<M, Name>
) => HandlerErrorFor<M, Name>
export type EventInputData<S extends EventValueSchema> = S extends JsonSchema.JsonSchema
? EventDataObject
: HandlerOutput<S>
export type EventData<S extends EventValueSchema> = S extends JsonSchema.JsonSchema
? EventDataObject
: Output<S>
// Keep the event name correlated with its payload even when callers use unions.
export type EventInput<D extends Definition> = {
[Name in keyof D["events"] & string]: [name: Name, data: EventInputData<D["events"][Name]["schema"]>]
}[keyof D["events"] & string]
type EventPayloadFor<
D extends Definition,
Name extends keyof D["events"] & string,
> = Omit<Event.Payload<Event.EphemeralDefinition>, "type" | "data" | "durable" | "location"> & {
readonly type: `rpc.${D["namespace"]}.${Name}`
readonly data: EventData<D["events"][Name]["schema"]>
readonly location: Location.Ref
}
export type EventPayload<D extends Definition, Name extends keyof D["events"] & string = keyof D["events"] & string> = {
readonly [K in Name]: EventPayloadFor<D, K>
}[Name]
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import {
Agent,
Config,
@@ -47,6 +48,7 @@ describe("public event manifest", () => {
expect(EventManifest.Server.has("question.asked")).toBe(false)
expect(EventManifest.Server.has("question.replied")).toBe(false)
expect(EventManifest.Server.has("question.rejected")).toBe(false)
expect(EventManifest.Server.has("rpc.acme.updated")).toBe(false)
expect(Agent.Event.Updated.durable).toBeUndefined()
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
})
+2
View File
@@ -9,6 +9,7 @@ import { FileSystemHandler } from "./handlers/fs"
import { FormHandler } from "./handlers/form"
import { CommandHandler } from "./handlers/command"
import { SkillHandler } from "./handlers/skill"
import { RpcHandler } from "./handlers/rpc"
import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { PluginHandler } from "./handlers/plugin"
@@ -55,6 +56,7 @@ export const handlers = Layer.mergeAll(
FileSystemHandler,
CommandHandler,
SkillHandler,
RpcHandler,
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
PersistentPtyHandler,
+35
View File
@@ -0,0 +1,35 @@
import { Rpc } from "@opencode-ai/core/rpc"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
handlers.handle("rpc.call", ({ params, payload }) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const rpc = yield* Rpc.Service
const output = yield* rpc.call(params.namespace, params.method, payload.input)
return output === undefined ? {} : { output }
}).pipe(
Effect.mapError(
(error) =>
new RpcError({
type: error.type,
message: error.message,
...(error.data === undefined ? {} : { data: error.data }),
}),
),
Effect.catchDefect((error) =>
Effect.fail(
new RpcInternalError({
type: "rpc.internal",
message: error instanceof Error ? error.message : "RPC call failed",
}),
),
),
),
),
)
+357
View File
@@ -0,0 +1,357 @@
import { expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Plugin } from "@opencode-ai/plugin/effect"
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Rpc } from "@opencode-ai/schema/rpc"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
const authorization = `Basic ${btoa("opencode:secret")}`
const fixture = Effect.fn(function* (plugins: readonly Plugin.Plugin[]) {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir("opencode-rpc-server-")),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
const config = path.join(tmp.path, "config")
yield* Effect.promise(() => Promise.all([first, second, config].map((directory) => mkdir(directory))))
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
config: { directory: config, project: false, content: "{}" },
fs: { filewatcher: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* Effect.forEach(plugins, (plugin) => sdk.register(plugin))
const locations = Context.get(context, LocationServiceMap.Service)
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
return {
first,
second,
handler,
boot: (directory: string) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })))),
call: (
route: string,
body: unknown = {},
options: { directory?: string; headers?: Record<string, string>; signal?: AbortSignal } = {},
) =>
Effect.promise(() => {
const url = new URL(`/api/rpc/${route}`, "http://opencode.local")
if (options.directory) url.searchParams.set("location[directory]", options.directory)
return handler(
new Request(url, {
method: "POST",
headers: { authorization, "content-type": "application/json", ...options.headers },
body: JSON.stringify(body),
signal: options.signal,
}),
)
}),
}
})
it.live("dispatches RPC wrappers with query, header and default locations and generic failures", () =>
Effect.gen(function* () {
const Echo = Rpc.define({
namespace: "transport.echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
json: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
fail: {
input: Schema.Undefined,
output: Schema.String,
errors: { rejected: Schema.Struct({ reason: Schema.String }) },
},
defect: { input: Schema.Undefined, output: Schema.String },
invalid: { input: Schema.Undefined, output: { type: "string" } },
},
events: {},
})
const server = yield* fixture([
Plugin.define({
id: "transport-implementer",
effect: (ctx) =>
Effect.gen(function* () {
const location = (yield* ctx.agent.list()).location
yield* ctx.rpc.register(Echo, {
echo: (input) => Effect.succeed(`${location.directory}:${input}`),
json: (input) => Effect.succeed(input),
empty: () => Effect.succeed(undefined),
fail: (_input, context) =>
Effect.fail(context.error("rejected", "handler failed", { reason: "declared" })),
defect: () => Effect.die(new Error("handler defect")),
invalid: () => Effect.succeed(123),
})
}).pipe(Effect.orDie),
}),
])
yield* server.boot(server.first)
yield* server.boot(server.second)
yield* server.boot(process.cwd())
const selected = yield* server.call(
"transport.echo/echo",
{ input: "selected" },
{
directory: server.first,
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
},
)
expect(selected.status).toBe(200)
expect(yield* Effect.promise(() => selected.json())).toEqual({ output: `${server.first}:selected` })
const header = yield* server.call(
"transport.echo/echo",
{ input: "header" },
{
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
},
)
expect(yield* Effect.promise(() => header.json())).toEqual({ output: `${server.second}:header` })
const fallback = yield* server.call("transport.echo/echo", { input: "default" })
expect(yield* Effect.promise(() => fallback.json())).toEqual({ output: `${process.cwd()}:default` })
const empty = yield* server.call("transport.echo/empty")
expect(empty.status).toBe(200)
expect(yield* Effect.promise(() => empty.json())).toEqual({})
yield* Effect.forEach([null, false, 42, ["array"], { location: "ordinary input" }], (input) =>
Effect.gen(function* () {
const response = yield* server.call("transport.echo/json", { input })
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ output: input })
}),
)
const denied = yield* server.call("transport.echo/empty", {}, { headers: { authorization: "" } })
expect(denied.status).toBe(401)
yield* Effect.forEach(
[
{
route: "missing/echo",
body: {},
error: { type: "rpc.namespace_unavailable", message: "RPC namespace is unavailable: missing" },
},
{
route: "transport.echo/missing",
body: {},
error: { type: "rpc.method_not_found", message: "Unknown RPC method: transport.echo.missing" },
},
{
route: "transport.echo/fail",
body: {},
error: { type: "rejected", message: "handler failed", data: { reason: "declared" } },
},
{ route: "transport.echo/echo", body: { input: 123 }, error: { type: "rpc.invalid_input" } },
{ route: "transport.echo/invalid", body: {}, error: { type: "rpc.invalid_output" } },
],
(item) =>
Effect.gen(function* () {
const response = yield* server.call(item.route, item.body)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "RpcError",
message: expect.any(String),
...item.error,
})
}),
)
const defect = yield* server.call("transport.echo/defect")
expect(defect.status).toBe(500)
expect(yield* Effect.promise(() => defect.json())).toEqual({
_tag: "RpcInternalError",
type: "rpc.internal",
message: "handler defect",
})
const malformed = yield* server.call("transport.echo/echo", "not a wrapper")
expect(malformed.status).toBe(400)
expect(yield* Effect.promise(() => malformed.json())).toMatchObject({
_tag: "InvalidRequestError",
message: expect.any(String),
})
}),
)
it.live("request cancellation interrupts Effect RPC handlers and signals Promise RPC handlers", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
const promiseStarted = Promise.withResolvers<void>()
const promiseStopped = Promise.withResolvers<void>()
const Blocking = Rpc.define({
namespace: "blocking",
methods: { wait: { input: Schema.Undefined, output: Schema.Undefined } },
events: {},
})
const PromiseBlocking = Rpc.define({
namespace: "promise-blocking",
methods: { wait: { input: { type: "null" }, output: { type: "null" } } },
events: {},
})
const server = yield* fixture([
Plugin.define({
id: "effect-blocking",
effect: (ctx) =>
ctx.rpc
.register(Blocking, {
wait: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Deferred.succeed(stopped, undefined)),
),
})
.pipe(Effect.asVoid, Effect.orDie),
}),
fromPromise({
id: "promise-blocking",
async setup(ctx) {
await ctx.rpc.register(PromiseBlocking, {
wait: (_input, call) =>
new Promise<null>((resolve) => {
promiseStarted.resolve()
call.signal.addEventListener(
"abort",
() => {
promiseStopped.resolve()
resolve(null)
},
{ once: true },
)
}),
})
},
}),
])
yield* server.boot(server.first)
const controller = new AbortController()
const pending = yield* server
.call(
"blocking/wait",
{},
{
directory: server.first,
signal: controller.signal,
},
)
.pipe(Effect.forkScoped)
yield* Deferred.await(started)
controller.abort()
yield* Deferred.await(stopped)
expect((yield* Fiber.join(pending)).status).not.toBe(400)
const promiseController = new AbortController()
const promisePending = yield* server
.call(
"promise-blocking/wait",
{ input: null },
{
directory: server.first,
signal: promiseController.signal,
},
)
.pipe(Effect.forkScoped)
yield* Effect.promise(() => promiseStarted.promise)
promiseController.abort()
yield* Effect.promise(() => promiseStopped.promise)
expect((yield* Fiber.join(promisePending)).status).not.toBe(400)
}),
)
it.live("public SSE and generic native plugin subscriptions receive RPC events across locations", () =>
Effect.gen(function* () {
const Updates = Rpc.define({
namespace: "updates",
methods: { emit: { input: Schema.String, output: Schema.Undefined } },
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
const received: RpcEvent[] = []
const observed = yield* Deferred.make<void>()
const server = yield* fixture([
Plugin.define({
id: "updates-implementer",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Updates, {
emit: (input): Effect.Effect<undefined> =>
registration.events.emit("updated", { text: input }).pipe(Effect.as(undefined), Effect.orDie),
})
}).pipe(Effect.orDie),
}),
Plugin.define({
id: "native-observer",
effect: (ctx) =>
Effect.gen(function* () {
const directory = (yield* ctx.agent.list()).location.directory
// One observer instance should see both locations, just like the public native stream.
if (!directory.endsWith("/first")) return
yield* ctx.event.subscribe().pipe(
Stream.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
Stream.take(2),
Stream.runForEach((event) => Effect.sync(() => received.push(event))),
Effect.andThen(Deferred.succeed(observed, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.orDie),
}),
])
yield* server.boot(server.first)
yield* server.boot(server.second)
const response = yield* Effect.promise(() =>
server.handler(
new Request("http://opencode.local/api/event", {
headers: { authorization, "x-opencode-directory": encodeURIComponent(server.first) },
}),
),
)
expect(response.status).toBe(200)
if (!response.body) throw new Error("Expected an SSE body")
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel()))
expect((yield* Effect.promise(() => reader.read())).value).toContain('"type":"server.connected"')
const first = yield* server.call("updates/emit", { input: "first" }, { directory: server.first })
const second = yield* server.call("updates/emit", { input: "second" }, { directory: server.second })
expect(first.status).toBe(200)
expect(second.status).toBe(200)
const events: RpcEvent[] = []
while (events.length < 2) {
const chunk = yield* Effect.promise(() => reader.read())
if (chunk.done) throw new Error("Event stream closed before RPC events arrived")
events.push(
...chunk.value
.split("\n\n")
.filter((frame) => frame.startsWith("data: "))
.map((frame) => Schema.decodeUnknownSync(Schema.fromJsonString(OpenCodeEvent))(frame.slice(6)))
.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
)
}
yield* Deferred.await(observed)
expect(events).toMatchObject([
{
type: "rpc.updates.updated",
location: { directory: server.first },
data: { text: "first" },
},
{
type: "rpc.updates.updated",
location: { directory: server.second },
data: { text: "second" },
},
])
expect(received).toEqual(events)
}),
)
+2 -1
View File
@@ -5,6 +5,7 @@ type EventMetadata = {
directory: string | undefined
workspace: string | undefined
}
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export function useEvent() {
const client = useClient()
@@ -18,7 +19,7 @@ export function useEvent() {
function on<T extends OpenCodeEvent["type"]>(
type: T,
handler: (event: Extract<OpenCodeEvent, { type: T }>, metadata: EventMetadata) => void,
handler: (event: OpenCodeEventMap[T], metadata: EventMetadata) => void,
) {
return client.event.on(type, (event) => {
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
@@ -11,6 +11,14 @@ import type { LogLevel, LogSink } from "../../../src/context/log"
const projectID = "proj_test"
function acceptsRpcEvent(on: ReturnType<typeof useEvent>["on"]) {
on("rpc.acme.updated", (event) => {
event.type satisfies `rpc.${string}`
event.data satisfies unknown
})
}
void acceptsRpcEvent
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
+196 -1
View File
@@ -8950,6 +8950,132 @@
"summary": "List skills"
}
},
"/api/rpc/{namespace}/{method}": {
"post": {
"tags": ["rpc"],
"operationId": "v2.rpc.call",
"parameters": [
{
"name": "namespace",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "method",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Rpc.Output",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Output"
}
}
}
},
"400": {
"description": "RpcError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/RpcErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"500": {
"description": "RpcInternalError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RpcInternalErrorEncoded"
}
}
}
}
},
"description": "Dispatch a method to the currently registered RPC namespace at the requested location.",
"summary": "Call a plugin RPC",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Input"
}
}
},
"required": true
}
}
},
"/api/event": {
"get": {
"tags": ["event"],
@@ -9069,7 +9195,7 @@
}
}
},
"description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"description": "Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"summary": "Subscribe to events"
}
},
@@ -16982,6 +17108,71 @@
}
]
},
"Rpc.Input": {
"type": "object",
"properties": {
"input": {}
},
"additionalProperties": false
},
"Rpc.Output": {
"type": "object",
"properties": {
"output": {}
},
"additionalProperties": false
},
"RpcErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcError"]
},
"type": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"RpcInternalErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcInternalError"]
},
"type": {
"type": "string",
"enum": ["rpc.internal"]
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServiceHealth": {
"type": "object",
"properties": {
@@ -19157,6 +19348,10 @@
"name": "skill",
"description": "Experimental skill routes."
},
{
"name": "rpc",
"description": "Plugin RPC routes."
},
{
"name": "event",
"description": "Experimental event stream routes."
+196 -1
View File
@@ -8950,6 +8950,132 @@
"summary": "List skills"
}
},
"/api/rpc/{namespace}/{method}": {
"post": {
"tags": ["rpc"],
"operationId": "v2.rpc.call",
"parameters": [
{
"name": "namespace",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "method",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Rpc.Output",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Output"
}
}
}
},
"400": {
"description": "RpcError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/RpcErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"500": {
"description": "RpcInternalError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RpcInternalErrorEncoded"
}
}
}
}
},
"description": "Dispatch a method to the currently registered RPC namespace at the requested location.",
"summary": "Call a plugin RPC",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Rpc.Input"
}
}
},
"required": true
}
}
},
"/api/event": {
"get": {
"tags": ["event"],
@@ -9069,7 +9195,7 @@
}
}
},
"description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"description": "Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
"summary": "Subscribe to events"
}
},
@@ -16982,6 +17108,71 @@
}
]
},
"Rpc.Input": {
"type": "object",
"properties": {
"input": {}
},
"additionalProperties": false
},
"Rpc.Output": {
"type": "object",
"properties": {
"output": {}
},
"additionalProperties": false
},
"RpcErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcError"]
},
"type": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"RpcInternalErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["RpcInternalError"]
},
"type": {
"type": "string",
"enum": ["rpc.internal"]
},
"message": {
"type": "string"
},
"data": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServiceHealth": {
"type": "object",
"properties": {
@@ -19157,6 +19348,10 @@
"name": "skill",
"description": "Experimental skill routes."
},
{
"name": "rpc",
"description": "Plugin RPC routes."
},
{
"name": "event",
"description": "Experimental event stream routes."
@@ -35,18 +35,26 @@ const session = await Effect.runPromise(program.pipe(Effect.provide(FetchHttpCli
## Headers and requests
Pass default headers to `OpenCode.make`. Each operation also accepts request options for cancellation or per-request
headers.
Configure default headers on the supplied `HttpClient`. Native operations use
normal Effect interruption for cancellation. RPC methods additionally accept
per-call location, header, and signal options.
```ts
const client = yield* OpenCode.make({
baseUrl: "https://opencode.example.com",
headers: { authorization: `Bearer ${process.env.OPENCODE_TOKEN}` },
})
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
const sessions = yield* client.session.list(undefined, {
signal: AbortSignal.timeout(10_000),
})
const httpClient = yield * HttpClient.HttpClient
const client =
yield *
OpenCode.make({ baseUrl: "https://opencode.example.com" }).pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequest(httpClient, (request) =>
HttpClientRequest.setHeaders(request, { authorization: `Bearer ${process.env.OPENCODE_TOKEN}` }),
),
),
)
const sessions = yield * client.session.list()
```
## Stream events
@@ -56,11 +64,49 @@ Streaming operations such as `event.subscribe()` and `session.log()` return Effe
```ts
import { Effect, Stream } from "effect"
yield* client.event.subscribe().pipe(
Stream.runForEach((event) => Effect.logInfo("OpenCode event", { type: event.type })),
)
yield *
client.event.subscribe().pipe(Stream.runForEach((event) => Effect.logInfo("OpenCode event", { type: event.type })))
```
Native and RPC event Streams share one lazy connection per client. Constructing
a Stream does not connect; consuming it does. Stopping one consumer leaves others
running, and the last consumer leaving closes the source. Source EOF or failure
ends current subscriptions without automatic retry or replay. Late native consumers
receive the current connection marker before live events.
## Plugin RPC
Use the same shared contract as Promise clients and server plugins:
```ts
import { Acme } from "opencode-acme-plugin/rpc"
const acme = client.rpc(Acme)
const result = yield * acme.search({ query: "hello" }, { location: { directory: "/workspace" } })
yield *
acme.events
.subscribe("updated")
.pipe(
Stream.runForEach((event) =>
Effect.logInfo("Plugin event", { type: event.type, location: event.location, text: event.data.text }),
),
)
```
Method arguments and results are inferred from the contract. The second optional
argument holds `location`, `signal`, and `headers`; omitted location uses the
normal request defaults. Calls are interrupted with their consuming Effect.
Method error maps are inferred in the Effect error channel. Declared errors are
decoded through their data schemas. The typed subclient removes the generic HTTP
RPC error wrapper; reserved `rpc.*` types identify framework failures.
RPC events are typed Streams, not callback-style `on` listeners. They receive the
namespace's events from all locations, each with required `location` and a normal
prefixed type such as `rpc.acme.updated`. This differs from server-plugin handles,
which are fixed to their own location. See [plugin RPC](/build/plugins#rpc) for
definitions, schemas, registration, and live subscription semantics.
## Local background service
The Node-only `@opencode-ai/client/effect/service` entrypoint discovers, starts, authenticates, and stops the local
@@ -77,14 +123,17 @@ import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
const program = Effect.gen(function* () {
const endpoint = yield* Service.ensure()
const client = yield* OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const httpClient = yield* HttpClient.HttpClient
const client = yield* OpenCode.make({ baseUrl: endpoint.url }).pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequest(httpClient, (request) => HttpClientRequest.setHeaders(request, Service.headers(endpoint))),
),
)
return yield* client.health.get()
})
@@ -96,6 +145,6 @@ const health = await Effect.runPromise(
Discover without starting, or stop the exact registered service.
```ts
const endpoint = yield* Service.discover()
yield* Service.stop()
const endpoint = yield * Service.discover()
yield * Service.stop()
```
@@ -2,10 +2,10 @@
title: "JavaScript"
---
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
`@opencode-ai/client` is the TypeScript client for the OpenCode HTTP
API. Use it when your application connects to an OpenCode server over the
network. Its types and methods are generated from the same contract as the
[API reference](/api).
network. Its native types and methods are generated from the same contract as the
[API reference](/api). Plugin RPC types come from imported RPC definitions.
<Callout type="warning">
The V2 API and client are beta. Method names, inputs, and outputs may change before the stable release.
@@ -43,7 +43,8 @@ await client.session.prompt({
Pass default authentication or application headers to `OpenCode.make` with
`headers`. You can also supply a custom `fetch` implementation. Each operation
accepts request options as its final argument for an `AbortSignal` or
per-request headers.
per-request headers. Event subscriptions are the exception: they accept only
subscriber-local cancellation and use the base client's headers.
```ts
const client = OpenCode.make({
@@ -68,6 +69,75 @@ for await (const event of client.event.subscribe()) {
}
```
Native and RPC event subscribers share one lazy connection per client. Client,
handle, and iterable creation open no event connection; consumption starts it.
Breaking iteration or aborting a subscriber ends only that iterator. The last
subscriber leaving closes the connection. The shared source waits for active
subscribers to accept each event; consumers should buffer before performing slow work.
Subscriptions are live-only, with no replay or automatic reconnection. A source
failure ends current subscriptions; subscribe again after recovery. A late native
subscriber receives the current `server.connected` marker, not past business events.
## Plugin RPC
Import a plugin's shared contract and pass it to `client.rpc`:
```ts
import { OpenCode } from "@opencode-ai/client"
import { Acme } from "opencode-acme-plugin/rpc"
const acme = client.rpc(Acme)
const result = await acme.search(
{ query: "hello" },
{ location: { directory: "/workspace" }, signal: AbortSignal.timeout(10_000) },
)
const unsubscribe = acme.events.on("updated", (event) => {
console.log(event.type, event.location.directory, event.data.text)
})
```
The second optional method argument holds `location`, `signal`, and `headers`,
separate from the plugin-defined input. Omitted location follows native request
defaults: base location headers, then the server's working directory. No location
is selected when constructing the subclient.
Methods infer arguments and results from the definition. Schema parsing belongs
to the contract boundary; callers send the accepted input representation, while
handlers receive parsed values. The Promise client accepts Standard Schema or
plain JSON Schema definitions and does
not run schema parsers locally: the server returns already parsed and transformed
output. Effect Schema definitions require the Effect client.
Declared method errors reject like errors from other Promise client endpoints,
and caught errors remain untyped. Generic HTTP RPC error wrappers are removed by
the typed subclient. Reserved `rpc.*` framework failures remain plain RPC failures,
while unrelated authentication, transport, and protocol errors keep their normal
client representations.
RPC subscriptions use local names and receive that namespace's events across all
locations. Inspect the required `event.location` to filter them. `events.subscribe`
matches the native async iterable API:
```ts
for await (const event of acme.events.subscribe("updated")) {
console.log(event.data.text)
}
```
`events.on` is a convenience wrapper over the same source. It returns unsubscribe;
async callbacks are awaited sequentially. Callback or source failures are logged
and end that listener. Native and typed subscriptions receive the same normal
`rpc.<namespace>.<event>` envelope with direct object event data. Live subscriptions
do not replay missed events.
The server plugin must be configured and implement the namespace; importing a
definition does not register it. See [plugin RPC](/build/plugins#rpc) for the
definition and registration API. Any HTTP client can also invoke the generic
`POST /api/rpc/{namespace}/{method}` route with `{ "input": ... }` and receive
`{ "output": ... }`. Omitted input/output fields represent no value.
## Local background service
The main client entrypoints are browser-compatible and do not include local
@@ -39,9 +39,9 @@ plugins.
"./plugins/local-effect.ts",
{
"package": "@acme/opencode-effect-plugin",
"options": { "agent": "reviewer", "strict": true }
}
]
"options": { "agent": "reviewer", "strict": true },
},
],
}
```
@@ -120,6 +120,7 @@ interface Context {
readonly mcp: MCPDomain
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
@@ -145,9 +146,9 @@ Pass options with the object form in `opencode.json(c)`.
"plugins": [
{
"package": "./plugins/company-effect.ts",
"options": { "strict": true }
}
]
"options": { "strict": true },
},
],
}
```
@@ -631,6 +632,59 @@ interface Context {
}
```
### RPC
Use the same execution-neutral [`Rpc.define` builder](/build/plugins#rpc).
Effect clients and plugins accept Effect Schema, Standard Schema, or plain JSON
Schema. Promise consumers accept only the portable Standard and JSON formats.
```ts
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
import { Acme } from "./rpc.js"
export default Plugin.define({
id: "acme-effect-plugin",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Acme, {
search: ({ query }, context) =>
findText(query).pipe(
Effect.flatMap((text) =>
text
? Effect.succeed({ text })
: Effect.fail(context.error("not_found", "Result not found", { query })),
),
),
})
yield* registration.events.emit("updated", { itemID: "item-1", text: "ready" })
}).pipe(Effect.orDie),
})
```
Effect handlers use normal interruption. Registrations belong to the plugin
scope; `yield* registration.dispose` removes one explicitly. Later registrations
override earlier ones at the same location, without changing in-flight handlers.
`ctx.rpc(Acme)` returns a local typed subclient. Its methods return Effects and
`events.subscribe(name)` returns a Stream. Use scoped fibers when listening
during plugin lifetime:
```ts
const acme = ctx.rpc(Acme)
yield *
acme.events.subscribe("updated").pipe(
Stream.runForEach((event) => Effect.logInfo(event.data.text)),
Effect.forkScoped,
)
```
There is no Effect callback-style `on` API. Subscriptions are location-bound,
live-only, and close when Stream consumption stops. Events use the normal
ephemeral Bus path. Method `errors` maps become typed Effect error channels. Construct one
with `context.error(...)` and fail it with `Effect.fail`; unexpected failures and
transport errors remain separate from the declared method errors.
### References
Read references available at the current location.
@@ -95,10 +95,10 @@ Pass plugin options with the object form in `opencode.json(c)`.
{
"package": "./plugins/company.ts",
"options": {
"strict": true
}
}
]
"strict": true,
},
},
],
}
```
@@ -468,14 +468,23 @@ interface IntegrationContext {
key(input: IntegrationConnectKeyInput, requestOptions?: RequestOptions): Promise<void>
}
oauth: {
connect(input: IntegrationOauthConnectInput, requestOptions?: RequestOptions): Promise<IntegrationOauthConnectOutput>
connect(
input: IntegrationOauthConnectInput,
requestOptions?: RequestOptions,
): Promise<IntegrationOauthConnectOutput>
status(input: IntegrationOauthStatusInput, requestOptions?: RequestOptions): Promise<IntegrationOauthStatusOutput>
complete(input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions): Promise<void>
cancel(input: IntegrationOauthCancelInput, requestOptions?: RequestOptions): Promise<void>
}
command: {
connect(input: IntegrationCommandConnectInput, requestOptions?: RequestOptions): Promise<IntegrationCommandConnectOutput>
status(input: IntegrationCommandStatusInput, requestOptions?: RequestOptions): Promise<IntegrationCommandStatusOutput>
connect(
input: IntegrationCommandConnectInput,
requestOptions?: RequestOptions,
): Promise<IntegrationCommandConnectOutput>
status(
input: IntegrationCommandStatusInput,
requestOptions?: RequestOptions,
): Promise<IntegrationCommandStatusOutput>
cancel(input: IntegrationCommandCancelInput, requestOptions?: RequestOptions): Promise<void>
}
transform(callback: (draft: IntegrationDraft) => void): Promise<Registration>
@@ -570,6 +579,116 @@ interface PluginContext {
}
```
### RPC
Expose typed methods and custom events through a shared RPC definition. Keep
the contract in a browser-safe module, separate from plugin setup and server code.
`Rpc.define` is synchronous and independent of Promise or Effect execution.
```ts title="src/rpc.ts"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { z } from "zod"
export const Acme = Rpc.define({
namespace: "acme",
methods: {
search: {
input: z.object({ query: z.string() }),
output: z.object({ text: z.string() }),
errors: {
not_found: z.object({ query: z.string() }),
},
},
},
events: {
updated: {
schema: z.object({ itemID: z.string(), text: z.string() }),
},
},
})
```
Promise plugin contracts accept Standard Schema such as Zod or plain JSON Schema.
Standard Schema infers types; plain JSON Schema uses `unknown` while still
validating at runtime. Effect Schema is supported only by the Effect plugin and
client APIs. Use Standard or JSON Schema when both API styles consume a contract.
Every method declares `input` and `output` and may declare an `errors` map.
Error keys become literal error `type` values, while each schema validates and
transforms that error's `data`. Names starting with `rpc.` are reserved for
framework failures. Schemas own parsing, transformations, and Effect encoding.
RPC does not add a second generic JSON validation pass.
Custom event schemas must produce JSON objects. Scalars, arrays, `null`, and
`undefined` are not valid event data. Plain JSON Schema event definitions are
checked at emission even though they do not infer a TypeScript payload type.
Plain JSON Schema is interpreted as Draft 2020-12 and delegated directly to
Effect's JSON Schema importer and decoder. Use Standard Schema when another
dialect or parser is required.
Use `{}` for an empty event payload; only omitted method input/output represents
no value.
Register the implementation inside `setup`:
```ts title="src/index.ts"
import { Plugin } from "@opencode-ai/plugin"
import { Acme } from "./rpc.js"
export default Plugin.define({
id: "acme-plugin",
async setup(ctx) {
const registration = await ctx.rpc.register(Acme, {
search: async ({ query }, context) => {
const text = await findText(query, { signal: context.signal })
if (!text) return context.error("not_found", "Result not found", { query })
return { text }
},
})
await registration.events.emit("updated", { itemID: "item-1", text: "ready" })
},
})
```
Promise handlers receive a general second context argument with `signal` and a
typed `error(type, message, data)` constructor. They may return or throw the
constructed error; both reject callers with `{ type, message, data? }`. RPC
namespaces are independent of plugin IDs. One plugin
can implement several namespaces, and later registrations override earlier ones
at the same location. Disposal or unload removes only that registration and
reveals the previous implementation. In-flight calls retain their original handler.
Other server plugins can obtain a handle without implementing the namespace:
```ts
const acme = ctx.rpc(Acme)
const result = await acme.search({ query: "hello" })
const unsubscribe = acme.events.on("updated", (event) => {
console.log(event.type, event.location.directory, event.data.text)
})
```
Handles are immediate; each call finds the current registration. Server-plugin
handles call and subscribe within their own location and cannot override it.
`events.subscribe("updated")` returns an async iterable; `events.on` is a
callback convenience returning unsubscribe. Plugin unload closes its subscriptions.
Event keys are local names. Subscribers see normal prefixed types such as
`rpc.acme.updated`, with `id`, `created`, direct `data`, required `location`, and optional
`metadata`. Events publish through the normal ephemeral Bus path.
Subscriptions remain live-only: there is no plugin log/replay API yet, and events
while disconnected are missed. The method name `events` is reserved for the
subclient's event API.
External [clients](/build/client#plugin-rpc) use `client.rpc(Acme)` and receive
that namespace's events across all locations. The native `/api/event` stream and
typed subclients observe the same direct `rpc.<namespace>.<event>` envelope.
Neither importing the contract nor constructing a handle loads the server implementation.
Configure the plugin on the server separately.
### References
Read the references available at a location.
@@ -995,7 +1114,7 @@ Schema: [`V2EventEncoded`](/api#schema-V2EventEncoded)
```ts
interface EventContext {
subscribe(requestOptions?: RequestOptions): AsyncIterable<OpenCodeEvent>
subscribe(options?: { signal?: AbortSignal }): AsyncIterable<OpenCodeEvent>
}
```
@@ -1237,10 +1356,7 @@ await ctx.shell.hook("create.before", (event) => {
```ts
interface ShellHookContext {
hook(
name: "create.before",
callback: (event: ShellCreateBefore) => Promise<void> | void,
): Promise<Registration>
hook(name: "create.before", callback: (event: ShellCreateBefore) => Promise<void> | void): Promise<Registration>
}
interface ShellCreateBefore {
@@ -1298,7 +1414,8 @@ manifest is:
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./rpc": "./src/rpc.ts"
},
"dependencies": {
"@opencode-ai/plugin": "beta"
@@ -1306,6 +1423,9 @@ manifest is:
}
```
The `./rpc` export is optional; include it when publishing a shared RPC contract
for other plugins and clients to import without loading your implementation.
Use versions compatible with the OpenCode release you target and test the
installed package, not only a workspace-linked copy. Because the plugin API is
beta, publish compatible plugin updates when V2 entrypoints or contracts