夜雨聆风学习资料网

ARTICLE · 1042306

DeepSeek Harness 源码-Web 客户端架构

DeepSeek Harness 源码-Web 客户端架构

DeepSeek Harness 源码解析系列

第 37 讲:Web 客户端架构

基于 DeepSeek Harness 源码 · 2026-09-20

💡 本讲一句话:DeepSeek Harness 的 Web 客户端不是"一个 React 应用",而是一张插件图——壳内核(boot kernel)只干三件事:加载页、模块表播种、失败时大声报错;所有业务 UI 都是插件通过 slot 注册拼出来的。读完你能看懂两阶段启动为什么必须等整个 immediately 层、HTTP RPC 和 WebSocket 下行流如何分工、以及"组件永远看不到 ctx"这条红线是怎么落地的。

一、入口只有 10 行:壳内核才是主角

前几讲我们一直在看宿主侧(host)的 runtime。这一讲切到浏览器侧。先看最反直觉的一点:apps/web 这个"应用"本身只有 10 行代码——它只负责找到挂载点,然后把一切交给 @deepseek-ai/dsh-client-web

📄 apps/web/src/main.ts (第 6-10 行)

import { AppWebEntry } from '@deepseek-ai/dsh-client-web' // 唯一依赖:壳内核入口类,加载/组装/插件装配全在它里面const el = document.getElementById('root') // 找挂载点——这是整个"应用"里唯一的 DOM 操作if (el === null) throw new Error('web app: missing #root') // 找不到直接抛错:fail loud,不渲染任何兜底 UIvoid new AppWebEntry(el).run() // 把挂载点交给内核跑两阶段启动;void 表示不 await——失败由加载页自己呈现

为什么入口要这么薄?因为壳内核有一条自足性规则(shell self-sufficiency rule):加载页必须在"插件全部失败"时也能工作——它呈现的恰恰是"哪些插件失败了"。如果加载页本身依赖某个插件,那插件挂了你就连错误报告都看不到。所以 boot.tsx 的文件头注释把这条规则写成了硬约束:

🔹 壳自足性规则:"这里的一切都不能自己是 loader entry,也都不 value-import 任何插件包——加载页必须能在(尤其是当)插件失败时工作。唯一被允许的例外是 modules 包:模块系统不能通过自己到达自己,所以它的类被壳打包进内核,等 cordis 起来后再收养它的插件入口。"

整个浏览器侧的包结构也印证了这个分层:packages/client/ 下有 40+ 个 dsh-client-* 包,按职责分成三层——runtime(数据对象层,零 React)、web-react(渲染机制胶水)、ui-*(业务 UI 插件)。壳内核(web)只认识前两层。

职责与红线
壳内核(shell)
dsh-client-web、apps/web
两阶段启动、加载页、模块表播种;零插件依赖,失败时大声报错
数据对象层(React-free)
dsh-client-runtime、dsh-client-connection
ConnectionController → SessionManager → Session 拥有全部业务状态;grep 可断言零 React import
渲染机制(shell-only glue)
dsh-client-web-react、dsh-client-ui-slots
slot 渲染器/outlet、uSES 桥接;所有 ctx→React 的集成都在这层
业务 UI 插件
ui-layout、ui-conversation、ui-sidebar…(30+)
只通过 ctx.slots.register 组合 UI;组件永远看不到 ctx

这个分层不是审美偏好,而是可执行的纪律:仓库的 AGENTS.md 明确写着数据对象层"零 React import——grep-assertable"。下一节看壳内核怎么把这张图拉起来。

二、两阶段启动:模块面 → 插件面

AppWebEntry.run()(boot.tsx)是整条启动链的入口。它先走"模块面"——解析宿主注入的 window.__DSH_BOOT__ manifest、建模块系统、渲染加载页;再走"插件面"——挂载 cordis Loader、创建每个插件 entry、等全部 ACTIVE。核心流程如下:

📄 packages/client/web/src/boot.tsx (第 97-143 行)

async run(): Promise<void> { // 启动链跑到"结算"为止;失败不 reject,而是留在加载页渲染错误报告  this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__) // 解析宿主注入的 manifest(wire 边界):modules 视图 + plugins 视图两张表  this.modules = new ClientModuleSystem({ // 建模块系统:fetch bundle 的 external 全靠它解析    modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams, // manifest 模块行 + 壳静态表一起喂给模块系统;seams 是测试注入点(jsdom 替换 script 路径)  })  this.modules.registerStatic(APP_SHELL_ID, AppShell) // app-shell 组装是唯一的壳自有模块——其余 graph row 全是 fetch 来的插件 bundle  this.modules.registerStatic(MODULES_ID, ModulesClient) // modules 包自己的 client 半部按裸包名注册(=graph row id),否则 statics 分支漏掉会触发真 fetch  ;(globalThis as DshWindow).__DSH_MODULES__ = this.modules // 把实例挂到内核槽位:modules entry 的 wrapper apply 从这里读,provide ctx.modules  this.root = createRoot(this.el) // React root:先渲染加载页(AppRoot),settled 之后一次性切真实 UI  this.root.render(<AppRoot settled={this.settled} status={this.status} error={this.error} // 加载页先行:三个 signal store 全是内核自有——失败报告不依赖任何插件(壳自足性规则)    renderApp={() => { const shell = this.ctx.get('appShell') // 延迟到 settled 后才取 appShell 服务——此刻插件还没 active      if (shell === undefined) throw new Error('web boot: appShell service missing after settled') // inject set 已保证 active,这是不可达防御——fail loud 而非静默返回空 UI      return shell.renderApp() }} />) // renderApp 闭包:settled 后 AppRoot 每次重渲染都调它(内部有 identity-stable 缓存)  const prefetching = this.prefetchImmediateTier() // immediately 层预取与 Loader 挂载并行跑(只注册 factory,失败静默留给 import 路径大声报)  this.ctx = new Context() // cordis 上下文:插件面所有服务/事件/inject 的宿主  try { // 启动链失败不 reject——留在加载页渲染错误报告(fail-loud surface)    await this.runPluginBoot(prefetching) // 插件面:挂 Loader → 注入 internal → 建 entry → await → sweep    this.settled.set(true) // 全部 ACTIVE 才翻 settled 信号,AppRoot 一次性切真实 UI  } catch (reason) { // 任何阶段抛错都走这里:console + error signal,绝不渲染半个 UI    console.error(reason) // 完整堆栈进控制台——加载页只显示 message,细节靠这里查    this.error.set(reason instanceof Error ? reason.message : String(reason)) // 失败留在加载页呈现(fail loud),绝不渲染半个 UI  }}

这段代码里最容易被忽略的是 prefetchImmediateTier() 与 entry 创建之间的屏障。boot.tsx 的模块注释解释得很直白:entry 创建会"物化"bundle,而物化会跑同步的跨包 require 边(比如 locale → runtime/client)——fiber 的 inject waiting 保护不了这种同步边,所以必须等整个 immediately 层的 factory 都注册完,才允许任何 entry 物化。但单个 bundle 预取失败仍然静默 resolve:因为创建侧的 import 会重新加载并大声报错,屏障不能把一个坏 bundle 放大成全局 fail-fast。

插件面的收尾是 assertEntriesActive()——一次"全量 fiber sweep"。它存在的理由是:cordis 的 inject waiting 没有超时,一个永远等不到依赖服务的 entry 会安静地停在 PENDING。sweep 就是那个 fail-loud 补偿:

📄 packages/client/web/src/boot.tsx (第 216-237 行)

private assertEntriesActive(): void { // 树静默后扫所有 entry:这是 inject waiting 无超时的 fail-loud 补偿  const ctx = this.ctx // 用当前 ctx 读服务表(判断 pending entry 缺哪个服务)  const failures: string[] = [] // 收集所有不合格 entry——一次性全列出,不只看第一个  for (const entry of ctx.loader.entries()) { // 遍历全部 loader entry:import 失败 / PENDING / FAILED 三种病都要抓    const name = entry.options.name // entry 名 = graph row id(包名),报告里直接可读    if (entry.fiber === undefined) { // import 失败的 entry 没有 fiber(Entry._init 记日志后返回)——直接算失败      failures.push(`${name}: import failed (see console for the import error)`)      continue    }    const state = STATE_LABELS[entry.fiber.state]    if (state === 'active') continue // ACTIVE 是唯一合格态    if (state === 'pending') { // PENDING:某个必需服务永远没到——把缺的服务名列出来,定位一步到位      const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)      failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)    } else {      failures.push(`${name}: ${state}`) // FAILED(apply 抛了)等其他态    }  }  if (failures.length > 0) { // 任何一条不合格就整体失败——宁可白屏+报告,不给半个 UI    throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)  }}

🔹 为什么这样设计:"宁可白屏+完整报告,不给半个 UI"是这条链的总原则。加载页(AppRoot)只依赖内核自己的三个 signal store(settled/status/error),不碰任何插件——所以它报告的失败对象(那张插件图)挂了,报告本身还活着。错误信息精确到"哪个 entry、缺哪个服务",因为 inject waiting 没有超时,这种静默 PENDING 是浏览器端最阴险的故障形态。

三、模块表播种:React 只有一份实例

浏览器里没有 Node 的 require,fetch 下来的 bundle 怎么解析 react@deepseek-ai/cordis 这些 external?答案是静态模块表:壳在启动时把平台常量模块一次性 import 进来,塞进一张冻结的表里,所有 bundle 的 require 都对着这张表解析。seed.ts 只有 41 行,但每一行都是契约:

📄 packages/client/web/src/seed.ts (第 9-41 行)

import * as React from 'react' // 壳静态 import:所有 bundle 看到的必须是同一个 React 实例(hooks 状态表是模块级的)import * as ReactJsxRuntime from 'react/jsx-runtime' // jsx runtime 同理——两份实例 = 两套 dispatcher,渲染直接炸import * as ReactDom from 'react-dom' // react-dom 本体(createRoot 等)——同样必须单实例import * as ReactDomClient from 'react-dom/client' // client 入口单独列:bundle 里 import 的是这个子路径,表里必须有对应 keyimport * as Cordis from '@deepseek-ai/cordis' // cordis 本体也走这张表:插件 bundle 里的 Context/Service 与壳是同一份import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' // slot 系统的 SlotMap 声明合并必须落在同一个模块上,否则类型链断裂import * as WebReact from '@deepseek-ai/dsh-client-web-react'import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' // UI 基础组件库:所有 ui-* 插件共享同一份,避免样式/状态分裂import * as UiAttachment from '@deepseek-ai/dsh-client-ui-attachment' // 附件 UI(图片预览等):宿主侧 attachment 能力的浏览器半部import * as SchemaForm from '@deepseek-ai/dsh-client-schema-form' // schema → 表单渲染器:settings/credentials 等配置 UI 的公共底座export function getStaticModules(): Record<string, unknown> { // 构建静态模块表——壳在启动时调用一次,冻结后交给 loader  return { // satisfies 是投影契约:PLATFORM_MODULES 加一个词而这里没 import(或反之)直接编译失败,不会漂成运行时 require miss    'react': React,    'react/jsx-runtime': ReactJsxRuntime,    'react-dom': ReactDom,    'react-dom/client': ReactDomClient,    '@deepseek-ai/cordis': Cordis,    '@deepseek-ai/dsh-client-ui-slots': UiSlots,    '@deepseek-ai/dsh-client-web-react': WebReact,    '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,    '@deepseek-ai/dsh-client-ui-attachment': UiAttachment,    '@deepseek-ai/dsh-client-schema-form': SchemaForm,  } satisfies Record<PlatformModule, unknown>}

注意 satisfies Record<PlatformModule, unknown> 这个写法:它不是普通的类型断言,而是"双向投影契约"——平台常量模块(platform.ts)里每加一个词,这里就必须补一条静态 import,否则编译失败。这把"运行时 require miss"这种最阴险的故障提前到了编译期。

还有一条配套规则值得记住:boot.tsx 里 loader.internal = this.modules 必须在任何 entry 存在之前注入。因为 cordis loader 的 tree.import 在 internal 为 undefined 时会回退到裸 dynamic import——这在浏览器里是"保证大声失败",只能当 tripwire(绊线),不能当路径。

四、app-shell:全程序唯一一次顶层 renderSlot

settled 之后,AppRoot 调用 renderApp()。这个函数由一个"伪插件"提供——app-shell(@deepseek-ai/dsh-client-app-shell)。它没有 npm 包,只存在于宿主 graph 和壳注册表里。它的 apply 做了两件事:安装 slot 渲染器、提供 renderApp:

📄 packages/client/web/src/app-shell.ts (第 27-50 行)

export const name = 'app-shell' // cordis 插件名(graph row id)——伪包:只存在于宿主 graph,没有 npm 实体 // cordis 插件名export const inject = ['slots', 'sessions', 'layout'] // 必需服务:等这三个都 active,本 entry 才激活——顺序由 fiber inject waiting 保证export function apply(ctx: Context): void { // 等 inject set(slots/sessions/layout)全 active 才执行——顺序由 fiber inject waiting 保证  ctx.slots.install(createSlotRenderer()) // 渲染器安装是壳的事(web-react 被壳打包),但 ctx.slots 要等 runtime entry active 才存在——所以落在这里,由 inject set 保证顺序  let renderApp: (() => ReactNode) | undefined // 闭包缓存:buildRenderApp 只跑一次,引用在 AppRoot 重渲染间保持稳定  ctx.reflect.provide('appShell', {    renderApp: (): ReactNode => {      renderApp ??= buildRenderApp({ ctx }) // 首次调用才组装;??= 保证 identity-stable——React 树不能每次重渲染都换根      return renderApp()    },  })}

而 buildRenderApp()(app.tsx)返回的树,核心只有一行——整个程序里唯一一次 ctx 级 renderSlot

📄 packages/client/web/src/app.tsx (第 26-44 行)

export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { // 组装真实 UI 树的工厂——由 app-shell 的 renderApp 闭包首次调用时执行  const { ctx } = deps // 解出 active 的 app-shell ctx(slots/sessions/layout 服务已就位)  const sessions = ctx.get('sessions') // 取 sessions 服务;strict get 读全局服务表(属性代理是拓扑敏感的,这里必须用 get)  if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable') // inject set 已保证 active,不可达防御——fail loud // inject set 已保证 active,这里是不可达的防御——fail loud  const useSessions = bindSnapshotSelector(sessions.list) // 把裸 observable 源绑成 uSES selector hook(web-react 的唯一 hook 构造器)  const SessionDocumentTitle = (): ReactNode => { // 文档标题跟随当前会话:纯派生数据,走框架 hook    const title = useSessions((state) => {      const id = state.current // 当前会话 id;没有就 undefined(无会话态)      return id === undefined ? undefined : state.byId[id]?.title // 从快照里取标题——派生是纯函数,不建自己的订阅    })    return <DocumentTitle {...title === undefined ? {} : { title }} />  }  return () => ( // 返回的函数 = AppRoot settled 后每次重渲染调用的真实 UI 树    <>      <SessionDocumentTitle />      {ctx.slots.renderSlot('root', {})} // 全程序唯一一次顶层 renderSlot:整棵布局树都挂在 'root' slot 上(ui-layout 把 AppFrame 注册在这里)    </>  )}

🔹 为什么这样设计:"shell 的 render 是全程序唯一一次 ctx 级 renderSlot"——这句话划出了组合模型的根。'root' slot 是 runtime 内置声明的,ui-layout 把 AppFrame 注册进去并声明四个子 slot(sidebar/conversation/details/shell.overlay),业务插件再往这些子 slot 里注册自己的组件。shell 只做一次 renderSlot('root'),其余全部由 slot 树递归展开。这样"谁占哪个坑、占了之后原来的东西去哪了"就有唯一权威答案:register 即替换(single kind)或并列(list kind),没有第二条路径。

ui-layout 的注册调用是这套模型最完整的样本——一次 register() 同时完成:贡献 AppFrame、声明四个子 slot(声明 = 独占渲染权)、安放 layout store、接线面板动作服务:

📄 packages/client/ui-layout/src/client/index.ts (第 116-143 行)

export function apply(ctx: ClientContext): void { // client 插件体:等 inject(slots/theme)active 后执行  const layout = new LayoutController() // 面板动作控制器:跨插件的 ctx.layout 契约(折叠/展开侧栏、详情列) // 面板动作控制器:跨插件的 ctx.layout 契约(折叠/展开侧栏、详情列)  ctx.effect(() => { // effect = 注册即副作用,返回 disposer——HMR 安全性的基础    const disposeService = ctx.reflect.provide('layout', layout) // provide 服务面:只暴露 ILayout 接口,具体实现留在包内(export discipline) // provide 服务面:只暴露 ILayout 接口,具体实现留在包内(export discipline)    const disposeRegistration = ctx.slots.register({      name: 'root', // 注册进 runtime 内置的 'root' slot——shell 唯一 renderSlot 的那个坑      children: { // children = 声明 + 授权:AppFrame 能渲染的 slot 恰好就是这四个 key,多一个少一个都在加载期失败        'sidebar': { kind: 'single', scope: 'root' }, // 左列整体;被 ui-sidebar 占据——注册即替换整列,它内部声明的座位随之消失        'conversation': { kind: 'single', scope: 'session-maybe' }, // 中列(含无会话 hero);session-maybe = 当前会话可选,占位者自己管两种状态        'details': { kind: 'single', scope: 'session' }, // 右详情列;scope session = 框架自动注入 sessionId 标准 prop        'shell.overlay': { kind: 'list', scope: 'root' }, // 全帧浮层:badge/toast/status pill 都在这,kind list = 并列追加而非替换      },      store: createLayoutStore, // 独占 store:传的是工厂本身——框架按 entry 实例化,把 useStore/actions 作为标准 prop 递给 AppFrame      inject: (actions: PanelActions) => { // hook 的唯一副作用:把根 store 的绑定动作接到 ctx.layout;会话业务动作归各注册者自己        layout.attachPanels(actions)        return {}      },    }, AppFrame) // AppFrame = 三列布局 + 让渡求解(concession solve)的帧组件    return () => { // disposer:先撤注册,再异步 settle provide——teardown 是同步 fire-and-forget      disposeRegistration()      void disposeService()    }  }, 'ui-layout: service + root registration')}

这里能看到 slot 系统的几条核心纪律(来自 packages/client/AGENTS.md):① 一个 API——组合 UI 只有 ctx.slots.register() 一条路,没有白名单、没有 face-minting;② children = 声明 + 授权——渲染了没声明的 slot、或声明了别人声明过的 slot,都在加载期失败,"冲突就是设计在说话";③ 组件 props 是四个 share 的全派生(PropsRuntime & PropsRenderSlots & PropsStore & inject face),永远不手写一个 share 已经派生的成员。

五、连接层:HTTP 走请求,WebSocket 只下行

浏览器和宿主之间的通信被切成两条单向通道:上行(请求)走 HTTP POST /api/<method>下行(事件)走 WebSocket。这个分工在 websocket-downlink.ts 的注释里写成了协议级约束:"客户端消息是协议违规:上行流量留在 HTTP 上"——浏览器一旦往 downlink socket 发消息,宿主直接以 1008 关闭:

📄 packages/client/connection/src/websocket-downlink.ts (第 99-137 行)

private upgrade<F extends Frame>( // 泛型 F = MuxFrame | HostFrame:两条下行流共用同一套升级/泵逻辑  req: IncomingMessage,  socket: Duplex,  head: Buffer,  open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>, // open = 打开类型化事件流的工厂(mux 或 host),AbortSignal 贯穿全程): void {  this.server.handleUpgrade(req, socket, head, (websocket) => { // noServer 模式:升级由我们手动接管,socket 所有权从 HTTP server 转移过来 // noServer 模式:升级由我们手动接管,socket 所有权从 HTTP server 转移过来    const abort = new AbortController() // 本连接的取消控制器:贯穿上游事件流迭代 + socket 生命周期    websocket.once('close', () => { abort.abort() }) // 对端关闭 → 中止上游事件流迭代(资源回收)    websocket.once('error', () => { abort.abort() }) // socket 错误同理    websocket.once('message', () => { // downlink-only 协议:收到任何上行消息都是违规      websocket.close(1008, 'downlink only') // 1008 = policy violation,直接断开——上行必须走 HTTP(协议级约束) // 1008 = policy violation,直接断开——上行必须走 HTTP    })    const pump = this.pump(websocket, open(abort.signal), abort) // 启动泵:从事件流读帧 → JSON 序列化 → send    this.pumps.add(pump) // 登记到 pumps 集合:close() 时统一 await(优雅关停所有在途泵) // 登记到 pumps 集合,close() 时统一 await(优雅关停)    void pump.then(() => { this.pumps.delete(pump) }) // 泵结束后自清理  })}private async pump<F extends Frame>(socket: WebSocket, frames: AsyncIterable<RpcRequest<F>>, abort: AbortController): Promise<void> {  try {    for await (const frame of frames) await send(socket, frame) // 逐帧发送;await send = 背压——socket 没写完不发下一帧 // 逐帧发送;await send 保证背压——socket 没写完不发下一帧  } catch (error) { // 上游迭代或 socket 写入失败    if (!abort.signal.aborted) { // 只有"非主动中止"才补发失败帧(对端已走就没人收了) // 只有"非主动中止"才补发失败帧(对端已走就没人收了)      try { await send(socket, failureFrame(error)) } catch { /* socket 丢失赢了竞态:失败帧发不出去就算了 */ }    }  } finally {    abort.abort() // 无论如何中止上游迭代    if (socket.readyState === WebSocket.OPEN) socket.close() // 优雅关闭(区别于 terminate)  }}

上行通道则是标准的 JSON-RPC over HTTP。rpc-host.ts 的 rpcFetchHandler 把每个请求过五道关:方法必须是 POST、endpoint 必须匹配路径段(防目录穿越)、Content-Type 必须是 application/json、body 必须通过 clientRequestSchema 解析、envelope 里的 method 字段必须与 URL endpoint 一致——最后一道关防的是"用合法 envelope 打错端点":

📄 packages/client/connection/src/rpc-host.ts (第 149-186 行)

async fetch(request: Request): Promise<Response> { // RPC over HTTP 的完整校验链:五道关,任何一道不过都返回明确错误码  const endpoint = endpointFromPath(channel, new URL(request.url).pathname) // 从路径提取 endpoint;含空段/./.. /非法字符一律 undefined(防目录穿越) // 从路径提取 endpoint;含空段/./.. /非法字符一律返回 undefined(防目录穿越)  if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) } // RPC 只走 POST;GET 事件流端点另有 426 upgrade required // RPC 只走 POST;GET 事件流端点另有 426 upgrade required  const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() // 取 MIME 主类型(剥掉 charset 等参数)——严格匹配,不猜  if (mediaType !== 'application/json') { return new Response('content type must be application/json', { status: 415 }) } // 415 = unsupported media type:不兼容、不猜测 // 严格 MIME:不猜、不兼容  let body: unknown // 先声明后赋值——json() 可能抛错,需要 try/catch 包住  try { body = await request.json() } catch { return new Response('body is not JSON', { status: 400 }) }  const envelope = clientRequestSchema.safeParse(body) // schemastery schema 校验:rpcId + method + payload 三件套缺一不可 // schemastery schema 校验:rpcId + method + payload 三件套  if (!envelope.success) { return invalidEnvelopeResponse(body, envelope.error.issues) } // 失败时尽量回带原始 rpcId——客户端能把错误对到发起的请求上 // 失败时尽量回带原始 rpcId,客户端能把错误对到发起的请求上  const message: ClientRequest = envelope.data // 校验通过,拿到类型安全的请求消息  if (message.method !== endpoint) { // method 与 URL endpoint 必须一致——防"合法 envelope 打错端点"    return errorResponse(message.rpcId, { code: 'bad-request', // method 与 URL endpoint 必须一致——防"合法 envelope 打错端点"      message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, details: { issues: [] } })  }  try {    const result = await handler(endpoint, message.payload, request.signal) // 分发到业务 handler;request.signal 贯穿——客户端断开即中止    return fullResponse(message.rpcId, result) // 成功:{ type: 'server-response', rpcId, result }  } catch (error) {    return new Response(`handler failure: ${String(error)}`, { status: 500 }) // handler 抛错 = 服务端故障,500 + 原文  }}

连接层还有一道信任围栏(trust fence)。connection/index.ts 的 PRIVILEGED_METHODS 把一批方法钉死在 loopback——即使部署声明了 trustedHosts(非回环访问),这些方法也不放行。注释把理由写得像安全评审记录:

📄 packages/client/connection/src/index.ts (第 79-119 行,节选)

// trustedHosts 是 DNS-rebinding 围栏,明确不是认证——所以在真正的认证层出现之前,// 整个配置平面保持 loopback-same-origin。const PRIVILEGED_METHODS = new Set([ // 特权方法名单:即使部署声明了 trustedHosts,这些也钉死在 loopback  'agentPreset.read', // preset 组合点名了会话要跑哪些插件——读一个就是侦察;copy/remove 重排部署提供的能力  'agentPreset.copy', // copy preset = 重排部署提供的能力组合——比只读 list 权限高得多  'agentPreset.openDocument', // 驱动宿主桌面打开文档——比旁边只读的 list 权限高得多  'agentPreset.remove',  'host.pickDirectory', // 原生目录选择对话框作用于宿主机本身——LAN 调用者不该有这能力 // 原生对话框作用于宿主机本身  'host.openPath',  'settings.describe', // 返回所有暴露命名空间的配置——读和写同样特权(侦察)  'settings.openDocument', // 打开宿主机的 settings 文档文件——驱动桌面,不是纯读  'settings.update', // 修改用户配置——写操作,必须 loopback  'settings.replace', // 整体替换配置——比 update 更危险(一次抹掉所有命名空间)  'settings.mutate',  'credentials.describe', // 报告任意环境变量名是否已配置、从哪来——匿名调用者不该有的侦察  'credentials.set', // 写入凭证存储——匿名 LAN 调用者绝对不能碰  'credentials.unset',  'llm.discoverModels', // 携带草稿凭证 + 让宿主向调用者选的 URL 发 GET 并回报状态/解析体 = 匿名 LAN 调用者的探测工具])

🔹 一个刻意的"不钉":agentPreset.list(选择 preset)不在特权名单里。注释解释:选一个 preset 看起来像提权——但 session.create 本来就接受 agentPreset 参数,只钉"切换"不钉"创建"等于在开着的门旁边装栅栏;更根本的是,部署的默认 preset 本身就带 bash 和文件系统工具——任何能开会话的调用者已经能以本进程身份跑命令。能力不是 preset 授予的。

六、客户端连接控制器:严格握手 + 指数退避

浏览器侧的 ConnectionController(connection/src/client/connection.ts)拥有两条物理流,职责边界写得很清:"Controller 拥有物理流;业务分发归 SessionManager"。它的核心循环是严格就绪握手:describe 证明一元可达性、两个 onOpen 证明物理流建立——三者齐了才允许 onConnected 触发 resync,否则 resync 可能跑在 subscribed baseline 前面:

📄 packages/client/connection/src/client/connection.ts (第 107-169 行)

private async loop(): Promise<void> {  while (this.running) { // running 是跨 await 的活体守卫——stop() 可能在任何 await 处翻转它    const gen = ++this.generation // 代际号:每轮重连一个新 generation,旧代的迟到事件靠它丢弃    const ac = new AbortController()    this.current = ac    let muxOpened = (): void => {} // 占位闭包(executor 同步替换):mux 流 onOpen 的 resolve 句柄 // 占位闭包(executor 同步替换):mux 流 onOpen 的 resolve 句柄    let hostOpened = (): void => {} // host 流同理——两个占位,等各自流的 onOpen 回调触发 // host 流同理    const streamsOpen = Promise.all([ // 两条物理流都建立才算"开"      new Promise<void>((resolve) => { muxOpened = resolve }),      new Promise<void>((resolve) => { hostOpened = resolve }),    ])    const failed = new Promise<void>((resolve) => { // 任一泵结束(流丢失)→ settle → abort 本代      const settle = (): void => { if (gen === this.generation && !ac.signal.aborted) ac.abort(); resolve() } // 任一泵结束 → 校验代际仍有效且未被 abort → 中止本代 + resolve failed promise      void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle) // mux 流:会话事件(session/event、queue、approval…)      void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle) // host 流:宿主级事件    })    try { // 严格就绪握手:describe(一元可达)+ 双流 onOpen(物理建立)——三者齐了才算 connected      const timeout = new AbortController() // 独立超时控制器:只用于 streamOpenTimeout,不影响主 ac      const [description] = await Promise.all([ // 严格就绪握手:describe(一元可达)+ 双流 onOpen(物理建立)        this.api.host.describe({}),        Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), // 超时兜底:永不发 onOpen 的坏代理不能把连接卡死——超时后按已连处理,live-gap repair 补漏      ]) // Promise.all 完成 = describe 成功 + 双流都开(或超时兜底)      timeout.abort()      const descriptionResult = description.result      if (!descriptionResult.ok) { throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) }      if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') // 握手期间被 stop/abort → 本代作废,走 catch 分支 // 握手期间被 stop/abort → 本代作废      this.attempt = 0 // 成功一次,退避计数归零      this.emitState('connected') // 去重发射:只在状态变化时通知 UI      if (this.isGenerationActive(ac)) { // state sink 可能同步 stop 掉控制器——不再存在的代不发布 description        this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) }) // onConnected 触发 SessionManager resync(拉基线)      }    } catch { // describe 失败 / 超时 / abort——都算代际失败,落到共享退避      if (!ac.signal.aborted) ac.abort() // 传输失败 = 代际失败:确保本代被中止后落到共享退避    }    await failed // 等某条流结束——正常路径:一直泵到断连才 resolve // 等某条流结束(正常路径:一直泵到断)    if (!this.isRunning()) return // stop() 已生效 → 干净退出    this.emitState('reconnecting') // UI 立刻看到"重连中"——覆盖整个退避+重试窗口(去重发射) // UI 立刻看到"重连中"——覆盖整个退避+重试窗口    this.attempt += 1    console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)    const idle = new AbortController() // 退避等待的取消控制器:stop() 可以打断 sleep    await sleep(this.backoffDelay(this.attempt), idle.signal) // 指数退避 + 抖动(见下)  }}

退避策略是"上限减半 + 随机上半区":cap/2 + random*(cap/2),即实际延迟落在 [cap/2, cap]——既有指数增长(base=500ms、factor=2、max=10s),又用抖动避免多客户端同步重试打爆宿主。所有参数都是可选配置,注释明确"部署可变——不硬编码调参":

参数
默认值
语义
backoffBaseMs
500
首次重试退避上限(实际延迟 = cap/2..cap,带抖动)
backoffFactor
2
连续失败每多一次,上限乘这个因子(指数增长)
backoffMaxMs
10000
退避上限封顶——再失败也不超过 10s
streamOpenTimeoutMs
3000
等双流 onOpen 的超时;超时后按已连处理,live-gap repair 补漏帧

还有一个容易被忽略的细节:sink 异常隔离callSink() 把每个 sink 调用包在 try/catch 里——业务层(SessionManager)抛错只记日志,绝不影响泵和重连语义。"坏的业务层不能拖垮连接层"是这条链的隔离原则。

七、数据对象层:Session 拥有状态,Notifier 批量通知

下行帧到达后,Session.handleMuxEnvelope()(runtime/src/client/sessions/session.ts)做分发——一个 switch 把 MuxFrame 路由到事件窗口、队列镜像、pending 交互(approval/question)。注意 stream/error 永远到不了 Session:Controller 已经收敛了它。这里摘 approval 的 mint/settle 对:

📄 packages/client/runtime/src/client/sessions/session.ts (第 467-515 行,节选)

handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void { // mux 帧到达的分发 switch(Manager-only 入口,UI 永不直接调)  switch (frame.type) {    case 'session/event': { this.acceptLiveEvent(frame.event, frame.view); return } // 会话事件 → 进事件窗口 + 触发 conversation 重建    case 'session/queue': { this.queueMirror.replace(frame.items); this.notifier.markDirty(); return } // 队列镜像整体替换——宿主是权威,客户端只镜像 // 队列镜像整体替换(宿主是权威)    case 'session/subscribed': { // 新 mux 代基线:宿主在同一条流上、subscribed 之后推队列快照      this.subscribedLastSeq = frame.lastSeq      if (this.queueMirror.reset()) this.notifier.markDirty() // 在这里清旧镜像——与 onConnected/resync 时序无竞态(在那两处清可能抹掉已落地的基线) // 在这里清旧镜像——与 onConnected/resync 时序无竞态(在那两处清可能抹掉已落地的基线)      return    }    case 'approval/requested': { // 审批请求到达 → mint 一个 PendingWait(挂起等待宿主侧 resolve) // 审批请求 → mint 一个 PendingWait(挂起等待宿主侧 resolve)      const { type: _type, sessionId: _sid, ...payload } = frame // 剥掉信封字段,payload 才是业务数据      this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m))) // respond 闭包:用户决策后走 HTTP 上行回宿主 // respond 闭包:用户决策后走 HTTP 上行回宿主      this.notifier.markDirty()      return    }    case 'approval/resolved': { // 审批被(别处)解决 → settle 对应 pending,UI 的等待态消失 // 审批被(别处)解决 → settle 对应 pending,UI 的等待态消失      for (const item of this.pending.values()) { if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item) } // 按 approvalId 匹配——同一会话可能有多个并发审批      this.notifier.markDirty()      return    }    case 'question/requested': { const { type: _t, sessionId: _s, ...q } = frame; this.mint(new PendingWait('question', rpcId, this.sessionId, q, m => this.api.respond(m))); this.notifier.markDirty(); return } // 提问请求:同 approval 模式——mint + markDirty    case 'question/resolved': { const item = this.pending.get(`q:${frame.questionRpcId}`); if (item !== undefined) this.settle(item); this.notifier.markDirty(); return } // 按 questionRpcId 精确匹配(比 approval 的遍历更直接)    default: return // stream/error 到不了这里(Controller 收敛);未知帧忽略(文档化的默认行为)  }}

Session 同时是一个裸 observable 源subscribe() + getSnapshot()。通知的批量语义由 Notifier(notifier.ts)承担——它的文件头注释把设计意图写得像规格书:

📄 packages/client/runtime/src/client/sessions/notifier.ts (第 37-51, 93-102 行)

markDirty(): void { // 状态变更入口:标脏 + 排一次微任务 flush  this.dirty = true // 标脏:快照缓存已过期,下次读必须重建  this.notifyPending = true // 通知待发送——与 dirty 是两个独立 bit(pull 不能吞掉 push)  if (this.scheduled === 'microtask') return // 已有微任务在排队 → N 次 markDirty 合并成一次通知(批处理核心)  this.schedule('microtask') // 排一个微任务:flush 时先重建快照再通知 listener}markFrameDirty(): void { // 流式变更入口:标脏 + 每帧最多发布一次累计状态  this.dirty = true // 标脏:快照缓存已过期,下次读必须重建  this.notifyPending = true // 通知待发送——与 dirty 是两个独立 bit(pull 不能吞掉 push)  if (this.scheduled !== 'none') return // 已有更高优先级的调度(微任务)→ 不降级;否则用 rAF 对齐渲染帧  this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')}flush(): void { // 批量 flush:先重建快照缓存,再通知——useSyncExternalStore 要求 getSnapshot 引用稳定  if (!this.notifyPending) return  if (this.listeners.size === 0) return // 无监听者 → 跳过重建(保持帧风暴廉价),下次 getSnapshot 懒重建  this.notifyPending = false  if (this.dirty) { this.dirty = false; this.rebuild() } // 重建在通知之前:保证 listener 看到的快照是新的  for (const listener of this.listeners) listener()}

🔹 为什么这样设计:流式 token 到达是高频事件——如果每个 chunk 都触发一次 React 重渲染,帧率直接崩。markFrameDirty 用 requestAnimationFrame 把"一帧内的 N 个 chunk"合并成一次发布;而受控输入(比如用户打字)走 notifyNow 同步 flush,因为"React 会把 DOM 回滚到旧值、光标跳到行尾"——注释原话。新鲜度和通知是两个独立的 bit:pull(ensureFresh)在 markDirty 和 flush 之间重建快照,但不能吞掉通知——否则 push 订阅者(对象层 watcher)在任何读者先 pull 时都会饿死。

Session.prompt() 则展示了客户端如何把"发送消息"这件事做对——包括一个微妙的时序决策:blank(空会话)的翻转发生在宿主接受时,而不是尝试时

📄 packages/client/runtime/src/client/sessions/session.ts (第 190-260 行,节选)

async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> { // queue = 追加到当前 turn 之后;steer = 打断当前 turn // queue = 追加到当前 turn 之后;steer = 打断当前 turn  this.promptError = null // 清上一轮错误(发送即重置)  this.lastAgentError = null  this.promptAttempted = true // 同步、在第一个 await 之前:blank→engaging 边必须在会话区第一帧可见(首条消息先于导航到达的场景)  if (this.blankBit) this.firstPromptPendingTurn = true  this.notifier.markDirty()  let result: RpcResult<{ accepted: true }>  try {    if (this.address === undefined) { // 普通会话:走 sessions.prompt RPC(上行 HTTP)      result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, // 普通会话:走 sessions.prompt RPC(上行 HTTP);mode 决定排队还是打断 content,        clientTimeZone: resolvedClientTimeZone(), })).result // 客户端时区随请求带上——宿主侧时间戳/调度依赖它    } else if (this.address.mode === 'one-shot') { // one-shot 子代理会话:只读,拒绝发送(错误码 subagent-not-resumable)      result = { ok: false, error: { code: 'subagent-not-resumable', message: 'one-shot subagent conversations are read-only', details: { childSessionId: this.address.childSessionId } } } // one-shot 子代理会话只读——明确拒绝而非静默忽略    } else { // 可续子代理:继续对话走 subagents.prompt      if (content.some(part => part.type === 'image')) { // 图片输入对子代理续聊不可用——检查后明确报错,不静默丢弃 // 图片输入对子代理续聊不可用——明确报错而非静默丢弃        result = { ok: false, error: { code: 'attachment-error', message: 'Image input is unavailable for subagent continuations.', details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' } } } // 错误码 + reason 双字段:UI 可展示、程序可分支      } else {        const routed = (await this.api.subagents.prompt({ ...this.address, // 可续子代理:走 subagents.prompt(address 含 childSessionId + mode)          content: content.flatMap(part => part.type === 'text' ? [{ type: 'text' as const, text: part.text }] : []), // 只转发文本部分(图片已被上面拒绝)——flatMap 过滤非文本 // 只转发文本部分(图片已被上面拒绝)          clientTimeZone: resolvedClientTimeZone(), })).result        result = routed.ok ? { ok: true, value: { accepted: true } } : routed // 统一成 sessions.prompt 的返回形状——调用方只面对一种成功形态      }    }  } catch (error) { result = transportError(error) } // 传输层错误(网络断、超时)统一转 RpcResult——调用方不区分业务错/传输错 // 传输层错误统一转成 RpcResult 形状——调用方只面对一种失败形态  if (!result.ok) { this.promptError = { op: 'send', error: result.error }; this.notifier.markDirty(); return result } // 失败:记录错误 + 标脏(UI 显示发送失败)+ 提前返回  if (this.blankBit) { // blank 在"接受"时翻转,不在"尝试"时:宿主判据是已记录的 turn/start(事实而非乐观)    this.blankBit = false // blank → engaged:会话从"空"变成"有内容"——侧栏列表、workspace 复用资格都依赖这个翻转 // 被拒的首条 prompt 必须让会话保持 blank——客户端 blank 镜像只降不升    this.options.onEngaged?.(this) // 通知上层"会话已激活"(侧栏列表、workspace 复用资格等依赖这个事实)    this.notifier.markDirty()  }  return result}

"blank 镜像只降不升"这条规则值得停一下:如果首条 prompt 被拒就提前翻转 blank,会话会永远显示在列表里、并失去 connectWorkspace 的复用资格——而宿主侧的权威判据(已记录的 turn/start)说它还是空的。客户端镜像只能向宿主的权威事实收敛,不能自己乐观地升。

八、响应式桥:裸源 → React hook 的唯一通道

数据对象层是零 React 的——它只产出"裸 observable 源"(subscribe + getSnapshot)。把这些源变成组件能用的 selector hook,全栈只有一个构造器:web-react 的 bindSnapshotSelector()。整个文件 24 行:

📄 packages/client/web-react/src/bind.ts (第 18-24 行)

export function bindSnapshotSelector<T>(w: HostObservable<T>): SnapshotSelectorHook<T> { // 入参:裸 observable 源(engine store / Session 对象 / store 实例)  const subscribe = (fn: () => void) => w.subscribe(fn) // 每个源只捕获一次稳定闭包——组件跨渲染永不重新订阅  const getSnapshot = () => w.getSnapshot() // 同理;同时为方法型源重绑 this  return function useSelector<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean): S { // 返回的 selector hook:传选择器 + 可选相等函数(默认 Object.is)    return useSyncExternalStoreWithSelector(subscribe, getSnapshot, undefined, sel, eq) // uSES shim:React 官方的外部 store 桥,selector 版——只在选择器的输出变化时重渲染  }}

这个"唯一 hook 构造器"的设计对应 AGENTS.md 里的数据访问阶梯:框架 hook(常设座位 + provide/inject 绑定的 use<Name>)→ 声明的 store(useStore/actions)→ inject 回调 → 其他一律是新的框架扩展点,需要主线程仲裁。业务组件里禁止出现 useSyncExternalStore、手动 subscribe 接线、或把外部快照镜像进本地状态——"给每个响应式事实一个拥有它的通道",而不是各造各的轮子。

三条活数据通道的分工可以浓缩成一张表(这是本讲最该带走的对照):

通道
适用场景
所有权与约束
owner props(renderSlot 处传入)
父组件知道的数据(如 sidebar 的 collapsed/width)
owner 在 renderSlot 调用点提供;纯数据 + 回调,禁止 ReactNode 值
声明的 store(useStore/actions)
跨 entry 共享、或需活过 remount 的状态(如面板几何)
register 时声明工厂;读 useStore、写 actions.*——actions 是完整变更 API,禁止模块级单例句柄
inject face(apply 闭包)
注册者私有的数据/回调(如 layout.attachPanels)
只返回纯数据和回调;裸 observable 只能进保留的 hooks 隔间,组件永远看不到源

🔹 组件永远看不到 ctx:ctx 只属于 apply 世界(插件体和 inject 工厂闭包)。所有 .tsx 业务组件的数据和回调都通过四个 props share 到达——不 import 服务类、不读 React context(BindingContext 是渲染器内部件)、不自造 hook。"组件需要新东西?答案是把它从源头(owner 站点 / store 声明 / inject face)穿成 prop,而不是加一个 hook。"

九、数据流全景:一条消息的往返

把本讲的各层串起来,用户在 Web UI 里发出一条消息后的完整路径如下:

上行路径(prompt:HTTP RPC)

① 组件层:composer 调 session.prompt(content, mode)

promptAttempted 同步置位(首帧可见);blank 翻转等宿主接受。

② 传输层:POST /api/sessions/prompt

信任围栏(loopback/trustedHosts)→ JSON-RPC envelope → method 与 endpoint 一致性校验。

③ 宿主侧:apiProxy → sessions.prompt handler

request.signal 贯穿——浏览器断开即中止宿主操作;结果以 server-response 帧回传。

下行路径(事件:WebSocket downlink)

④ 宿主侧:WebSocketDownlinks.pump → send(socket, frame)

mux/host 两条流;逐帧 await(背压);上行消息 = 协议违规,1008 断开。

⑤ 客户端:ConnectionController.pumpStream → sink

sink 异常隔离(业务抛错不拖垮泵);stream/error 在此收敛,到不了 Session。

⑥ Session.handleMuxEnvelope → Notifier.markFrameDirty

事件进窗口;rAF 批量合并流式 chunk;flush 先重建快照再通知。

⑦ React:useSyncExternalStoreWithSelector → 重渲染

selector 输出变化才触发;组件只看到 props share,看不到 ctx。

十、小结:Web 客户端的三条设计主线

🔹 壳自足 + fail loud:加载页不依赖任何插件;启动失败时列出"哪个 entry、缺哪个服务",宁可白屏+报告不给半个 UI。inject waiting 没有超时,全量 fiber sweep 就是那个补偿。

🔹 单向分层 + 唯一通道:数据对象层零 React(grep-assertable);裸 observable 源 → bindSnapshotSelector 是唯一 hook 构造器;组件永远看不到 ctx,数据只走 owner props / store / inject 三条声明过的路。

🔹 slot = 组合的唯一 API:register 即替换(single)或并列(list),children 声明 = 独占渲染权,冲突在加载期失败。shell 只做一次 renderSlot('root'),整棵 UI 树由 slot 递归展开。

🔹 传输分工 + 信任围栏:上行 HTTP(JSON-RPC over POST)、下行 WebSocket(downlink-only,1008 断违规);特权方法钉死 loopback,trustedHosts 是 DNS-rebinding 围栏而非认证。

下一讲(第 38 讲)进入高级主题:扩展系统与自修改——agent 如何检查和挂载它自己的插件。Web 客户端的"一切皆插件"哲学,在那里会看到宿主侧的镜像。

📚 系列导航

← 第 36 讲:ACP 自动化协议

→ 第 38 讲:扩展系统与自修改 (extensions)

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料