为什么需要一个独立的 API 服务器?
OpenCode 从立项之初就确立了一个原则:核心逻辑只实现一次,但可以被多个前端消费。
TUI 是第一个客户端,Web 应用是第二个,Tauri 桌面端是第三个,VS Code 扩展是第四个。如果核心逻辑直接嵌入 TUI,其他客户端就无从复用。
解决方案是让 packages/opencode 同时扮演两个角色:
opencode [目录] → TUI 模式:启动服务器 + 挂载 TUI
opencode serve [目录] → 无头模式:只启动服务器,等待外部客户端连接无论哪种模式,HTTP API 服务器都在运行。这是整个多端架构的基础。
技术选型:为什么选 Hono?
Node.js 生态里的 HTTP 框架从不缺选择,但 OpenCode 选择了 Hono。原因很实际:
| 关注点 | Hono 的优势 |
|---|---|
| 运行时兼容 | 原生支持 Bun、Node.js、Deno、Cloudflare Workers,无需 polyfill |
| 类型安全 | 路由级别的完整 TypeScript 类型推导 |
| 中间件 | 内置 CORS、BasicAuth、SSE、proxy、WebSocket |
| OpenAPI | hono-openapi 插件一键从代码生成规范文档 |
| 体积 | 极轻量,无外部运行时依赖 |
OpenCode 大量使用了 hono-openapi 提供的 describeRoute + validator + resolver 三件套,使每个路由处理函数同时承担"业务逻辑"和"API 文档"两项职责。
服务器入口:createApp 的中间件链
服务器的核心工厂函数在 packages/opencode/src/server/server.ts:
export const createApp = (opts: { cors?: string[] }): Hono => {
const app = new Hono()
return app
.onError(...) // 1. 统一错误处理
.use(...) // 2. BasicAuth 鉴权(可选)
.use(...) // 3. 请求日志
.use(cors(...)) // 4. CORS 白名单
.route("/global", GlobalRoutes())
.put("/auth/:providerID", ...) // 5. 认证凭据管理
.delete("/auth/:providerID", ...)
.use(async (c, next) => { // 6. 工作区上下文注入
return WorkspaceContext.provide({
workspaceID: ...,
async fn() {
return Instance.provide({ directory, init, async fn() { return next() } })
},
})
})
.use(WorkspaceRouterMiddleware)
.get("/doc", openAPIRouteHandler(...)) // 7. OpenAPI 文档
.route("/session", SessionRoutes())
.route("/provider", ProviderRoutes())
// ... 更多路由
.get("/event", ...) // 8. SSE 事件流
.all("/*", ...) // 9. 兜底代理到 app.opencode.ai
}中间件按顺序执行,每一层都有明确职责。让我们逐层深入。
第一层:统一错误处理
.onError 是 Hono 的全局错误捕获器:
.onError((err, c) => {
if (err instanceof NamedError) {
let status: ContentfulStatusCode
if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
return c.json(err.toObject(), { status })
}
if (err instanceof HTTPException) return err.getResponse()
const message = err instanceof Error && err.stack ? err.stack : err.toString()
return c.json(new NamedError.Unknown({ message }).toObject(), { status: 500 })
})关键设计:NamedError 是 OpenCode 的自定义错误基类。所有业务层抛出的错误都继承自它,因此在这里可以统一分类为 HTTP 状态码,并以结构化 JSON 返回。路由处理函数可以放心地 throw new NotFoundError(...),不需要每个路由都写 try/catch。
server/error.ts 提供了辅助函数,避免重复写 400/404 的响应 Schema:
export function errors(...codes: number[]) {
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
}
// 使用:在路由的 responses 字段里展开
describeRoute({
responses: {
200: { ... },
...errors(400, 404), // 自动引入 400 和 404 的 Schema
},
})第二层:可选的 BasicAuth
.use((c, next) => {
if (c.req.method === "OPTIONS") return next() // CORS 预检不需要鉴权
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next() // 未设置密码时跳过
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return basicAuth({ username, password })(c, next)
})通过环境变量 OPENCODE_SERVER_PASSWORD 启用 BasicAuth。这对"在远程服务器上运行 opencode serve,本地通过 SSH 隧道连接"的场景很有用。注意 CORS OPTIONS 预检请求被豁免,否则浏览器客户端会因为预检失败而无法正常工作。
第三层:CORS 精细白名单
CORS 配置直接影响哪些 Web 应用可以调用 API:
.use(cors({
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input // 本地开发
if (input.startsWith("http://127.0.0.1:")) return input
if (["tauri://localhost", "http://tauri.localhost", "https://tauri.localhost"]
.includes(input)) return input // Tauri 桌面端
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input // *.opencode.ai
if (opts?.cors?.includes(input)) return input // 命令行传入的自定义域名
return // 其他来源:拒绝
},
}))白名单涵盖四类来源:本地开发服务器、Tauri 桌面应用(有三种不同的 Origin 格式)、opencode.ai 旗下的所有子域名,以及启动时通过 --cors 参数指定的自定义域名。origin 函数返回字符串则允许,返回 undefined 则拒绝。
第四层:工作区上下文注入
这是整个中间件链中最核心的一层,也是最体现 OpenCode 架构思想的地方:
.use(async (c, next) => {
if (c.req.path === "/log") return next()
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(decodeURIComponent(raw))
return WorkspaceContext.provide({
workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined,
async fn() {
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() { return next() },
})
},
})
})每个请求在进入业务路由之前,都会先:
- 从查询参数或请求头读取
directory(默认process.cwd()) - 通过
Instance.provide()初始化该目录对应的项目实例(按需创建) - 将实例绑定到当前异步上下文(AsyncLocalStorage)
这意味着路由处理函数可以直接调用 Instance.directory、Instance.worktree 等,无需显式传递参数。一台服务器可以同时服务多个工作目录,每个请求自动路由到对应的项目实例。
这和 React 的 Context API 在设计思想上一脉相承:不是把依赖一层一层传下去,而是注入到一个"环境"里,需要的地方直接取。
路由组织:模块化 Hono 子路由
所有业务路由按资源类型拆分为独立文件,挂载到对应路径:
/global → GlobalRoutes() # 全局配置
/session → SessionRoutes() # 会话管理(最复杂)
/project → ProjectRoutes() # 项目信息
/pty → PtyRoutes() # 伪终端
/config → ConfigRoutes() # 配置读写
/mcp → McpRoutes() # MCP 服务器管理
/provider → ProviderRoutes() # LLM 提供商
/permission → PermissionRoutes() # 权限响应
/question → QuestionRoutes() # 用户问答
/tui → TuiRoutes() # TUI 专用
/ → FileRoutes() # 文件读取(注意挂在根路径)每个子路由都是一个标准的 Hono 实例,通过 .route() 方法挂载。这种设计让每个路由模块可以独立测试,也可以独立演进。
Session 路由:API 的核心
Session 路由是 OpenCode API 最丰富的部分,routes/session.ts 有近千行。它完整体现了 RESTful 设计加流式扩展的混合风格:
标准 CRUD
GET /session # 列表(支持 directory/roots/start/search/limit 过滤)
GET /session/status # 所有会话的当前状态
GET /session/:id # 获取单个会话
POST /session # 创建新会话
DELETE /session/:id # 删除会话
PATCH /session/:id # 更新标题或归档状态GET /session 使用 for await...of 异步生成器遍历数据库,支持流式返回大量会话:
async (c) => {
const query = c.req.valid("query")
const sessions: Session.Info[] = []
for await (const session of Session.list({
directory: query.directory,
roots: query.roots,
start: query.start,
search: query.search,
limit: query.limit,
})) {
sessions.push(session)
}
return c.json(sessions)
}会话特殊操作
POST /session/:id/init # 初始化,生成 AGENTS.md
POST /session/:id/fork # 从指定消息点分叉出新会话
POST /session/:id/abort # 中止正在运行的 AI 处理
POST /session/:id/share # 生成分享链接
DELETE /session/:id/share # 撤销分享
GET /session/:id/diff # 获取某条消息对应的文件变更 diff
POST /session/:id/summarize # LLM 压缩(第5章详述)
POST /session/:id/revert # 撤销消息效果
POST /session/:id/unrevert # 恢复被撤销的消息fork 是一个特别有价值的功能:允许用户从历史某个时间点"另开一条时间线",尝试不同的解决思路,而不破坏原来的会话记录。
消息层 API
GET /session/:id/message # 获取所有消息
GET /session/:id/message/:msgID # 获取单条消息
DELETE /session/:id/message/:msgID # 删除消息
DELETE /session/:id/message/:msgID/part/:partID # 删除某个 Part
PATCH /session/:id/message/:msgID/part/:partID # 更新某个 Part消息 API 直接操作第5章介绍的 MessageV2 Part 结构,粒度细到单个 Part 的增删改。
流式响应:两种截然不同的模式
OpenCode 服务器使用了两种流式技术,针对不同的场景。
模式一:streamSSE — 全局事件总线
GET /event 是服务器的"广播频道",所有客户端都应连接它来实时同步状态:
.get("/event", async (c) => {
c.header("X-Accel-Buffering", "no") // 禁用 Nginx 代理缓冲
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
// 立即发送连接确认
stream.writeSSE({
data: JSON.stringify({ type: "server.connected", properties: {} }),
})
// 订阅 Bus 上的所有事件,转发到 SSE 流
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({ data: JSON.stringify(event) })
if (event.type === Bus.InstanceDisposed.type) {
stream.close() // 实例销毁时关闭流
}
})
// 每 10 秒发送一次心跳,防止代理服务器因超时关闭连接
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({ type: "server.heartbeat", properties: {} }),
})
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
unsub()
resolve()
})
})
})
})SSE(Server-Sent Events)协议格式非常简单:每条消息以 data: <content>\n\n 格式发送,客户端通过浏览器原生的 EventSource API 接收。OpenCode 直接把 BusEvent 序列化为 JSON,事件的 type 字段告诉客户端如何处理。
心跳设计:10 秒一次的心跳是生产环境的必要措施。很多反向代理(Nginx、Caddy、AWS ALB)会在连接空闲一段时间后断开,心跳确保连接始终活跃。第8章介绍的 TUI SDKProvider 中的 reconnect 逻辑,也是为了处理这类意外断连。
两个响应头:
X-Accel-Buffering: no— 告诉 Nginx 不要缓冲此响应,让事件立即透传到客户端X-Content-Type-Options: nosniff— 防止浏览器对 SSE 流进行 MIME 嗅探
模式二:stream — 单请求响应流
POST /session/:id/message 用另一种方式处理流:
.post("/:sessionID/message", ..., async (c) => {
c.status(200)
c.header("Content-Type", "application/json")
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID })
stream.write(JSON.stringify(msg)) // 处理完成后一次性写入结果
})
})注意:这里用的是 stream(),不是 streamSSE()。它不遵循 SSE 协议,而是普通的 HTTP streaming。HTTP 连接保持开放,SessionPrompt.prompt() 完成(可能需要几十秒)后,才把最终的消息对象序列化写入。
为什么这样设计? 因为 AI 处理的中间过程(每个 token、每个工具调用)已经通过 SSE 事件总线广播了。已连接 /event 的客户端会实时看到进度;而 POST /session/:id/message 只需要在结束时返回最终结果供调用方记录。这是关注点分离的典型体现。
对于不需要等待结果的场景,还有 POST /session/:id/prompt_async,它直接返回 204 并在后台异步运行:
async (c) => {
c.status(204)
return stream(c, async () => {
SessionPrompt.prompt({ ...body, sessionID }) // 不 await,立即返回
})
}这三种模式的对比:
| 端点 | 协议 | 用途 |
|---|---|---|
GET /event | SSE | 广播所有 Bus 事件,实时进度更新 |
POST /session/:id/message | HTTP Stream | 等待 AI 处理完成,返回最终消息 |
POST /session/:id/prompt_async | HTTP 204 | 触发 AI 处理,不等结果 |
OpenAPI 自动生成
每个路由都用 describeRoute 标注了元信息,用 validator 定义了 Zod 模式,用 resolver 关联 Zod 类型到 OpenAPI Schema。这三者配合,让 API 文档自动从代码生成:
// 一个典型的自文档路由
.get(
"/:sessionID",
describeRoute({
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
operationId: "session.get",
responses: {
200: {
description: "Get session",
content: {
"application/json": {
schema: resolver(Session.Info), // 直接引用 Zod schema
},
},
},
...errors(400, 404),
},
}),
validator("param", z.object({ sessionID: Session.get.schema })),
async (c) => {
const sessionID = c.req.valid("param").sessionID // 类型安全,已验证
const session = await Session.get(sessionID)
return c.json(session)
},
)访问 GET /doc 可以看到完整的 OpenAPI 3.1.1 规范。这份规范也是 SDK 代码生成的数据源:
server.ts + routes/*.ts
↓ describeRoute/validator/resolver 元信息
generateSpecs(app) → OpenAPI 3.1.1 JSON
↓
script/generate.ts → packages/sdk/js/src/
↓
客户端调用 opencode.session.list({...}) // 完全类型安全这条流水线保证了"服务器返回什么,客户端就能看到什么类型"的端到端类型安全。
服务器启动:端口策略与 mDNS
Server.listen() 处理启动逻辑:
export function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}) {
const app = createApp(opts)
const args = { hostname: opts.hostname, idleTimeout: 0, fetch: app.fetch, websocket } as const
const tryServe = (port: number) => {
try { return Bun.serve({ ...args, port }) }
catch { return undefined }
}
// 先尝试 4096,失败则让操作系统分配随机端口
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
// 非 loopback 地址才发布 mDNS
const shouldPublishMDNS =
opts.mdns && server.port && !["127.0.0.1", "localhost", "::1"].includes(opts.hostname)
if (shouldPublishMDNS) MDNS.publish(server.port!, opts.mdnsDomain)
return server
}端口降级逻辑:如果请求端口 0(意为"操作系统分配"),服务器会先尝试固定的 4096 端口。这对开发体验很重要——大多数时候端口 4096 是空闲的,TUI 客户端就不需要动态发现服务器地址。只有当 4096 被占用时才真正退化到随机端口。
mDNS 广播:在局域网模式(--hostname 0.0.0.0)下,服务器通过 mDNS 广播自己的地址,移动端或其他设备可以自动发现,无需手动配置 IP。这是 server/mdns.ts 的职责。
兜底代理:透明转发到 Web 应用
.all("/*", async (c) => {
const path = c.req.path
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: { ...c.req.raw.headers, host: "app.opencode.ai" },
})
response.headers.set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; ...")
return response
})对于所有未匹配的路径(即 API 路由之外的路径),服务器将请求透明代理到 https://app.opencode.ai。这意味着在浏览器中直接打开 http://localhost:4096,看到的是完整的 Web 应用 UI,而 API 调用则被路由到本地服务器。
用户不需要分别启动"后端服务器"和"前端开发服务器",一个命令就能得到完整的 Web + API 环境。
请求的完整生命周期
把上面各层串联起来,一个 POST /session/:id/message 请求的完整路径是:
浏览器发出请求
↓
onError 注册(兜底)
↓
BasicAuth 检查(如有密码)
↓
请求日志记录
↓
CORS 检查,拒绝非白名单 Origin
↓
工作区上下文注入:Instance.provide(directory)
↓
WorkspaceRouterMiddleware(企业版多租户路由)
↓
query 参数验证(directory/workspace 字段)
↓
路由匹配:/session → SessionRoutes
↓
路由匹配:/:sessionID/message → POST 处理函数
↓
param 验证:sessionID
↓
body 验证:PromptInput schema
↓
SessionPrompt.prompt(...) → AI 处理(可能数十秒)
同时 Bus 广播事件给 SSE 客户端
↓
stream.write(JSON.stringify(msg)) → 响应返回整个过程中,Bus 持续广播事件(session.assistant.token、session.tool.call 等),已连接 SSE 的客户端实时更新 UI。POST /session/:id/message 只是最后的"告知完成"。
设计模式总结
| 模式 | 体现 | 好处 |
|---|---|---|
| 中间件责任链 | CORS → Auth → 日志 → 上下文注入 | 关注点分离,每层只做一件事 |
| 按需初始化 | Instance.provide() per-request | 同一服务器服务多个工作目录 |
| 代码即文档 | describeRoute + validator + resolver | API 文档永远和实现同步 |
| 双流模式 | SSE 推事件 + HTTP Stream 传结果 | 实时进度与最终状态解耦 |
| 向下代理 | 未匹配路径 → app.opencode.ai | 一个端口提供完整 Web 体验 |
思考题:
工作区上下文通过
Instance.provide()注入到 AsyncLocalStorage,这和 React 的 Context API 在设计思想上有什么相似之处?为什么这种模式比"每个函数都接收 directory 参数"更优雅?为什么
POST /session/:id/message使用stream()而不是streamSSE()?如果 AI 处理中途抛出异常,客户端能正确感知吗?GET /event中的 10 秒心跳是维持连接活性的手段。如果客户端断线重连,它会错过这期间的事件吗?OpenCode 应该如何设计来解决这个问题?
下一章预告
第10章:数据持久化 — 深入 packages/opencode/src/storage/,学习:SQLite + Drizzle ORM 的 schema 设计、消息与 Part 的存储结构、SQLite JSON 列的查询技巧、数据库迁移策略,以及 KV 存储的分层缓存设计。