Compare commits

...
7 changed files with 194 additions and 7 deletions

No files matched your search

+1
View File
@@ -731,6 +731,7 @@
"effect": "catalog:",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode/ai": "workspace:*",
"@opencode/httpapi-codegen": "workspace:*",
"@opencode/protocol": "workspace:*",
+1
View File
@@ -37,6 +37,7 @@
"effect": "catalog:"
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode/ai": "workspace:*",
"@opencode/httpapi-codegen": "workspace:*",
"@opencode/protocol": "workspace:*",
+8
View File
@@ -31,6 +31,13 @@ export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
}
readonly plugin: EmbeddedHost.Interface["plugins"]["register"] & OpenCodeClient["plugin"]
/**
* This instance's HTTP API for external opencode clients, with CORS but without response compression.
* Serve it with an Effect `HttpServer`; a fetch handler cannot accept PTY WebSockets. Stop that server
* before closing this instance. Node's server waits for open SSE streams, so call `closeAllConnections()`
* when stopping it. Bun closes requests idle for 10 seconds and SSE heartbeats every 15, so set `idleTimeout: 0`.
*/
readonly http: EmbeddedHost.Interface["http"]
}
export const create: <R = never>(
@@ -64,6 +71,7 @@ export const create: <R = never>(
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.destroy(workspaceID),
},
plugin: Object.assign(host.plugins.register, client.plugin),
http: host.http,
}
})
+31 -5
View File
@@ -5,19 +5,29 @@ import { SessionRestart } from "@opencode/core/session/execution/restart"
import { Session } from "@opencode/core/session"
import { Workspace } from "@opencode/core/workspace"
import { WorkspaceDriver } from "@opencode/core/workspace/driver"
import { isAllowedCorsOrigin } from "@opencode/server/cors"
import { createEmbeddedRoutes } from "@opencode/server/routes"
import type { ServerOptions } from "@opencode/server/options"
import type { LayerNode } from "@opencode/util/effect/layer-node"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { context, layer, type LogOptions } from "../logging"
import { OwnedFetch } from "./fetch"
import { SdkInstances } from "./instances"
export interface CreateOptions<R = never> extends Omit<ServerOptions, "hostname" | "port" | "password"> {
/**
* `password` requires Basic auth on `http`, as `opencode serve` does; in-process SDK calls are authorized
* automatically.
*/
export interface CreateOptions<R = never> extends Omit<ServerOptions, "hostname" | "port"> {
readonly log?: LogOptions
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
readonly instances?: SdkInstances.Options<R>
/**
* Addresses where the embedder serves `http`, reported by `/api/info` and used for pairing links. Read on
* every request, so addresses known only after startup are reported once they exist.
*/
readonly urls?: () => ReadonlyArray<string>
}
/** Host hooks for embedding opencode on a non-default runtime profile. */
@@ -29,7 +39,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* <R = never>(
options: CreateOptions<R> = {},
embed: EmbedOptions = {},
) {
const { log, workspaceProviders, instances, ...server } = options
const { log, workspaceProviders, instances, urls, ...server } = options
const selector = instances ? SdkInstances.provide(instances, yield* Effect.context<R>()) : undefined
const runtime = ManagedRuntime.make(
createEmbeddedRoutes(
@@ -42,6 +52,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* <R = never>(
? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))]
: embed.overrides,
selector ? (replacements) => SdkInstances.node(selector, replacements) : undefined,
urls,
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))),
)
@@ -50,13 +61,28 @@ export const create = Effect.fn("EmbeddedHost.create")(function* <R = never>(
// The sweep is a no-op when nothing is suspended. ManagedRuntime owns the
// fiber so recovery never delays startup but still stops with the host.
runtime.runFork(Context.get(services, SessionRestart.Service).resumeSuspendedSessions)
const http = Context.get(services, HttpRouter.HttpRouter).asHttpEffect()
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
context(services),
)(Context.get(services, HttpRouter.HttpRouter).asHttpEffect())
const transport = OwnedFetch.make(handler, runtime.dispose)
)(http)
const authorization = server.password && `Basic ${btoa(`opencode:${server.password}`)}`
const transport = OwnedFetch.make(
authorization
? (request) => {
const authorized = new Request(request)
authorized.headers.set("authorization", authorization)
return handler(authorized)
}
: handler,
runtime.dispose,
)
return {
runtime,
http: http.pipe(
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, server), maxAge: 86_400 }),
Effect.provideContext(context(services)),
),
fetch: transport.fetch,
plugins: Context.get(services, SdkPlugins.Service),
sessions: Context.get(services, Session.Service),
+2 -1
View File
@@ -17,7 +17,8 @@ export interface InstanceOptions {
readonly configure: (key: string) => InstanceConfiguration | Promise<InstanceConfiguration>
}
export interface CreateOptions extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders" | "instances"> {
export interface CreateOptions
extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders" | "instances" | "password" | "urls"> {
readonly plugins?: ReadonlyArray<Plugin.Plugin>
readonly instances?: InstanceOptions
}
+143
View File
@@ -0,0 +1,143 @@
import { createServer } from "node:http"
import { expect } from "bun:test"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpServer } from "effect/unstable/http"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCode } from "../src/effect"
const it = testEffect(Layer.empty)
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const auth = { authorization: `Basic ${btoa("opencode:secret")}` }
// Serves the embedded instance as an embedder would: the server is acquired after the instance, so it stops first.
type Sdk = typeof import("../src/effect")
type Served = {
sdk: Sdk
opencode: OpenCode.Interface
base: string
location: string
directory: string
urls: Array<string>
}
const served = <A, E, R>(f: (input: Served) => Effect.Effect<A, E, R>) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir("opencode-sdk-http-")),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((directory) =>
Effect.gen(function* () {
const sdk = yield* Effect.promise(() => import("../src/effect"))
const urls: Array<string> = []
const opencode = yield* sdk.OpenCode.create({
config: { directory: directory.path, project: false, content: "{}" },
models: { fetch: false },
fs: { filewatcher: false },
password: "secret",
urls: () => urls,
})
const node = createServer()
const server = yield* NodeHttpServer.make(() => node, { host: "127.0.0.1", port: 0 })
yield* server.serve(opencode.http).pipe(Effect.provide(NodeHttpServer.layerHttpServices))
// Close open SSE streams before the serving fiber stops.
yield* Effect.addFinalizer(() => Effect.sync(() => node.closeAllConnections()))
return yield* f({
sdk,
opencode,
urls,
base: HttpServer.formatAddress(server.address),
location: `directory=${encodeURIComponent(directory.path)}`,
directory: directory.path,
})
}),
),
Effect.scoped,
)
it.live("serves the embedded instance to external clients", () =>
served(({ sdk, opencode, base, location, directory, urls }) =>
Effect.gen(function* () {
// In-process SDK calls still work once the routes require the password.
const created = yield* opencode.sessions.create({
id: sdk.Session.ID.create(),
location: sdk.Location.Ref.make({ directory: sdk.AbsolutePath.make(directory) }),
})
const page = yield* opencode.sessions.list({ directory: sdk.AbsolutePath.make(directory) })
expect(page.data.map((session) => session.id)).toContain(created.id)
yield* Effect.promise(async () => {
expect((await fetch(`${base}/api/session?${location}`)).status).toBe(401)
expect((await fetch(`${base}/api/session?${location}`, { headers: auth })).status).toBe(200)
// Addresses known only after startup are reported once they exist.
expect((await (await fetch(`${base}/api/info`, { headers: auth })).json()).urls).toEqual([])
urls.push("https://opencode.example")
expect((await (await fetch(`${base}/api/info`, { headers: auth })).json()).urls).toEqual([
"https://opencode.example",
])
const preflight = await fetch(`${base}/api/session`, {
method: "OPTIONS",
headers: { origin: "https://app.opencode.ai", "access-control-request-method": "GET" },
})
expect(preflight.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai")
const events = await fetch(`${base}/api/event`, { headers: auth })
expect(events.headers.get("content-type")).toContain("text/event-stream")
const reader = events.body!.getReader()
expect(new TextDecoder().decode((await reader.read()).value)).toContain("server.connected")
await reader.cancel()
// A pairing code yields a session token that works as the password.
const pairing = await (await fetch(`${base}/api/pair`, { method: "POST", headers: auth })).json()
const session = await (
await fetch(`${base}/auth/connect/${pairing.code}`, { headers: { accept: "application/json" } })
).json()
const paired = await fetch(`${base}/api/session?${location}`, {
headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` },
})
expect(paired.status).toBe(200)
})
}),
),
)
ptyTest("accepts PTY WebSockets", () =>
served(({ base, location }) =>
Effect.promise(async () => {
const pty = await (
await fetch(`${base}/api/pty?${location}`, {
method: "POST",
headers: { ...auth, "content-type": "application/json" },
body: JSON.stringify({ command: "/bin/sh", args: ["-c", "echo http-pty-ready; exec cat"] }),
})
).json()
const ticket = await (
await fetch(`${base}/api/pty/${pty.data.id}/connect-token?${location}`, {
method: "POST",
headers: { ...auth, "x-opencode-ticket": "1" },
})
).json()
const url = new URL(`/api/pty/${pty.data.id}/connect?${location}`, base)
url.protocol = "ws:"
url.searchParams.set("ticket", ticket.data.ticket)
const output = await new Promise<string>((resolve, reject) => {
const socket = new WebSocket(url)
let text = ""
const timeout = setTimeout(() => reject(new Error(`PTY output missing: ${text}`)), 5_000)
socket.addEventListener("message", (event) => {
text += typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data)
if (!text.includes("http-pty-ready")) return
clearTimeout(timeout)
socket.close()
resolve(text)
})
socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("PTY WebSocket failed"))
})
})
expect(output).toContain("http-pty-ready")
}),
),
)
+8 -1
View File
@@ -100,8 +100,15 @@ export function createEmbeddedRoutes(
options: ServerOptions = {},
overrides: LayerNode.Replacements = [],
instances?: InstanceNode,
serviceURLs: () => ReadonlyArray<string> = () => [],
) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides, instances)
return makeRoutes(
ServerAuth.Config.configLayer({ password: Option.fromNullishOr(options.password) }),
options,
serviceURLs,
overrides,
instances,
)
}
function makeRoutes<AuthError, AuthServices>(