opencode 服务器包拆分实践:基于 Effect HttpApi 的 @opencode-ai/server 独立化路线
本文基于 opencode 仓库中的规格文档 server-package.md,系统讲解 opencode 的 HTTP 服务器如何从 packages/opencode 中逐步剥离为独立的 @opencode-ai/server 工作区包。读完本文,你将理解:为什么必须“不要制造包循环”、契约(contract)与宿主(host)之间的依赖方向应如何设计、五步 PR 拆分序列的具体依据,以及当前仓库中 packages/server 已经落地的“可嵌入服务器 API”的真实形态,便于你在类似 Monorepo 中复刻这一拆分策略。
一、背景:服务器代码的“现状”与拆分动机
server-package.md 的开篇即明确了本文档的定位:这是“在 opencode 服务器迁移到 Effect HttpApi 后端之后,面向未来 packages/server 拆分的实践参考”。规格文档列出了拆分启动前的五项现状,每一条都能在当前仓库中找到对应实现:
- 服务器仍位于
packages/opencode内。入口是 packages/opencode/src/server/server.ts,其中的listen(opts)负责监听端口(支持可选的 mDNS 广播),Default导出了一个基于HttpApiApp.webHandler().handler的 fetch 式应用,openapi()则通过OpenApi.fromApi(PublicApi)生成 OpenAPI 文档。 - 运行时与应用层被集中到两个文件。packages/opencode/src/effect/app-runtime.ts 用
AppNodeBuilderV1.build(LayerNode.group([...]))组装出AppLayer,一次性聚合了Database、Session、Agent、Provider、Plugin、LLM等数十个领域服务;packages/opencode/src/effect/run-service.ts 则提供了attach/attachWith(把InstanceRef、WorkspaceRef注入到 Effect 上下文)与makeRuntime(构造带memoMap的ManagedRuntime,暴露runSync/runPromise/runFork/runCallback等边界方法)。 - 路由树位于
src/server/routes/instance/httpapi下,由src/server/server.ts承载。实际的 group/handler 文件在 packages/opencode/src/server/routes/instance/httpapi 目录中,按config.ts、session.ts、event.ts、pty.ts等主题划分为 20 余个 group 与同名 handler 模块。 - OpenAPI 生成基于 HttpApi 契约 + 兼容性转换,转换逻辑集中在 packages/opencode/src/server/routes/instance/httpapi/public.ts。这正是规格文档反复强调要“持续收缩”的兼容 shim 层。
- 规格撰写时尚无独立的
packages/server工作区。
二、目标包布局:五个各司其职的工作区
规格文档给出的“Future State”是一个五包目标布局,其设计意图是让“领域、传输、入口、客户端、扩展面”互不越界:
| 目标包 | 职责 |
|---|---|
packages/core |
共享的领域服务与 schema |
packages/server |
HTTP 契约、处理器(handlers)、OpenAPI 生成,以及可嵌入的服务器 API |
packages/cli |
TUI 与 CLI 入口 |
packages/sdk |
由服务器 OpenAPI 规范生成 |
packages/plugin |
插件编写面(authoring surface) |
对照当前仓库,这个布局已经基本成型:packages/core、packages/server、packages/cli、packages/sdk 与 packages/plugin 均已存在。尤其值得关注的是 packages/server/package.json 的依赖声明:
{
"name": "@opencode-ai/server",
"version": "1.18.29",
"private": true,
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
}
}
注意它只依赖 core 与 protocol,不依赖 @opencode-ai/opencode。这个依赖方向就是下一条核心规则的落地形态。
三、拆分的核心规则:绝不制造包循环
规格文档将整条拆分策略压缩成一句话:“Do not create a package cycle.”(不要制造包循环),并给出在“足够多的共享服务代码离开 packages/opencode 之前”,未来 packages/server 必须二选一的约束:
- 只拥有纯粹的 HttpApi 契约(pure HttpApi contracts only);或
- 接受由宿主(
packages/opencode)提供的 services / layers / callbacks。
同时明确禁止的反模式是:packages/server 一边 import packages/opencode 的 services,一边又被 packages/opencode import 去承载路由——两者互为依赖即成环,TypeScript 编译、打包与测试隔离都会随之劣化。
当前仓库提供了两处可以直接验证该规则的证据:
证据一:依赖方向的静态约束。 如上所述,@opencode-ai/server 的依赖清单里没有 @opencode-ai/opencode;而 packages/opencode/src/server/server.ts 中可以看到反方向的引用:
import type { CorsOptions } from "@opencode-ai/server/cors"
即 opencode → server 的引用是被允许的(宿主承载路由时引用被拆出的包),而 server → opencode 是被规格明确禁止的。
证据二:“宿主注入”模式的实际实现。 packages/server/src/routes.ts 展示了“接受宿主提供 services”这条路线的完整形态——它并不 import 任何 opencode 内部实现,而是从 @opencode-ai/core 与 effect 组装自己的服务层:
const applicationServices = LayerNode.group([
Database.node,
EventV2.node,
httpClient,
ToolOutputStore.cleanupNode,
SessionV2.node,
PermissionSaved.node,
PtyTicket.node,
Credential.node,
PtyEnvironment.node,
LocationServiceMap.node,
])
export function createRoutes(password?: string) {
return makeRoutes(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer,
)
}
export function createEmbeddedRoutes() {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
}
这里有三个值得注意的设计点:
applicationServices是一个LayerNode.group,只聚合 core 层的服务节点,SessionExecution通过AppNodeBuilder.build(..., [[SessionExecution.node, SessionExecutionLocal.node]])以“接口 + 本地实现”的替换方式接入,避免把某个具体实现的节点硬编码进 group。- 认证是参数化的:
createRoutes(password?)面向独立监听场景,密码可空;createEmbeddedRoutes()则固定为Option.none()(不启用密码认证),专为被宿主内嵌的场景准备。 - 暴露 Web 处理器而非端口,这正是规格中“embeddable server API”一词的落地:
export const routes = createRoutes()
export const webHandler = () =>
HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true })
webHandler() 返回 toWebHandler 包装好的 handler,宿主可以自行决定挂在哪个 Node server / 端口 / 代理之后——服务器包只交付“请求进、响应出”的能力,把“在哪里托管”的决策权完全留给宿主。
契约本体则进一步下沉到了 protocol 包:packages/server/src/api.ts 全文仅 8 行:
import { makeDefaultApi } from "@opencode-ai/protocol/api"
import { LocationMiddleware } from "./location"
import { SessionLocationMiddleware } from "./middleware/session-location"
export const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware,
})
也就是说,HttpApi 的“形状”(endpoint、schema、错误契约)由 @opencode-ai/protocol 统一生产,packages/server 只负责注入两个与位置(Location)语义相关的中间件。这比规格设想的“只拥有纯契约”走得更彻底:连契约的生成逻辑也独立在了 server 包之外。
四、处理器工厂:Layer.mergeAll 的分组装配
规格中 PR 序列的第 4 步要求“在 handler 工厂的服务依赖可以被宿主层供给、而不是直接 import 之后,才提取 handler 工厂”。packages/server/src/handlers.ts 展示了这一步的完成态——18 个 handler 分组以 Effect Layer 的形式各自独立,最后用 Layer.mergeAll 合并:
export const handlers = Layer.mergeAll(
HealthHandler,
LocationHandler,
AgentHandler,
SessionHandler,
MessageHandler,
ModelHandler,
ProviderHandler,
IntegrationHandler,
CredentialHandler,
PermissionHandler,
FileSystemHandler,
CommandHandler,
SkillHandler,
EventHandler,
PtyHandler,
QuestionHandler,
ReferenceHandler,
ProjectCopyHandler,
)
每个 handler 对应 packages/server/src/handlers/ 下的一个文件(agent.ts、session.ts、pty.ts 等),依赖在各自 Layer 内部声明,由 makeRoutes 中的 Layer.provide(handlers, ...) 统一解析。配套的横切中间件位于 packages/server/src/middleware/:authorization.ts(认证)、schema-error.ts(schema 校验错误的统一映射)、session-location.ts(会话与位置解析)。这种“每个 group 自带依赖声明 + mergeAll 装配 + 统一 provide”的结构,使得未来新增或移除任何一个 API 分组都不需要触碰其余 handler 的代码,符合 routes.md 中“稳定服务在 handler layer 构建时一次性 yield,请求级只提供派生上下文”的模式约定。
五、建议的 PR 序列:五步走法与当前进度
规格文档给出的五步序列,本质是“按依赖倒序、从最纯的模块开始”的拆分纪律:
- 持续收缩 public.ts 中的 OpenAPI 兼容 shim。 该文件承担 SDK/OpenAPI 兼容转换;routes.md 进一步给出操作准则:每收紧一个 source schema 就删掉一个 workaround,OpenAPI 可见 schema 变化时要确认生成的 SDK diff 是有意的,且“优先修 source schema,而不是新增后处理规则”。
- 把稳定的领域 schema 移入共享包,前提是它们不再依赖 opencode 本地运行时模块。 当前 packages/schema 与 packages/core 就是这类 schema/领域代码的落点。
- 当契约模块可以不再 import
packages/opencode的任何实现细节时,将纯 HttpApi 契约模块提取进packages/server。 从当前仓库看,契约生成已进一步下沉到 packages/protocol,packages/server/src/api.ts只是薄封装,这一步可以视为已完成。 - 在服务依赖可以由宿主层供给之后,提取 handler 工厂。 对应 packages/server/src/handlers/ 的 18 个分组实现,依赖全部来自 core,同样已落地。
- 最后才移动服务器托管(hosting),且必须在包归属清晰之后。 当前 packages/opencode/src/server/server.ts 的
listen/Default/openapi仍留在 opencode 包内,符合“hosting last”的排序。
换言之,对照规格的五步清单:第 1 步是长期任务(shim 尚未清零),第 3、4 步已经由 protocol + server 两个包承接,第 5 步(托管迁移)被刻意保留在 opencode 内,等待包边界进一步固化。
六、非目标(Non-Goals):三条明确的“不做”
规格文档用一整节 Non-Goals 锁死了拆分的边界,这三条对后续维护者依然有效:
- 不要复活旧的双后端(dual-backend)迁移形态。 即不再维护两套并行的后端实现;这也与 routes.md 中“保留
{ name, data }错误体直到一次有意的破坏性 API 变更”“通用 middleware 不做领域错误映射”等约束一脉相承——迁移是一次性的,而不是长期并存的双轨。 - 不要在服务依赖拥有清晰的包边界之前先拆服务器托管。 即 hosting 是最后一步,不能因为“看起来好拆”就提前动
server.ts的listen/openapi导出面。 - 不要在生成物兼容性被证实之前切换 SDK 生成来源。
packages/sdk的生成链路(经由 OpenAPI)只有在确认输出保持兼容后,才允许切换到新的包产出。
七、如何在仓库中验证与继续阅读
如果你希望亲手验证本文所述的包边界与拆分进度,以下路径是最直接的入口(仓库为只读,以下均为查看方式):
- 依赖方向:对比 packages/server/package.json(依赖
core、protocol)与 packages/opencode/package.json,确认server → opencode方向不存在;再在packages/opencode/src/server/server.ts中检索@opencode-ai/server,查看宿主对被拆包的反向引用(目前为 CORS 选项类型)。 - 可嵌入 API 的形态:阅读 packages/server/src/routes.ts 中
createRoutes/createEmbeddedRoutes/webHandler三个导出,理解“带密码认证”与“内嵌免密”两种装配方式的差异。 - 契约下沉路径:从 packages/server/src/api.ts 追到 packages/protocol 的
makeDefaultApi,再对照 packages/opencode/src/server/routes/instance/httpapi/public.ts 中尚未收缩的兼容转换层。 - 装配与中间件:查看 packages/server/src/handlers.ts 与 packages/server/src/middleware/ 下的三个中间件文件。
- 规范文档族:server-package.md 所在目录 packages/opencode/specs/effect/ 还有配套的路由模式(routes.md)、迁移模式(migration.md)与总体路线图(todo.md),是理解本次拆分约束的上下文文档。
八、小结
server-package.md 虽然篇幅不长,却为 opencode 的服务器独立化定下了可执行的纪律:以“不产生包循环”为唯一铁律,用“纯契约 / 宿主注入”两条互斥路线约束 packages/server 的职责,再以五步 PR 序列保证每一次移动都有独立的编译与兼容性验证点,最后用三条 Non-Goals 排除掉“双后端长期并存”“过早拆托管”“抢跑 SDK 切换”这三类典型返工路径。从当前仓库的实际状态看,契约(下沉至 packages/protocol)与 handler 工厂(packages/server/src/handlers/)已经完成迁移并以 webHandler() 形式提供了可嵌入 API,而托管逻辑仍按规格留驻在 packages/opencode 中等待最后一步——这正是该文档所描述的路线在真实代码中的兑现过程。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00