opencode HttpApi 路由模式实战:Effect 类型化路由树、SSE 流与 WebSocket 升级的正确写法
发布时间:2026/9/7 15:38:56 锦皓数字建站

opencode HttpApi 路由模式实战Effect 类型化路由树、SSE 流与 WebSocket 升级的正确写法【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode本文基于 opencode 仓库中packages/opencode实例 HTTP 服务的内部路由规范AGENTS.md展开讲清楚该项目如何基于 Effect 的HttpApiBuilder组织类型化路由树普通 JSON 端点、SSE 流式端点、WebSocket 升级路由分别应该用哪套 API 写法中间件、错误契约与依赖注入应该放在哪一层。读完本篇你可以直接按照仓库既有风格为 opencode 新增一个 HttpApi 端点并理解 server.ts 中整棵路由树是如何组装、鉴权并对外提供 OpenAPI 文档的。一、总览三层路由写法及其适用边界规范文件 AGENTS.md 给出的核心规则可以概括为一张“写法选择表”路由类型推荐写法仓库实例普通 HTTP 端点含 SSE 流式响应HttpApiBuilder.group(...)handlers/session.ts、handlers/event.ts需要原始请求/响应体的声明式端点如 WebSocket 升级HttpApiBuilder.group(...)内使用handleRaw(...)handlers/pty.ts声明式 API 面之外的路由如 UI 兜底路由原生HttpRouter.use(...)server.ts 中的uiRoute这套分层的意义在于只要端点在“声明式 API 面”内它的中间件、路由上下文与 OpenAPI 元数据就能挂在同一棵类型化路由树上只有真正脱离 API 面的路由才允许退化为原始HttpRouter。目录结构上规范所指的代码位于 packages/opencode/src/server/routes/instance/httpapi 下分为groups/端点契约声明、handlers/端点实现、middleware/中间件契约与实现以及api.ts/server.ts组装边界四个部分正好对应规范中“声明、实现、中间件、组装”四个位置。二、普通端点HttpApiBuilder.group中一次性取出稳定服务规范的第一条要求构建 handler 层时把稳定服务一次性yield*出来然后在各端点实现中闭包引用它们而不是在每个请求回调里反复构造依赖。规范给出的最小范例export const sessionHandlers HttpApiBuilder.group(InstanceHttpApi, session, (handlers) Effect.gen(function* () { const session yield* Session.Service return handlers.handle(list, () session.list()) }), )仓库中真实的 sessionHandlers 是这一模式的完整体现export const sessionHandlers HttpApiBuilder.group(InstanceHttpApi, session, (handlers) Effect.gen(function* () { const session yield* Session.Service const shareSvc yield* SessionShare.Service const promptSvc yield* SessionPrompt.Service const revertSvc yield* SessionRevert.Service // ...共 12 个稳定服务一次取出 const scope yield* Scope.Scope const list Effect.fn(SessionHttpApi.list)(function* (ctx: { query: typeof ListQuery.Type }) { const directory ctx.query.directory ? yield* InstanceState.directory : undefined return yield* session.list({ directory: ctx.query.scope project ? undefined : directory, scope: ctx.query.scope, // ... }) }) // ... }), )几个值得注意的实现细节每个端点实现都用Effect.fn(SessionHttpApi.xxx)包一层带上了带作用域的函数名。从源码结构看这是为了在 Effect 追踪/日志中区分各端点的执行轨迹端点入参ctx的字段params/query/payload由组声明端的 Schema 决定实现处直接得到解码后的类型例如typeof ListQuery.Type错误转换发生在 handler 边界如 session-errors.ts 中把存储层的 NotFound 领域错误映射为ApiError声明的错误类型而不是让领域服务感知 HTTP。三、SSE 流式端点留在group内用HttpServerResponse.stream返回规范明确要求SSE 端点不要改走原始路由仍然留在HttpApiBuilder.group(...)内由 handler 返回HttpServerResponse.stream(...)同时在端点契约上用HttpApiSchema.asText({ contentType: text/event-stream })标注成功 Schema让 OpenAPI 正确记录流的 Content-Type。opencode 的事件订阅端点/event是标准实现。契约声明见 groups/event.tsHttpApiEndpoint.get(subscribe, /event, { query: WorkspaceRoutingQuery, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: text/event-stream })), }) .annotateMerge(OpenApi.annotations({ identifier: event.subscribe, summary: Subscribe to events, description: Get events, }))对应 handler 在 handlers/event.ts 中通过handleRaw(subscribe, ...)实现核心响应构造过程是先注册监听再开始发射Queue.unbounded接收EventV2Bridge的事件events.listen(...)是急切注册保证监听注册到 HTTP 响应体 fiber 启动之间不会丢事件按实例过滤流按event.location.directory instance.directory且 workspace 匹配过滤再合并一条server.instance.disposed的终止流Stream.takeUntil到 disposed 事件为止心跳保活每 10 秒发一个server.heartbeat事件首帧为server.connected编码为 SSE 并挂响应头Stream.pipeThroughChannel(Sse.encode())之后以contentType: text/event-stream返回并附加Cache-Control: no-cache, no-transform、X-Accel-Buffering: no关闭 nginx 类代理的缓冲、X-Content-Type-Options: nosniff。这正是规范中“SSE 留在声明式路由树内、用asText标注流内容类型”两条规则的完整落地。四、WebSocket 升级handleRaw保留类型化路由树规范指出声明式端点如果需要原始请求或响应典型即 WebSocket 升级用HttpApiBuilder.group(...)配handleRaw(...)。这样端点的中间件、路由上下文和 OpenAPI 元数据仍然挂在同一棵类型化路由树上。规范给的范例与仓库实现高度一致。groups/pty.ts 中声明了一个独立的PtyConnectApiconnect端点挂在/pty/:ptyID/connect上并挂了三组中间件InstanceContextMiddleware、WorkspaceRoutingMiddleware和专门的PtyConnectAuthorizationexport const PtyConnectApi HttpApi.make(pty-connect).add( HttpApiGroup.make(pty-connect) .add( HttpApiEndpoint.get(connect, PtyPaths.connect, { params: Params, success: described(Schema.Boolean, Connected session), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge(OpenApi.annotations({ identifier: pty.connect, summary: Connect to PTY session, description: Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time., // transform: 手工补齐 directory/workspace/cursor/ticket 查询参数 // 因为这些参数是在 raw handler 里解码的 transform: (operation) ({ ... }), })), ) .middleware(InstanceContextMiddleware) .middleware(WorkspaceRoutingMiddleware) .middleware(PtyConnectAuthorization), )注意一个工程细节因为 raw handler 里先检查 PTY 是否存在再解码查询参数为了保持“不存在先返回空 404”的既有响应顺序这些查询字段没有声明进端点 Schema而是通过 OpenAPI 注解的transform回调手工补进文档。注释里明确写了这一取舍是阅读 raw 端点契约时的关键线索。实现侧 ptyConnectHandlers 展示了 raw handler 的完整能力面从ctx.request取查询参数中的 PTY 连接 ticket经tickets.consume(...)校验一次性票据非法时返回空 403 响应yield* Effect.orDie(ctx.request.upgrade)拿到 WebSocket 句柄进入真正的 socket 读写出站帧回放帧、实时数据、关闭帧统一进一个Queue.unbounded由单个 drain writer 顺序写出保证重放与实时输出的顺序一致用Effect.race并行跑读attachment.write与写drain任一侧结束即整体结束并ensuring(attachment.detach())确保 PTY 附着一定释放。五、原始HttpRouter.use只用于 API 面之外的路由规范的第四条原始HttpRouter.use(...)只允许用于声明式 API 面之外的路由例如兜底 UI 路由。server.ts 用一段注释把整棵路由树说得很清楚// Route tree: // - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi. // - eventApiRoutes: typed SSE route with instance routing context and its existing API contract. // - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth. // - instanceApiRoutes: remaining typed instance routes. // - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.其中真正用到原始HttpRouter.use的只有两个/doc延迟构造 OpenAPI JSON 响应见下文与uiRoute*, /*兜底服务内嵌 Web UI。uiRoute特意用 router 级中间件做鉴权正是为了让公开静态资源可以绕过认证——这是规范中“raw 路由配 router 中间件”说法的直接对应。/doc路由还体现了“稳定层一次性提供”的性能意识源码注释原文// OpenApi.fromApi is non-trivial; defer until /doc is actually hit so // processes that never serve it (CLI, scripts) dont pay at module load. const docResponse lazy(() HttpServerResponse.jsonUnsafe(OpenApi.fromApi(PublicApi)))OpenAPI 文档只在首次命中/doc时惰性生成且序列化后的响应被缓存复用避免每个请求重新JSON.stringify。六、依赖注入纪律层提供一次而不是每请求重建规范对 Layer 使用有两条明确的“避免”规则仓库代码是它们的注脚规则 1避免在请求 handler 或 raw 路由回调内部执行Effect.provide(SomeLayer)。稳定层应在应用/层边界提供一次而不是每请求重建或按请求做作用域化。反面教材式的反例在规范里被直接点名正面做法可见server.ts的组装段所有稳定服务Session、Pty、Provider等约 50 个LayerNode先在app组中声明再由createRoutes在Layer.provide(...)链上一次性提供handler 内部只yield*服务、不提供层。规则 2避免HttpRouter.provideRequest(...)除非依赖天然是请求级的。稳定的应用服务应走HttpRouter.use(...)。规则 3Effect.provideService(...)在中间件里只用于“由请求推导出的上下文”例如WorkspaceRouteContext派生出的InstanceRef、WorkspaceRef不要拿它把稳定服务偷偷塞进请求 effect。仓库中 middleware/instance-context.ts 就是规范中“请求派生上下文”的标准写法function provideInstanceContextE( effect: Effect.EffectHttpServerResponse.HttpServerResponse, E, store: InstanceStore.Interface, ): Effect.EffectHttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext { return Effect.gen(function* () { const route yield* WorkspaceRouteContext const ctx yield* store.load({ directory: decode(route.directory) }) return yield* effect.pipe( Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, route.workspaceID), ) }) }这里provideService提供的是InstanceRef/WorkspaceRef两个从路由目录/workspace 参数推导出来的上下文完全符合规范第 35 行的限定而InstanceStore.Service本身是稳定服务是在instanceContextLayer构建时yield*出来的没有混进请求 effect。七、公开 JSON 错误显式的Schema.ErrorClass契约规范最后一段对错误契约的表述是全套规范中最容易被忽视、也最影响 SDK 兼容性的部分每个端点的公开 JSON 错误都应该是显式声明在端点上的Schema.ErrorClass契约仅当“空/tagged body 就是期望的线上形态”时才使用内置HttpApiError.*如HttpApiError.BadRequest这类无字段体对 SDK 可见、需要携带message的错误要定义专用 API 错误 Schema规范点名了ApiNotFoundError这类命名并以声明的那个精确错误失败领域服务与存储服务必须保持不含 HttpApi 类型预期领域错误在handler 边界翻译为 API 错误。仓库的 errors.ts 就是这套契约的集中定义全部用Schema.TaggedErrorClass生成、并绑定httpApiStatusexport class SessionNotFoundError extends Schema.TaggedErrorClassSessionNotFoundError()( SessionNotFoundError, { sessionID: Schema.String, message: Schema.String, }, { httpApiStatus: 404 }, ) {} export class PtyNotFoundError extends Schema.TaggedErrorClassPtyNotFoundError()( PtyNotFoundError, { ptyID: Schema.String, message: Schema.String }, { httpApiStatus: 404 }, ) {} // 另有 InvalidRequestError(400)、UnauthorizedError(401)、ForbiddenError(403)、 // ConflictError(409)、UpstreamError(502)、ServiceUnavailableError(503)、 // TimeoutError(504)、UnknownError(500) 等端点侧的用法可在 groups/pty.ts 看到get端点声明error: PtyNotFoundErrorupdate端点声明error: [PtyNotFoundError, HttpApiError.BadRequest]connectToken声明error: [PtyForbiddenError, PtyNotFoundError]。handler 侧则在 catch 领域错误时翻译成这些声明类例如 handlers/pty.tsEffect.catchTag( Pty.NotFoundError, (error) new ApiError.PtyNotFoundError({ ptyID: error.ptyID, message: PTY session not found: ${error.ptyID}, }), )Pty.NotFoundError是领域错误PtyNotFoundError是 API 契约错误翻译发生在 handler 边界——这正是规范要求的分层opencode-ai/core下的 Pty 服务不知道任何 HttpApi 类型的存在。八、中间件声明与提供的正确位置规范最后一条新增中间件时把“端点契约中间件”声明在归属它的HttpApiGroup上实现层则在server.ts的组装边界提供router 级中间件只留给真正 raw 的兜底路由或全局传输策略。仓库的对应关系非常清晰声明端各 group 在自己身上挂中间件。如 groups/event.ts 的HttpApiGroup.make(event).middleware(InstanceContextMiddleware).middleware(WorkspaceRoutingMiddleware).middleware(Authorization)PtyApi/PtyConnectApi同样在组上声明见 groups/pty.ts提供端server.ts 按“路由子树”分块提供。instanceRoutes统一在实例级路由子树挂httpApiAuthLayer workspaceRoutingLive instanceContextLayer schemaErrorLayereventApiRoutes与ptyConnectApiRoutes各自在子树级提供对应层。中间件契约在组上声明一次实现层在组装边界注入一次两端解耦API 聚合api.ts 展示了契约中间件的另一种声明位置——根 API 直接在HttpApi.make链上挂.middleware(SchemaErrorMiddleware).middleware(Authorization)得到RootHttpApi/global 与控制面路由与InstanceHttpApiconfig、file、instance、mcp、project、pty、question、permission、provider、session、sync、tui、workspace 共 14 个 group最终聚合为OpenCodeHttpApi并追加EventSchema等附加 Schema 供 OpenAPI 引用全局传输策略createRoutes返回的层在Layer.mergeAll(六条路由子树)之上统一Layer.provide了errorLayer、compressionLayer、corsVaryFix、fenceLayer与 CORS 中间件server.tsCORS 甚至以{ global: true }的 router 中间件形式提供maxAge: 86_400——这正对应规范“router 中间件留给全局传输策略”的定位。server.ts末尾还有一处值得注意的组装顺序注释Observability.layer必须放在 provide 链的最后“Must stay last”因为 Effect 中后 provide 的层构建在更底层过早提供会让急切 fork 的 fiber 捕获默认 stdout logger 从而污染 TUI引用了 issue #34730。这说明规范中“稳定层在应用边界提供”不只是风格要求也直接影响可观测性正确性。九、小结新增一个 HttpApi 端点的标准流程综合规范与源码在 opencode 中新增端点可以按以下清单执行定契约在groups/对应文件中用HttpApiGroup.make(...).add(HttpApiEndpoint.method(...))声明端点配OpenApi.annotationsidentifier/summary/descriptionSSE 端点的 success Schema 用HttpApiSchema.asText({ contentType: text/event-stream })标注错误字段填 errors.ts 中已声明的TaggedErrorClass缺失则新增并绑定httpApiStatus定中间件把端点契约中间件声明在归属 group 上参照InstanceContextMiddleware/WorkspaceRoutingMiddleware/Authorization的三层组合实现层留到server.ts组装写 handler在handlers/对应文件中用HttpApiBuilder.group(InstanceHttpApi, name, ...)包裹开头yield*全部稳定服务端点实现里只做参数解码后的业务编排与领域错误翻译需要原始请求/响应socket 升级等才用handleRaw挂路由树在api.ts把 group 并入InstanceHttpApi或独立HttpApi.make参照PtyConnectApi的做法并在server.ts的对应子树instanceApiRoutes/ 新子树中Layer.providehandler 层与中间件实现层自检纪律handler 内不Effect.provide(SomeLayer)中间件内provideService只注入请求派生上下文领域服务保持零 HttpApi 依赖/doc的 OpenAPI 文档会自动反映新端点。遵循这套模式新端点天然获得类型化的参数/响应校验、统一的鉴权与实例上下文注入、显式的错误契约以及无需额外维护的 OpenAPI 文档——这也是该目录路由代码在规模上14 个实例级 group 事件流 PTY WebSocket仍能保持可组装、可测试的根本原因。【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。