# HTTP 服务端

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

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/server
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

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 4096` | TUI 照常运行，同时对外监听端口 |
| `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`：

```ts
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)` 推回主线程（见[事件与同步](https://daiw.net/manual/opencode/events-sync)）。只有带上 `--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` 的结果：

| 层 | 分组 | 代表路径 |
| --- | --- | --- |
| Root | `control`、`controlPlane`、`global` | `/auth/:providerID`、`/log`、`/global/health`、`/global/event`、`/global/dispose` |
| Event | `event` | `/event`（SSE） |
| PtyConnect | `pty-connect` | `/pty/:ptyID/connect`（WebSocket） |
| Instance | `session` | `/session/:sessionID/message` 等 27 个端点 |
| Instance | 其余 14 组 | `config`、`provider`、`permission`、`question`、`file`、`mcp`、`tui` 等 |
| V2 | `packages/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` 先算出目录：

```ts
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](https://daiw.net/manual/opencode/sdk-and-protocol) 生成代码。

下一篇：[事件与同步](https://daiw.net/manual/opencode/events-sync)——事件如何产生、持久与分发，客户端又如何靠一条 SSE 流保持同步。
