diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fb36c75cc..0f71b39a9 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -138,11 +138,12 @@ export const layer = Layer.effect( const { Server } = yield* Effect.promise(() => import("../server/server")) + const serverUrl = Server.url const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", + baseUrl: serverUrl?.toString() ?? "http://localhost:4096", directory: ctx.directory, headers: ServerAuth.headers(), - fetch: async (...args) => Server.Default().app.fetch(...args), + ...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }), }) const cfg = yield* config.get() const input: PluginInput = { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e8958c7ba..5145e26d6 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -67,7 +67,7 @@ export async function openapi() { return OpenApi.fromApi(PublicApi) } -export let url: URL +export let url: URL | undefined export async function listen(opts: ListenOptions): Promise { const listener = await Effect.runPromise(listenEffect(opts)) @@ -84,15 +84,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect) { +function makeStop(state: ListenerState, unpublishMdns: Effect.Effect, listenerUrl: URL) { return Effect.gen(function* () { const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore)) - const closeScopeOnce = yield* Effect.cached(Scope.close(state.scope, Exit.void).pipe(Effect.ignore)) + const closeScopeOnce = yield* Effect.cached( + Scope.close(state.scope, Exit.void).pipe( + Effect.ignore, + Effect.ensuring( + Effect.sync(() => { + if (url === listenerUrl) url = undefined + }), + ), + ), + ) return (close?: boolean) => Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 805c11d62..585c59cb4 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import net from "node:net" +import path from "node:path" +import { pathToFileURL } from "node:url" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" @@ -300,6 +302,57 @@ describe("HttpApi Server.listen", () => { expect(output).not.toContain("Sent HTTP response") }) + test("plugin client requests reuse the listening server instance", async () => { + await using tmp = await tmpdir({ + init: async (directory) => { + const plugin = path.join(directory, "plugin.ts") + const initialized = path.join(directory, "initialized.txt") + const completed = path.join(directory, "completed.txt") + await Bun.write( + plugin, + [ + "export default async function plugin(input) {", + ` await Bun.write(${JSON.stringify(initialized)}, (await Bun.file(${JSON.stringify(initialized)}).text().catch(() => "")) + "initialized\\n")`, + " setTimeout(async () => {", + " await input.client.config.get()", + ` await Bun.write(${JSON.stringify(completed)}, "completed")`, + " }, 50)", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(directory, "opencode.json"), + JSON.stringify({ formatter: false, lsp: false, plugin: [pathToFileURL(plugin).href] }), + ) + return { initialized, completed } + }, + }) + const previous = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + let listener: Awaited> | undefined + try { + listener = await startListener() + const response = await fetch(new URL("/config", listener.url), { + headers: { authorization: authorization(), "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + await withTimeout( + (async () => { + while (!(await Bun.file(tmp.extra.completed).exists())) await Bun.sleep(10) + })(), + 5_000, + "timed out waiting for plugin client request", + ) + expect(await Bun.file(tmp.extra.initialized).text()).toBe("initialized\n") + } finally { + if (listener) await stop(listener, "timed out cleaning up plugin client listener").catch(() => undefined) + if (previous === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = previous + } + }) + test("port 0 prefers 4096 when free", async () => { if (!(await isPortFree(4096))) return const listener = await startListener()