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.
This commit is contained in:
Kit Langton
2026-04-14 15:43:49 -04:00
parent a4c8d0588e
commit 9033d5d09b
6 changed files with 195 additions and 4 deletions

View File

@@ -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

View File

@@ -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()
},
})

View File

@@ -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

View File

@@ -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),
),

View File

@@ -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)),
}
}
}

View File

@@ -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()
}
})
})