refactor(permission): serve experimental httpapi with effect

Move the permission slice onto the parallel Effect-native experimental HttpApi server, stop extending the Hono mount path for this PR, and keep the question and permission groups served through the direct Effect server boundary.
This commit is contained in:
Kit Langton
2026-04-14 21:31:24 -04:00
parent 563b93d4f1
commit 6032738a90
8 changed files with 170 additions and 50 deletions

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

@@ -1,12 +1,7 @@
import { lazy } from "@/util/lazy"
import { Hono } from "hono"
import { PermissionHttpApiHandler } from "./permission"
import { QuestionHttpApiHandler } from "./question"
export const HttpApiRoutes = lazy(() =>
new Hono()
.all("/question", QuestionHttpApiHandler)
.all("/question/*", QuestionHttpApiHandler)
.all("/permission", PermissionHttpApiHandler)
.all("/permission/*", PermissionHttpApiHandler),
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
)

View File

@@ -10,7 +10,7 @@ import type { Handler } from "hono"
const root = "/experimental/httpapi/permission"
const Api = HttpApi.make("permission")
export const PermissionApi = HttpApi.make("permission")
.add(
HttpApiGroup.make("permission")
.add(
@@ -50,10 +50,8 @@ const Api = HttpApi.make("permission")
}),
)
const PermissionLive = HttpApiBuilder.group(
Api,
"permission",
Effect.fn("PermissionHttpApi.handlers")(function* (handlers) {
export const PermissionLive = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
@@ -72,15 +70,17 @@ const PermissionLive = HttpApiBuilder.group(
return true
})
return handlers.handle("list", list).handle("reply", reply)
return HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
handlers.handle("list", list).handle("reply", reply),
)
}),
)
).pipe(Layer.provide(Permission.defaultLayer))
const web = lazy(() =>
HttpRouter.toWebHandler(
Layer.mergeAll(
AppLayer,
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
HttpApiBuilder.layer(PermissionApi, { openapiPath: `${root}/doc` }).pipe(
Layer.provide(PermissionLive),
Layer.provide(HttpServer.layerServices),
),

View File

@@ -15,7 +15,7 @@ const Reply = Schema.Struct({
}),
})
const Api = HttpApi.make("question")
export const QuestionApi = HttpApi.make("question")
.add(
HttpApiGroup.make("question")
.add(
@@ -55,10 +55,8 @@ const Api = HttpApi.make("question")
}),
)
const QuestionLive = HttpApiBuilder.group(
Api,
"question",
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
export const QuestionLive = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Question.Service
const list = Effect.fn("QuestionHttpApi.list")(function* () {
@@ -76,7 +74,9 @@ const QuestionLive = HttpApiBuilder.group(
return true
})
return handlers.handle("list", list).handle("reply", reply)
return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
handlers.handle("list", list).handle("reply", reply),
)
}),
).pipe(Layer.provide(Question.defaultLayer))
@@ -84,7 +84,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,109 @@
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 { PermissionApi, PermissionLive } from "./permission"
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 = Layer.mergeAll(
HttpApiBuilder.layer(QuestionApi, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
Layer.provide(QuestionLive),
),
HttpApiBuilder.layer(PermissionApi, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
Layer.provide(PermissionLive),
),
)
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

@@ -14,7 +14,9 @@ import type { SQL } from "../storage/db"
import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { makeRuntime } from "@/effect/run-service"
import { Log } from "../util/log"
import { fn } from "../util/fn"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"

View File

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Instance } from "../../src/project/instance"
import { Permission } from "../../src/permission"
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"
@@ -18,7 +18,7 @@ afterEach(async () => {
describe("experimental permission httpapi", () => {
test("lists pending permissions, replies, and serves docs", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.Default().app
const server = await ExperimentalHttpApiServer.listen({ hostname: "127.0.0.1", port: 0 })
const headers = {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
@@ -39,37 +39,39 @@ describe("experimental permission httpapi", () => {
},
})
const list = await app.request("/experimental/httpapi/permission", {
headers,
})
try {
const list = await fetch(`${server.url}/experimental/httpapi/permission`, { headers })
expect(list.status).toBe(200)
const items = await list.json()
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
})
expect(list.status).toBe(200)
const items = await list.json()
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
})
const doc = await app.request("/experimental/httpapi/permission/doc", {
headers,
})
const doc = await fetch(`${server.url}/experimental/httpapi/permission/doc`, { headers })
expect(doc.status).toBe(200)
const spec = await doc.json()
expect(spec.paths["/experimental/httpapi/permission"]?.get?.operationId).toBe("permission.list")
expect(spec.paths["/experimental/httpapi/permission/{requestID}/reply"]?.post?.operationId).toBe("permission.reply")
expect(doc.status).toBe(200)
const spec = await doc.json()
expect(spec.paths["/experimental/httpapi/permission"]?.get?.operationId).toBe("permission.list")
expect(spec.paths["/experimental/httpapi/permission/{requestID}/reply"]?.post?.operationId).toBe(
"permission.reply",
)
const reply = await app.request(`/experimental/httpapi/permission/${items[0].id}/reply`, {
method: "POST",
headers,
body: JSON.stringify({ reply: "once" }),
})
const reply = await fetch(`${server.url}/experimental/httpapi/permission/${items[0].id}/reply`, {
method: "POST",
headers,
body: JSON.stringify({ reply: "once" }),
})
expect(reply.status).toBe(200)
expect(await reply.json()).toBe(true)
expect(await pending).toBeUndefined()
expect(reply.status).toBe(200)
expect(await reply.json()).toBe(true)
expect(await pending).toBeUndefined()
} finally {
await server.stop()
}
})
})