浏览器到运行时,中间没有大表
API 网关产品面:当运行时要面对浏览器,协议不再是子进程里的 NDJSON,而是 HTTP/JSON 信封加一条 WebSocket 多路复用流。旧的"40+ 方法 API Proxy 大表"已被拆掉——一元操作归业务 Remote 拥主方,Connection 管认证与信封,Gateway 管分发。
本章以中文撰写
第 0 秒,你在浏览器里打开 dsh 的 Web 界面。一个会话列表请求从前端发出,落到 Host 的 HTTP 端口。它不像第 44 章那样是一根 stdio——这是 HTTP:一个 POST 请求,带 cookie,Host 这边先认证、再查 Host/Origin、再把信封拆开,交给一个分发器,分发器找到对应的业务服务,执行业务,把结果包回 JSON 信封送回去。同时,一条 WebSocket 已经在背后挂着,把会话事件、状态翻转一条条推给前端。这就是 API 网关:运行时面对浏览器时的那张脸。而它和旧文档里那个”40+ 方法大表”的样子,已经完全不同了。
旧的大表没了:一元操作各归其主
先把最重要的架构事实摆出来。仓库里那份迁移笔记写得直白:“Connection owns the transport envelope and exact Fetch route registry, and no API Proxy service remains.” 曾经那张 rpc-map.ts 里 40+ 个方法的集中大表——session.rename、llm.providers、credentials.set、settings.update、agentPreset.copy——现在每个都搬回了它的业务拥主方:
| 旧 API Proxy 操作 | 现在的归属 | 谁拥主 |
|---|---|---|
| session.rename | sessionTitle/rename | SessionTitleService |
| llm.providers / discoverModels | llm/listProviders / discoverModels | LlmRuntime |
| credentials.set/unset | credentials/set / unset | CredentialsController |
| settings.update/replace | settings/* | SettingsController |
| agentPreset.copy/remove | agentPresets/* | AgentPresetService |
| skill.list | skills/list | SessionSkillCatalog |
为什么要拆?迁移笔记说:旧做法让一个简单的一元操作同时存在于业务 Service、API Proxy 接口、Zod schema、路由表、客户端 stub、客户端调用处——六处重复。现在 Typert Remote 机制让业务包自己拥有签名与 Host 适配,Connection 只选它生成的 contribution,客户端只做表现层 join。一个一元操作,业务方定义一面,消费方定义另一面,中间没有中间人。
浏览器这张脸上其实跑着两条并行的路,分工清楚:
| 维度 | 一元 POST /api | WebSocket mux(/api/remote.mux) |
|---|---|---|
| 触发 | 客户端主动发一次 | Upgrade 后挂着 |
| 方向 | 请求→响应一来一回 | 双向推流,open/item/end/cancel 帧 |
| 谁处理 | Gateway.invoke() 反射到业务方法 | RemoteStreamMuxServer 多路复用逻辑流 |
| 背压 | body 上限 300MiB | uplink inbox 有界字节数,超了抛 uplink-overflow |
| 保活 | TCP 自带 | 心跳 ping/pong,丢 2 次即 terminate |
| 典型用途 | 改名、设凭据、列 provider | 会话事件推送、流式方法、$events 瀑布 |
这套分工有个硬约束:Host 与浏览器之间共享的”查找策略”——复用活 Agent、冷会话恢复、并发去重、拒子代理身份——仍然是单一权威。它抛一个带专属 code 的 RemoteError(session/not-found、session/agent-busy),Gateway 把 code/message/details 原样上 wire,于是”查找被拒”和”网关内部错”在浏览器那边始终分得开。
本节源码依据(3 处)
Connection:认证、信任与信封,不归业务管
进来的每一个请求,第一道关是 Connection。packages/client/connection 这一层做三件业务不该操心的事:浏览器认证、Host/Origin 信任、传输信封与精确 Fetch 路由。
认证是一只签名 cookie。browser-auth.ts 生成 32 字节随机 secret(base64url),存进凭据库,种一个 dsh-auth- 前缀的 cookie,cookie 里装 version、authority(请求的 host)、签发与过期时间。进程级还有一个 launch token(WeakMap 挂在 owner 上),用于首登 URL。每次请求,Connection 验 cookie 的 HMAC、验 authority 对不对、验没过期——过不了就是 401/403。注意它为什么验 authority:一个 loopback 端口上的 cookie 不能被另一个 host 的页面拿去用,签名观众必须等于当前 host。
信封这一层,http-bridge.ts 把 node:http 请求桥成 WHATWG Request 交给 fetch-shaped handler,body 默认上限 300 MiB——注释解释:这个数是按默认 200 MiB 图片聚合上限、base64 膨胀后约 267.7 MiB 留的余量。桥接里有个细节值得记:客户端断开检测必须挂在 response 的 close 而不是 request 的——Node 16 之后,IncomingMessage 'close' 在请求体读完就触发(GET 请求立刻触发),会把流式响应刚开起来就 abort;只有 ServerResponse 'close' 才表示连接真断了。
本节源码依据(4 处)
Gateway:只分发,不执行业务
过了 Connection,请求进 Gateway。TypertGatewayService 是 Cordis Service,inject ['typert'],它的核心方法就两个:invoke()(一元调用)和 stream()(流式方法)。它不写任何业务逻辑——它做的是”这个 endpoint 归谁、参数对不对、把调用反射到那个业务方法上”。
请求进来先过 claimsEndpoint 这道归属判断:endpoint 必须是 namespace/method 两段,且能在 ctx.typert.local 里查到、或在收集来的 srcClaims 里。查不到就拒——这防止一个浏览器请求被随便路由到一个不存在的方法。然后 prepareInvocation 解析 descriptor、按 zod 校验绑定、把 named wire args 换成反射调用的实参,Reflect.apply 到业务 receiver 上。
错误被刻意分成两类。基础设施错误是 19 个 gateway/* code(gateway/protocol、gateway/arguments-invalid、gateway/lookup-not-found……),它们描述”分发这一步出了什么错”;业务错误身份——包括查找策略抛的 session/not-found——原样上 wire,不被 Gateway 包成 gateway/internal。这样浏览器才能区分”你这个方法我压根没接住”和”你找的会话不存在”。
流式方法走另一条路:invokePrepared 发现 descriptor 带 mode(流模式)就拒,“stream Remote methods must be opened through the stream carrier”;反之 openStream 对一个一元方法也拒。一元和流在类型层面就被分开,不会串。
本节源码依据(4 处)
WebSocket MUX:一条 socket 上的多路复用流
一元请求是请求-响应,但事件推送、流式方法需要一条长连接。这条路是 RemoteStreamMuxServer,挂在 REMOTE_STREAM_MUX_PATH = '/api/remote.mux'。它的妙处在于一条 WebSocket 多路复用很多条逻辑流:客户端发 open 帧(带 streamId、endpoint、payload),Host 这边开一条逻辑流;之后 item/end/cancel 帧都带上同一个 streamId,互不干扰。
几个设计点很硬:
- 心跳:一个
unref()的定时器定期 ping,missedHeartbeats计数,丢满MAX_MISSED_HEARTBEATS = 2次 pong 就terminate——死连接不留。 - uplink 有界:
UplinkInbox给每条逻辑流的上行字节数封顶,超了就抛gateway/uplink-overflow;客户端在end之后再发 item,抛gateway/protocol——流量与协议都不无限。 - Peer 绑定:upgrade 时 admission 出一个 Peer,这个 socket 的所有流都替这个 Peer 说话;Peer scope 被 dispose,socket 就
close(1001, 'peer left')。 - 重复 open 是唯一关 socket 的协议违规:一个 streamId 已经开了再开,直接抛错关连接;其余乱序帧(已结束流的 item/end/cancel)只丢弃,不关 socket——容错是分级的。
还有一条 Gateway 自己拥有的流:$events(REMOTE_EVENT_STREAM_ENDPOINT)。它把 Host Cordis 事件转发给浏览器,客户端处理完一个 scoped 事件后经 $events/result(REMOTE_EVENT_RESULT_ENDPOINT)回传结果。dispatchRpc 专门拦 $events/result 这个特殊端点,把结果喂回对应的 pending 事件。
本节源码依据(5 处)
断线之后:指数退避与 generation 概念
浏览器和 Host 之间的长连接会断——笔记本合盖、网络切换、Host 重启。客户端这边不是傻傻地立刻重连,而是 ConnectionController 在管。它的核心是 generation(代):一次成功建立的连接是一个 generation,带一个单调递增的 id 和 Host 给的事实(host.home,仅用于缩写显示路径)。断开后进入重试循环:backoffDelay 按 backoffBaseMs * backoffFactor^(attempt-1) 指数增长、封顶 backoffMaxMs,再叠一个半区间随机抖动——避免所有客户端同时撞回来。
几个状态细节体现了它的老练:
- 手动重连
reconnect()把 attempt 归零、立即使一次重连,不等退避——用户点”重试”就得马上试。 - 网络状态感知
setNetworkAvailable(false)时暂停自动重连、报disconnected;网络回来再重置退避——离线时没必要白白撞墙。 - 就绪超时
waitForReady:一个 generation 在generationReadyWarnMs还没 ready 就告警,超generationReadyTimeoutMs直接取消这个 generation——挂死的握手不无限等。 - sink 异常隔离:业务层回调抛错只记日志,绝不影响 pump 或重连语义——UI 层崩一下不能把连接管理带崩。
这套”代 + 退避 + 就绪超时”和子进程 SDK 那边的 dispose 梯子是同一种哲学:外部世界不可靠,每一层都要自己做幂等与退避。
本节源码依据(4 处)
转发 Cordis 事件:scoped 瀑布穿网关
$events 流不只是”广播事件”。有些 Host 事件是 scoped waterfall——Host 端的 Cordis 监听器可以选择”先让浏览器的客户端处理一下,再继续 Host 的默认行为”。packages/api/remotes/src/index.ts 里那个 remoteEventSource 把这些事件分两类挂上来:emit 模式的直接翻成通知帧推给浏览器;waterfall 模式的,它把这次调用包成一个 TypertRemoteEventInvocation 推给浏览器,浏览器处理完经 $events/result 回一个 {kind:'result', value} 或 {kind:'next'}——next 就是”我不管,交给 Host 下一个监听器”。
这个桥里有两个安全闸门。其一,waterfall 事件必须直接携带它的 Agent(request.agent 且等于 carrierAgent),否则直接抛 forwarded scoped event ... must carry its Agent directly——不能让一个 scoped 事件替一个没绑定的 Agent 说话。其二,转发出去的事件参数必须是无损 JSON(assertJsonArgs),不是就抛错——Host 不能把一个带函数、带循环引用的对象塞给浏览器。一个 RemoteEventQueue(Deque)把同步的 Cordis 监听器桥成异步迭代器,浏览器断开时队列里 pending 的 waterfall 全部 reject。
本节源码依据(3 处)
小结:信封归 Connection,分发归 Gateway,业务归拥主
浏览器这张脸,是把一个运行时的能力安全地、类型化地暴露给网页。它把三层切得很干净:Connection 管”这个请求配不配上桌”,Gateway 管”这桌菜端给谁”,业务 Remote 管”菜怎么做”。旧的集中大表被拆碎之后,每一层都更薄、更可独立演进——加一个新业务能力,只需业务方注册一个 Remote 贡献,不用再动那张全局路由表。
下一张脸是这一切的终点:第 47 章讲浏览器拿到这些流之后,怎么把append-only 事件日志投影成你眼睛看到的对话。