HTTP 服务端

所有前端共用的一套 Effect HttpApi:路由分组、实例定位、认证与 CORS、端口回退与 mDNS、生命周期,以及 TUI 如何不开端口复用同一个 handler。

作者 David更新于 第 28 篇(共 38 篇)

OpenCode 的内核从不直接面对用户:终端界面、Web、桌面端、ACP、SDK 都通过同一套 HTTP API 与它交谈。入口是 packages/opencode/src/server/server.ts,底层是 Effect 的 effect/unstable/httpapi 与 @effect/platform-node。

怎么起一个服务端

命令行为
opencode serve无界面服务端,打印 opencode server listening on http://...
opencode web起服务端并在浏览器打开 Web 界面
opencode --port 4096TUI 照常运行,同时对外监听端口
opencode attach http://localhost:4096终端界面连接已在运行的服务端

网络参数定义在 packages/opencode/src/cli/network.ts:--port(默认 0)、--hostname(默认 127.0.0.1)、--mdns、--mdns-domain、--cors,命令行没给的项回落到配置文件的 server 段;开了 --mdns 又没指定主机名时监听 0.0.0.0。认证靠环境变量 OPENCODE_SERVER_PASSWORD 与 OPENCODE_SERVER_USERNAME,没设密码时 serve 会警告服务端未受保护。

一个 handler,两种挂载

server.ts 有两个出口:listen() 监听 TCP 端口,Default 则把同一棵路由树包成进程内的 fetch:

export const Default = lazy(() => {
  const handler = HttpApiApp.webHandler().handler
  const app: ServerApp = {
    fetch: (request: Request) => handler(request, HttpApiApp.context),
    request(input, init) {
      return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
    },
  }
  return { app }
})

(packages/opencode/src/server/server.ts:56)

webHandler 由 HttpRouter.toWebHandler 生成,只认标准的 Request 与 Response。默认的 opencode 命令正是利用这一点:TUI 在主线程,服务端在 Bun Worker 里,中间没有端口。TUI 侧的 createWorkerFetch 把请求拆成 url、method、headers、body,经 postMessage 送进 Worker;Worker 里的 rpc.fetch(packages/opencode/src/cli/tui/worker.ts:30)拼回 Request,设了密码就补上 Authorization 头,再交给 Server.Default().app.fetch。请求地址是伪域名 http://opencode.internal,只为让 SDK 拼出合法 URL。

响应体被整个读成字符串再送回,这条隧道承载不了流式响应,于是事件另走一路:Worker 订阅 GlobalBus,逐条 Rpc.emit("global.event", event) 推回主线程(见事件与同步)。只有带上 --port、--hostname 或 --mdns 时,Worker 才调 Server.listen() 真正开端口,TUI 改走 HTTP 与 SSE。opencode run 的本地模式同理。

路由树

packages/opencode/src/server/routes/instance/httpapi/server.ts 的 createRoutes() 合并了七组路由;源码注释特意说明,Web 界面兜底路由的鉴权放在路由器中间件里,“so public static assets can bypass it”。逐个核对 groups/*.ts 的结果:

层分组代表路径
Rootcontrol、controlPlane、global/auth/:providerID、/log、/global/health、/global/event、/global/dispose
Eventevent/event(SSE)
PtyConnectpty-connect/pty/:ptyID/connect(WebSocket)
Instancesession/session/:sessionID/message 等 27 个端点
Instance其余 14 组config、provider、permission、question、file、mcp、tui 等
V2packages/server 的 Api/api/session、/api/event 等
兜底docRoute、uiRoute/doc、/*

tui 分组很特别:/tui/append-prompt、/tui/open-help 等端点并不直接操作界面,而是发布 tui.prompt.append、tui.command.execute 之类的事件,由正在运行的 TUI 从事件流里接收。uiRoute 优先返回构建时嵌入的 Web 界面,否则反向代理到 https://app.opencode.ai。

请求如何找到“实例”

一个服务端进程可以同时服务多个目录,每个目录对应一个由 InstanceStore 缓存的 InstanceContext,所以 serve 启动时不加载任何项目,实例按请求加载。定位由两个中间件接力,WorkspaceRoutingMiddleware 先算出目录:

function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string {
  return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd()
}

(packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts:86)

查询参数优先,其次是请求头,最后才是服务端进程的当前目录。两处例外:会话路由以会话自己的目录为准,从 /session/:sessionID/... 抽出 ID、查到会话后用 session.directory 覆盖请求目录;工作区可能在远端,带 ?workspace= 且目标是远程时(实验特性 experimentalWorkspaces),请求被整体代理过去,并删掉无意义的本机 directory 参数。随后 InstanceContextMiddleware 调 store.load({ directory }) 取得或创建实例,注入 InstanceRef 与 WorkspaceRef。V2 的 /api/* 则由 packages/server/src/location.ts 读 location[directory] 查询参数或 x-opencode-directory 请求头,构造 Location.Ref。

认证与 CORS

ServerAuth(packages/opencode/src/server/auth.ts)只实现 HTTP Basic,用户名默认 opencode;密码为空时所有鉴权中间件直接放行。凭据有三个入口:Authorization 请求头;查询参数 auth_token(Base64 编码的 用户名:密码);PTY 的 WebSocket——浏览器不能给升级请求加头,于是先 POST /pty/:ptyID/connect-token 换一次性票据,再带 ?ticket= 连接。实现上刻意没用 Effect 的“多种认证择一”:

Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the full handler, so a downstream failure can make the next auth alternative run and remap an authorized NotFound into Unauthorized.

(packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts:16)

安全中间件包住的是整个 handler,下游一失败就会去试下一种认证,已认证请求的 404 会被改写成 401。CORS 白名单在 packages/server/src/cors.ts:无 Origin 的请求、localhost 与 127.0.0.1 的任意端口、桌面端的 oc://renderer、Tauri 来源、*.opencode.ai,外加 --cors 与配置追加的域名。

端口、mDNS 与 WebSocket

--port 为 0 时,startWithPortFallback 沿用旧行为:“explicit 0 prefers 4096 first, then any free port”(packages/opencode/src/server/server.ts:119),所以服务端多半在 4096,SDK 的默认端口也是它。mDNS 用 bonjour-service 发布名为 opencode-<端口> 的 http 服务,监听回环地址时只警告、不发布。

/event 与 /global/event 返回 text/event-stream,带 X-Accel-Buffering: no,压缩中间件也绕开它们。WebSocket 只用于 PTY 连接和远程工作区代理,WebSocketTracker 登记每个连接,listener.stop(true) 时统一以 1001 关闭。另外,runtime-flags.ts 里的 experimentalWebSockets 与服务端无关,它控制 OpenAI Codex 插件与模型服务之间是否走 WebSocket。

生命周期

释放实例必须等响应发出之后,否则 POST /instance/dispose 自己的响应可能半路夭折:

Disposal is requested by an endpoint handler, but must run from the outer server middleware after the response has been produced. The original Request object is the stable handoff key between those two phases.

(packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts:13)

PATCH /config 与 POST /instance/dispose 的 handler 调 markInstanceForDisposal,把实例挂进以原始 Request 为键的 WeakMap,由最外层的 disposeMiddleware 在响应之后清理。全局的 disposeAllInstancesAndEmitGlobalDisposed 释放全部实例并广播 global.disposed,POST /global/dispose、修改全局配置、TUI 收到 SIGUSR2 时都会调用它;释放中每个实例发出 server.instance.disposed,TUI 据此重新拉取状态,配置热重载就是这样实现的。

OpenAPI

packages/opencode/src/server/routes/instance/httpapi/public.ts 的 PublicApi 挂了一个 transform:matchLegacyOpenApi 把 Effect 生成的规范修回旧 SDK 期望的形状。GET /doc 首次访问才生成规范;opencode generate 把它打印出来,供 SDK 生成代码。

下一篇:事件与同步——事件如何产生、持久与分发,客户端又如何靠一条 SSE 流保持同步。

本页目录