ソースを参照

fix(plugin): reuse active server for client requests

Temporarily route plugin SDK calls through the active listener so they reuse its instance store instead of initializing plugins again through the default app. Keep the in-process fallback for commands without a listener. This is intentionally a narrow bridge until v2 owns plugin clients in the correct shared server runtime.
Dax Raad 2 ヶ月 前
コミット
87c33b3d85

+ 3 - 2
packages/opencode/src/plugin/index.ts

@@ -138,11 +138,12 @@ export const layer = Layer.effect(
 
 
         const { Server } = yield* Effect.promise(() => import("../server/server"))
         const { Server } = yield* Effect.promise(() => import("../server/server"))
 
 
+        const serverUrl = Server.url
         const client = createOpencodeClient({
         const client = createOpencodeClient({
-          baseUrl: "http://localhost:4096",
+          baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
           directory: ctx.directory,
           directory: ctx.directory,
           headers: ServerAuth.headers(),
           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 cfg = yield* config.get()
         const input: PluginInput = {
         const input: PluginInput = {

+ 14 - 6
packages/opencode/src/server/server.ts

@@ -67,7 +67,7 @@ export async function openapi() {
   return OpenApi.fromApi(PublicApi)
   return OpenApi.fromApi(PublicApi)
 }
 }
 
 
-export let url: URL
+export let url: URL | undefined
 
 
 export async function listen(opts: ListenOptions): Promise<Listener> {
 export async function listen(opts: ListenOptions): Promise<Listener> {
   const listener = await Effect.runPromise(listenEffect(opts))
   const listener = await Effect.runPromise(listenEffect(opts))
@@ -84,15 +84,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
     const state = yield* startWithPortFallback(opts)
     const state = yield* startWithPortFallback(opts)
     const address = yield* tcpAddress(state)
     const address = yield* tcpAddress(state)
     const listenerUrl = makeURL(opts.hostname, address.port)
     const listenerUrl = makeURL(opts.hostname, address.port)
-    url = listenerUrl
-
     const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
     const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
+    url = listenerUrl
 
 
     return {
     return {
       hostname: opts.hostname,
       hostname: opts.hostname,
       port: address.port,
       port: address.port,
       url: listenerUrl,
       url: listenerUrl,
-      stop: yield* makeStop(state, unpublishMdns),
+      stop: yield* makeStop(state, unpublishMdns, listenerUrl),
     }
     }
   },
   },
 )
 )
@@ -169,10 +168,19 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
   })
   })
 }
 }
 
 
-function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>) {
+function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>, listenerUrl: URL) {
   return Effect.gen(function* () {
   return Effect.gen(function* () {
     const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
     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) =>
     return (close?: boolean) =>
       Effect.gen(function* () {
       Effect.gen(function* () {

+ 53 - 0
packages/opencode/test/server/httpapi-listen.test.ts

@@ -1,5 +1,7 @@
 import { afterEach, describe, expect, test } from "bun:test"
 import { afterEach, describe, expect, test } from "bun:test"
 import net from "node:net"
 import net from "node:net"
+import path from "node:path"
+import { pathToFileURL } from "node:url"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Server } from "../../src/server/server"
 import { Server } from "../../src/server/server"
 import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
 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")
     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<ReturnType<typeof startListener>> | 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 () => {
   test("port 0 prefers 4096 when free", async () => {
     if (!(await isPortFree(4096))) return
     if (!(await isPortFree(4096))) return
     const listener = await startListener()
     const listener = await startListener()