深入浅出 DeepSeek Harness 第七部:产品面——同一运行时的不同面孔 Chapter 46

浏览器到运行时,中间没有大表

API 网关产品面:当运行时要面对浏览器,协议不再是子进程里的 NDJSON,而是 HTTP/JSON 信封加一条 WebSocket 多路复用流。旧的"40+ 方法 API Proxy 大表"已被拆掉——一元操作归业务 Remote 拥主方,Connection 管认证与信封,Gateway 管分发。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 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.renamesessionTitle/renameSessionTitleService
llm.providers / discoverModelsllm/listProviders / discoverModelsLlmRuntime
credentials.set/unsetcredentials/set / unsetCredentialsController
settings.update/replacesettings/*SettingsController
agentPreset.copy/removeagentPresets/*AgentPresetService
skill.listskills/listSessionSkillCatalog

为什么要拆?迁移笔记说:旧做法让一个简单的一元操作同时存在于业务 Service、API Proxy 接口、Zod schema、路由表、客户端 stub、客户端调用处——六处重复。现在 Typert Remote 机制让业务包自己拥有签名与 Host 适配,Connection 只选它生成的 contribution,客户端只做表现层 join。一个一元操作,业务方定义一面,消费方定义另一面,中间没有中间人。

浏览器这张脸上其实跑着两条并行的路,分工清楚:

维度一元 POST /apiWebSocket mux(/api/remote.mux)
触发客户端主动发一次Upgrade 后挂着
方向请求→响应一来一回双向推流,open/item/end/cancel 帧
谁处理Gateway.invoke() 反射到业务方法RemoteStreamMuxServer 多路复用逻辑流
背压body 上限 300MiBuplink 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,于是”查找被拒”和”网关内部错”在浏览器那边始终分得开。

Host 网关:浏览器 Web 应用经 Connection 层(dsh-auth cookie/HMAC 认证、Host/Origin 信任、信封、精确 Fetch 路由、node:http↔fetch 桥 300MB 上限、operator Peer 准入)发出一元 POST /api;Gateway(TypertGatewayService)按 claimsEndpoint 判归属、invoke 一元调用、stream 流式方法、descriptor 解析 zod 校验,把请求分发给各业务 Remote 拥主方(Session/Settings/Credentials/Llm/Command/Workspace 等)。流式路走 Upgrade 到 WebSocket mux(/api/remote.mux):一条 socket 多路复用逻辑流,open/item/end/cancel 帧,uplink inbox 有界字节数,心跳 ping/pong 丢 2 次即 terminate,$events 转发 Cordis 事件。

本节源码依据(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 事件日志投影成你眼睛看到的对话。