Przeglądaj źródła

add effect-native experimental httpapi server

Serve the question HttpApi slice directly with Effect as a parallel experimental server, keep the effectful group-builder pattern that resolves services once at layer construction time, and document the Effect-native serving path as the preferred target for parallel slices.
Kit Langton 3 dni temu
rodzic
commit
9033d5d09b

+ 2 - 0
packages/opencode/specs/effect/http-api.md

@@ -253,6 +253,8 @@ Each route-group spike should follow the same shape.
 - mount under an experimental prefix such as `/experimental/httpapi`
 - keep existing Hono routes unchanged
 - expose separate OpenAPI output for the experimental slice first
+- prefer serving the parallel experimental slice through an Effect-native server boundary (`HttpRouter.serve(...)`) instead of optimizing around Hono interop
+- treat `HttpRouter.toWebHandler(...)` as the adapter path for embedding into the existing Hono server, not as the long-term target shape
 
 ### 4. Verification
 

+ 11 - 0
packages/opencode/src/cli/cmd/serve.ts

@@ -1,4 +1,5 @@
 import { Server } from "../../server/server"
+import { ExperimentalHttpApiServer } from "../../server/instance/httpapi/server"
 import { cmd } from "./cmd"
 import { withNetworkOptions, resolveNetworkOptions } from "../network"
 import { Flag } from "../../flag/flag"
@@ -17,8 +18,18 @@ export const ServeCommand = cmd({
     const opts = await resolveNetworkOptions(args)
     const server = await Server.listen(opts)
     console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
+    const httpapi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI_PORT
+      ? await ExperimentalHttpApiServer.listen({
+          hostname: opts.hostname,
+          port: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI_PORT,
+        })
+      : undefined
+    if (httpapi) {
+      console.log(`experimental httpapi listening on http://${httpapi.hostname}:${httpapi.port}`)
+    }
 
     await new Promise(() => {})
+    await httpapi?.stop()
     await server.stop()
   },
 })

+ 1 - 0
packages/opencode/src/flag/flag.ts

@@ -47,6 +47,7 @@ export namespace Flag {
   export declare const OPENCODE_CLIENT: string
   export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
   export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
+  export const OPENCODE_EXPERIMENTAL_HTTPAPI_PORT = number("OPENCODE_EXPERIMENTAL_HTTPAPI_PORT")
   export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
 
   // Experimental

+ 4 - 4
packages/opencode/src/server/instance/httpapi/question.ts

@@ -10,7 +10,7 @@ import type { Handler } from "hono"
 
 const root = "/experimental/httpapi/question"
 
-const Api = HttpApi.make("question")
+export const QuestionApi = HttpApi.make("question")
   .add(
     HttpApiGroup.make("question")
       .add(
@@ -50,8 +50,8 @@ const Api = HttpApi.make("question")
     }),
   )
 
-const QuestionLive = HttpApiBuilder.group(
-  Api,
+export const QuestionLive = HttpApiBuilder.group(
+  QuestionApi,
   "question",
   Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
     const svc = yield* Question.Service
@@ -79,7 +79,7 @@ const web = lazy(() =>
   HttpRouter.toWebHandler(
     Layer.mergeAll(
       AppLayer,
-      HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
+      HttpApiBuilder.layer(QuestionApi, { openapiPath: `${root}/doc` }).pipe(
         Layer.provide(QuestionLive),
         Layer.provide(HttpServer.layerServices),
       ),

+ 104 - 0
packages/opencode/src/server/instance/httpapi/server.ts

@@ -0,0 +1,104 @@
+import { NodeHttpServer } from "@effect/platform-node"
+import { Context, Effect, Exit, Layer, Scope } from "effect"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
+import { createServer } from "node:http"
+import { AppRuntime } from "@/effect/app-runtime"
+import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
+import { memoMap } from "@/effect/run-service"
+import { Flag } from "@/flag/flag"
+import { InstanceBootstrap } from "@/project/bootstrap"
+import { Instance } from "@/project/instance"
+import { Filesystem } from "@/util/filesystem"
+import { QuestionApi, QuestionLive } from "./question"
+
+export namespace ExperimentalHttpApiServer {
+  export type Listener = {
+    hostname: string
+    port: number
+    url: URL
+    stop: () => Promise<void>
+  }
+
+  function text(input: string, status: number, headers?: Record<string, string>) {
+    return HttpServerResponse.text(input, { status, headers })
+  }
+
+  function decode(input: string) {
+    try {
+      return decodeURIComponent(input)
+    } catch {
+      return input
+    }
+  }
+
+  const auth = <E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
+    Effect.gen(function* () {
+      if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
+
+      const req = yield* HttpServerRequest.HttpServerRequest
+      const url = new URL(req.url, "http://localhost")
+      const token = url.searchParams.get("auth_token")
+      const header = token ? `Basic ${token}` : req.headers.authorization
+      const expected = `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`
+      if (header === expected) return yield* effect
+
+      return text("Unauthorized", 401, {
+        "www-authenticate": 'Basic realm="opencode experimental httpapi"',
+      })
+    })
+
+  const instance = <E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
+    Effect.gen(function* () {
+      const req = yield* HttpServerRequest.HttpServerRequest
+      const url = new URL(req.url, "http://localhost")
+      const raw = url.searchParams.get("directory") || req.headers["x-opencode-directory"] || process.cwd()
+      const workspace = url.searchParams.get("workspace") || undefined
+      const ctx = yield* Effect.promise(() =>
+        Instance.provide({
+          directory: Filesystem.resolve(decode(raw)),
+          init: () => AppRuntime.runPromise(InstanceBootstrap),
+          fn: () => Instance.current,
+        }),
+      )
+
+      const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
+      return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
+    })
+
+  export async function listen(opts: { hostname: string; port: number }): Promise<Listener> {
+    const scope = await Effect.runPromise(Scope.make())
+    const serverLayer = NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })
+    const routes = HttpApiBuilder.layer(QuestionApi, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
+      Layer.provide(QuestionLive),
+    )
+    const live = Layer.mergeAll(
+      serverLayer,
+      HttpRouter.serve(routes, {
+        disableListenLog: true,
+        disableLogger: true,
+        middleware: (effect) => auth(instance(effect)),
+      }).pipe(Layer.provide(serverLayer)),
+    )
+
+    const ctx = await Effect.runPromise(Layer.buildWithMemoMap(live, memoMap, scope))
+
+    const server = Context.get(ctx, HttpServer.HttpServer)
+
+    if (server.address._tag !== "TcpAddress") {
+      await Effect.runPromise(Scope.close(scope, Exit.void))
+      throw new Error("Experimental HttpApi server requires a TCP address")
+    }
+
+    const url = new URL("http://localhost")
+    url.hostname = server.address.hostname
+    url.port = String(server.address.port)
+
+    return {
+      hostname: server.address.hostname,
+      port: server.address.port,
+      url,
+      stop: () => Effect.runPromise(Scope.close(scope, Exit.void)),
+    }
+  }
+}

+ 73 - 0
packages/opencode/test/server/question-httpapi-effect-server.test.ts

@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { AppRuntime } from "../../src/effect/app-runtime"
+import { Instance } from "../../src/project/instance"
+import { Question } from "../../src/question"
+import { Server } from "../../src/server/server"
+import { ExperimentalHttpApiServer } from "../../src/server/instance/httpapi/server"
+import { SessionID } from "../../src/session/schema"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info> }) =>
+  AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
+
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
+describe("experimental question effect httpapi server", () => {
+  test("serves the question slice directly over effect http", async () => {
+    await using tmp = await tmpdir({ git: true })
+    const server = await ExperimentalHttpApiServer.listen({ hostname: "127.0.0.1", port: 0 })
+    const headers = {
+      "content-type": "application/json",
+      "x-opencode-directory": tmp.path,
+    }
+    const questions: ReadonlyArray<Question.Info> = [
+      {
+        question: "What would you like to do?",
+        header: "Action",
+        options: [
+          { label: "Option 1", description: "First option" },
+          { label: "Option 2", description: "Second option" },
+        ],
+      },
+    ]
+
+    let pending!: ReturnType<typeof ask>
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        pending = ask({
+          sessionID: SessionID.make("ses_test"),
+          questions,
+        })
+      },
+    })
+
+    try {
+      const list = await fetch(`${server.url}/experimental/httpapi/question`, { headers })
+      expect(list.status).toBe(200)
+      const items = await list.json()
+      expect(items).toHaveLength(1)
+
+      const doc = await fetch(`${server.url}/experimental/httpapi/question/doc`, { headers })
+      expect(doc.status).toBe(200)
+      const spec = await doc.json()
+      expect(spec.paths["/experimental/httpapi/question"]?.get?.operationId).toBe("question.list")
+
+      const reply = await fetch(`${server.url}/experimental/httpapi/question/${items[0].id}/reply`, {
+        method: "POST",
+        headers,
+        body: JSON.stringify({ answers: [["Option 1"]] }),
+      })
+      expect(reply.status).toBe(200)
+      expect(await pending).toEqual([["Option 1"]])
+    } finally {
+      await server.stop()
+    }
+  })
+})