From 9033d5d09bcba6a5ce89f8e1b0d4a7a53c565ad8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 15:43:49 -0400 Subject: [PATCH] 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. --- packages/opencode/specs/effect/http-api.md | 2 + packages/opencode/src/cli/cmd/serve.ts | 11 ++ packages/opencode/src/flag/flag.ts | 1 + .../src/server/instance/httpapi/question.ts | 8 +- .../src/server/instance/httpapi/server.ts | 104 ++++++++++++++++++ .../question-httpapi-effect-server.test.ts | 73 ++++++++++++ 6 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/server.ts create mode 100644 packages/opencode/test/server/question-httpapi-effect-server.test.ts diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index cce3f4081f..298901cd8c 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/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 diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 73e7a18a70..ee19225ba2 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/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() }, }) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f091fa02a9..c389d60a8a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/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 diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts index c694d321df..0de5e42aad 100644 --- a/packages/opencode/src/server/instance/httpapi/question.ts +++ b/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), ), diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts new file mode 100644 index 0000000000..ef0fa11465 --- /dev/null +++ b/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 + } + + function text(input: string, status: number, headers?: Record) { + return HttpServerResponse.text(input, { status, headers }) + } + + function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } + } + + const auth = (effect: Effect.Effect) => + 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 = (effect: Effect.Effect) => + 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 { + 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)), + } + } +} diff --git a/packages/opencode/test/server/question-httpapi-effect-server.test.ts b/packages/opencode/test/server/question-httpapi-effect-server.test.ts new file mode 100644 index 0000000000..a75cac6f59 --- /dev/null +++ b/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 }) => + 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: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ] + + let pending!: ReturnType + + 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() + } + }) +})