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 配置或访问管理权限。
WebSocket 建立流程
apps/server/src/ws.ts 是边界文件。
它完成三件事:
- 调用
EnvironmentAuth.authenticateWebSocketUpgrade验证 upgrade; - 为连接构造带 principal/scope 的运行上下文;
- 把
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 和慢客户端。
错误分层
从客户端视角,至少要区分四类失败:
| 层 | 示例 | 客户端动作 |
|---|---|---|
| Transport | DNS、socket closed、timeout | 交给 connection supervisor 重连 |
| Authentication | ticket 失效、session 撤销 | 刷新认证或重新配对 |
| Authorization | 缺少某 scope | 降级 UI / 提示授权,不重试同一调用 |
| Domain | thread 不存在、生命周期冲突 | 刷新快照或展示业务错误 |
如果把它们都抹成 WebSocket error,重试会把权限错误打成请求风暴,UI 也无法告诉用户该怎么恢复。
Effect 的 error channel 与 Schema 让 handler 能保留这些区别。
RPC handler 应该有多薄
理想 handler 只负责:
- 解析已验证 payload;
- 从连接上下文获取 principal/environment;
- 调用领域服务;
- 将领域结果映射为协议返回。
编排规则放进 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 是边界,不应承载业务状态机。