T3 Code 源码拆解
核心运行时

04 · 类型化 RPC、WebSocket 与鉴权

约 95 个 RPC 如何共享 Schema,连接如何认证,方法级 scope 又如何限制权限。

为什么是 RPC,不是散装 WebSocket 事件

T3 Code 的连接既有普通请求,也有长流:列项目、发送消息、订阅 shell、读取 diff、终端输入、审批工具。 如果每个功能自行定义 JSON 消息,客户端与服务端会快速产生三套不一致:名称、序列化和错误。

packages/contracts/src/rpc.ts 用 Effect RPC 把每个方法声明为一个带输入、成功值和失败 Schema 的契约,再组合成 WsRpcGroup。本快照中 Rpc.make( 出现约 95 次,说明 RPC 已经是产品表面,而非少数内部调用。

概念上,一个定义同时生成:

Rpc.make('thread.sendMessage', {
  payload: ThreadSendMessageInput,
  success: ThreadCommandReceipt,
  error: ThreadCommandError,
})

实际名称和 Schema 以源码为准;这里展示的是结构。客户端拿到类型化代理,Server 为同一 group 提供 handler, 序列化层由 Effect Schema 负责。

方法目录与授权目录

RPC 方法名还集中出现在 WS_METHODS。Server 的 RpcAuthorization.ts 通过 requiredScopeForRpcMethod 将方法映射为权限范围。两张目录解决不同问题:

  • RPC group 证明“消息可以怎样编码并返回什么”;
  • 授权映射证明“这个已认证主体是否可以调用”。

认证不等于授权。一个只读移动端 session 即使完成 WebSocket 握手,也不应获得终端写入、Provider 配置或访问管理权限。

一次 RPC 的信任链
HTTP auth / ticket allowed denied Client WebSocket Upgrade EnvironmentAuth Session Principal + scopes Effect RPC Server requiredScopeForRpcMethod Typed Handler Authorization Error Orchestration / Terminal / Git Typed success or typed error

WebSocket 建立流程

apps/server/src/ws.ts 是边界文件。 它完成三件事:

  1. 调用 EnvironmentAuth.authenticateWebSocketUpgrade 验证 upgrade;
  2. 为连接构造带 principal/scope 的运行上下文;
  3. 把 WsRpcGroup 的 handler 集合交给 Effect RPC server。

握手认证失败时,连接不会进入业务 runtime;方法授权失败时,连接保留但当前调用返回类型化错误。 这一区分让客户端可以在同一连接上继续使用被允许的方法,也方便 UI 把“需要更高权限”与“网络断开”分开呈现。

为什么 WebSocket URL 只放短期 ticket

浏览器 WebSocket API 对自定义 Authorization header 支持受限。最简单但危险的做法,是把长期 bearer token 放进 URL; URL 可能进入代理日志、浏览历史、崩溃上报和截图。

T3 Code 的远程路径先用正常 HTTP 认证请求短期 WS ticket,再只把 ticket 放进连接 URL。认证体系还支持 DPoP, 将 token 使用绑定到客户端持有的密钥证明。即使 ticket 仍可能出现在日志里,它的寿命和用途都被压缩。

而且 ticket 只解决“谁建立了连接”,每个 RPC 仍做 scope 授权。这是纵深防御,不把整个安全模型押在握手一次成功上。

相关实现位于:

Unary 与 Stream 共用契约

普通 RPC 返回一次结果;订阅类 RPC 返回流。线程 shell/event 流需要处理一个经典竞态:客户端先取快照、再订阅时, 两步之间可能产生新事件。T3 Code 使用全局 sequence/cursor 让客户端声明已经看到的位置,Server 补发 gap 后继续实时推送。

但补发不是无限的。ws.ts 对恢复 gap 设有 1,000 个全局事件的上限。超过上限时,系统选择重新建立快照, 而不是一次性在内存中收集巨大历史。

Cursor 不是永久重放承诺

Cursor 的作用是消除正常连接切换中的小窗口,不是把 WebSocket 变成无限 event-log 下载 API。 超过边界必须回到快照,这同时保护 Server 和慢客户端。

错误分层

从客户端视角,至少要区分四类失败:

层示例客户端动作
TransportDNS、socket closed、timeout交给 connection supervisor 重连
Authenticationticket 失效、session 撤销刷新认证或重新配对
Authorization缺少某 scope降级 UI / 提示授权,不重试同一调用
Domainthread 不存在、生命周期冲突刷新快照或展示业务错误

如果把它们都抹成 WebSocket error,重试会把权限错误打成请求风暴,UI 也无法告诉用户该怎么恢复。 Effect 的 error channel 与 Schema 让 handler 能保留这些区别。

RPC handler 应该有多薄

理想 handler 只负责:

  1. 解析已验证 payload;
  2. 从连接上下文获取 principal/environment;
  3. 调用领域服务;
  4. 将领域结果映射为协议返回。

编排规则放进 decider,Provider 副作用放进 reactor,Git 操作放进 VCS driver。薄 handler 不是风格偏好, 它确保 HTTP/WebSocket 传输不会成为另一个状态权威。

兼容性成本

类型共享能在 monorepo 编译时发现大部分不兼容,却不能消灭已部署版本差异:手机可能比 Server 旧,桌面可能正在滚动更新。 因此协议演化应优先新增 optional 字段/方法,持久化事件需要版本化或可容错 decode,错误类型也不能随意重命名。

T3 Code 还在投影迁移中做过 model selection 等字段的 canonicalization,这说明“同仓一起发布”不代表运行时永远同版本。

可迁移经验

  • 把方法 Schema 与方法授权分开建模,并为两者做穷尽性测试。
  • WebSocket 握手使用短期单用途票据,长期凭据留在受 header 保护的 HTTP 流程。
  • 流式订阅必须定义 cursor、gap 上限和快照重建三件套。
  • Transport、认证、授权、领域错误必须在类型和 UI 上保持可区分。
  • RPC 是边界,不应承载业务状态机。

On this page