mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 13:36:18 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e73d68f811 | ||
|
|
2c48f2c633 | ||
|
|
089521816d | ||
|
|
501d67ebf3 | ||
|
|
23fc225eee | ||
|
|
3e36c51f69 | ||
|
|
9455a761c5 |
@@ -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
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Rpc } from "@opencode-ai/schema/rpc"
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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) } },
|
||||
})
|
||||
@@ -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" } })
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "test/**/*.types.ts"]
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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." }))
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user