Skip to content

Web 前端不是一个自持状态的 SPA,而是一套精简的客户端运行时:它从 Node 进程通过自定义链路拉取会话、工作区、投影、设置等一切数据,再经由基于 slot 的 UI 把事实呈现出来。本节剖析浏览器侧的传输与运行时——即 packages/client/ 下的各包,以及 apps/web 如何接入它们。

客户端 / 宿主的划分

同一包在两个面上工作(双面面约定):Node 面(包根,由宿主 tsdown 构建)在宿主进程里运行,负责组装/合并 window.__DSH_BOOT__、伺服 bundle、注册 HTTP 路由;浏览器面./client 导出,由客户端 tsdown 构建)在页面里运行。所有 Web 包都声明 dsh.client 元数据,供 dsh-client-modules 的 Node 面扫描 Loader 树发现它们。

角色(Node 面)角色(浏览器面)
@deepseek-ai/dsh-client-connection在 Web 服务器上挂载 /api 网关与 WebSocket 下行HTTP/WebSocket 客户端、带重连的 ConnectionControllerctx.connection
@deepseek-ai/dsh-client-runtime(空 applySlotRegistry、SessionRuntime、WorkspaceRuntime、流泵 sink
@deepseek-ai/dsh-client-modules扫描 dsh.client、组装启动图、伺服 /plugins/<id>/client.jsclientModulesClientModuleSystem——懒模块表
@deepseek-ai/dsh-client-hmrstat 轮询 bundle、伺服 /plugins/events SSEEventSource 热替换插件 fiber
@deepseek-ai/dsh-client-webShell 内核:AppWebEntryAppRoot、模块表种子
@deepseek-ai/dsh-client-web-reactReact 绑定:createSlotRendererbindSnapshotSelectorSessionProvider

这一划分遵循客户端 bundle 纯净门packages/client/tsdown.client.ts):插件 bundle 不得互相做值导入;协作一律经由 Cordis 服务(ctx.*),从而让浏览器 bundle 保持解耦、可 HMR。

传输:HTTP 上行,WebSocket 下行

宿主面把一切绑在 API_PATH = '/api' 下(packages/client/connection/src/api-path.ts):

ts
export const API_PATH = '/api'
export const MUX_EVENTS_PATH  = `${API_PATH}/events.mux`   // 会话 mux 帧
export const HOST_EVENTS_PATH = `${API_PATH}/events.host`  // 宿主帧

浏览器面(packages/client/connection/src/client/)用一个 IApiClient 契约承载三种载体:

载体上行下行何时
WebApiClientfetch POST 到 /api/<method>每条流一条下行 WebSocket(events.muxevents.host真实页面
FixtureApiClient内存中?fixture=... 启动模式(页面 URL 携带 fixture
createWebConnectionRpc()fetch POST通用底层 RPC 通道

WebApiClientclient/web-api-client.ts)继承 AbstractApiClient:一元调用(settings.*sessions.*…)走 globalThis.fetch;下游事件流是仅下行 WebSocket,用 @deepseek-ai/dsh-host-apiproxy/api 里共享的 serverRequestSchema/hostFrameSchema/muxFrameSchema 解析。收到 stream/error 载荷或 socket 关闭即结束生成器,控制器据此触发重连。

txt
浏览器                                Node 宿主
  ├─ fetch POST /api/session.list ──────►  API 网关(HostConnectionService)
  ├─ POST /api/settings.mutate ─────────►  settings.*(环回特权)
  ├─ WS  /api/events.mux   ◄────────────── mux 帧流
  ├─ WS  /api/events.host ◄────────────── 宿主帧流
  └─ EventSource /plugins/events ◄──────(仅 HMR)graph/rebuilt 帧

信任围栏与鉴权

/api 没有凭据——鉴权是一道围栏而非签名令牌。isTrustedApiRequest/assertTrustedAuthorityapi-request-trust.ts)会拒绝任何 Host 既非环回、又不在 trustedHosts 配置权威列表中的请求(DNS 重绑定防御)。其中一部分方法还会经由 PRIVILEGED_METHODS 额外钉死在环回——包括 settings.describesettings.mutatecredentials.setcredentials.describehost.pickDirectoryhost.openPathagentPreset.read/removellm.discoverModels。而模型目录(llm.providersllm.models刻意不在此列:局域网客户端的模型选择器确实需要它。

连接生命周期

唯一的流循环是 ConnectionControllerclient/connection.ts),由 ctx.connection.start(sinks) 驱动。它同时开启两条流,等待严格就绪握手(host.describe + 两个 onOpen,受 streamOpenTimeoutMs 约束),随后发出 onConnected;任何失联都会降级为 reconnecting 并以带抖动的指数退避重试:

ts
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
  backoffBaseMs: 500, backoffFactor: 2,
  backoffMaxMs: 10_000, streamOpenTimeoutMs: 3_000,
}

控制器与消费者无关:ConnectionSinks 只是把帧/状态交给调用 start() 的那一方。运行时插件(src/client/index.ts)是唯一消费者:

ts
const loop = connection.start({
  onMuxEnvelope:     (envelope) => sessions.handleMuxEnvelope(envelope),
  onHostEnvelope:    (envelope) => { sessions.handleHostEnvelope(envelope); workspaces.handleHostEnvelope(envelope)
                                     /* 转发 host/remote-event 到 ctx.remote.$dispatch */ },
  onConnected:       () => { sessions.handleConnected(); workspaces.handleConnected(); ctx.emit('connection/reset') },
  onStateChange:     (state) => { if (state === 'reconnecting') sessions.handleDisconnected() },
})

值得留意的帧语义:在一代连接上,宿主会重放一条基线,让 resync 不会跑在已订阅状态之前;在 reconnecting 时,运行时丢弃与代数相关的交互状态。connection/reset 是逐流缓存(命令目录、队列镜像)需重新拉取的缓存失效事件。

在客户端镜像宿主服务

宿主服务绝不会存在于浏览器中——它们被镜像到窄契约之后。ctx.sessions 的类型是 ISessionscontract/sessions.ts),ctx.workspacesIWorkspacescontract/workspaces.ts);具体实现 SessionRuntime/WorkspaceRuntime 都以 createSnapshotStorecontract/store.ts)的 push 模型快照为表头。读取走快照存储与选择器钩子;写入回调 RPC 客户端。功能包被刻意挡在具体服务之外——加宽接口本身就是加宽功能可用能力的显式行为。

Remotectx.remote)提供第二种、RPC 惯用的镜像:每帧 host/remote-event 会被 $dispatchctx.remote.$on 订阅者,而 session/<domain> 帧会更新各会话镜像——例如 jobsBySession 列表。

Agent 作用域使用 Typert:ctx.typert.contexts.registerClient('agent', …) 让 Agent 作用域解析成会话 id;createScope/scopeOfagents/scope.ts)为每个会话铸造一个作用域,agent id === 会话 id。

客户端模块系统

ClientModuleSystempackages/client/modules/src/client/system.ts)是 Node ESM loader 的浏览器对应物。宿主 Node 面扫描 dsh.client 包、对其 ./client.js bundle 求哈希(sha1 → 12 位十六进制),并把入口图以 window.__DSH_BOOT__ 注入(ClientModuleRegistry.injectBootManifest)。浏览器内核在任何 Cordis 存在之前就基于这些行构造模块系统,并最先收养 client-modules 包装插件,从而提供 ctx.modules

线上的形态(manifest.ts):

ts
export interface WebBootEntry { id, url /* /plugins/<id>/client.js?rev=… */, rev, inject?, immediately? }
export interface WebBootGraph  { rev, entries: WebBootEntry[] }

解析分支顺序(懒 CJS):种子词 → 已记忆化记录 → 静态注册表(shell 自有模块)→ 图行(fetch + 具现化)→ 工厂具现化 → 抛错。bundle 只会注册其工厂(window.__ModuleLoader__.load);一切副作用——包括 CSS 注入——都在工厂闭包内、于首次 require 时才执行,并递归具现化依赖。这正是 HMR 安全的原因:重跑一个 bundle 只是纯注册。

浏览器里的 HMR

dsh-client-hmrpackages/client/hmr/src/client/index.ts)通过 EventSource('/plugins/events') 监听。Node 面每 pollIntervalMs(默认 500)stat 轮询 bundle 的 mtime/size,对变更的 bundle 重新哈希,并推送 SSE 帧 {type:'graph'} / {type:'rebuilt', id, rev}

收到 rebuilt 帧后,浏览器原地重载该入口:invalidate() 旧工厂 → prefetch() 新 bundle → 先删注册表的旧 fiber → 排空其 disposer → 移除它拥有的 <style data-plugin> 标签 → entry.refresh()。由于激活顺序由 fiber 的 inject 等待决定,重载数据层插件(connection/runtime)会自然级联到其 UI 依赖。Shell 变更仍意味着整页刷新——只有 dsh.client 插件入口可热替换。失败一律不回滚。

包关系一览

提供方消费方依赖种类
dsh-client-connectionctx.connectionruntime、ui-*、apps/web 启动服务 + IApiClient
dsh-client-runtimectx.sessions/workspaces/slots每个 ui 模块(标准钩子)服务 + slot 契约
dsh-client-modules(浏览器面)shell 内核、dsh-client-hmr模块表
dsh-client-modules(Node 面)web-app bundle、dsh-client-hmr(Node 面)clientModules 服务
dsh-client-ui-slotsweb-react 绑定、每个 slot 注册者类型契约(SlotMap),零运行时代码

dsh-client-connection 是唯一也带宿主面的浏览器包——其 Node 面绑定 /api 网关,因此同样是 bundle/web-app 的依赖。

浏览器面会从 @deepseek-ai/dsh-host-apiproxy/api…/client 导入类型——即浏览器安全通道——而绝不导入 apiproxy 包根,否则会把 bootHost/cordis 拖进页面 bundle。runtime 还合并了几个 cordis Eventsslots/changed(key)connection/reset() 是运行时自己持有的两个,分别在 slot 重新变更与一代连接丢失时发出。

夹具(fixture)模式

连接插件从页面 URL 选择载体:若 URL 携带 fixture 查询参数,conn 便是 FixtureApiClient(内存中,?fixture),宿主描述源也从 fixtureClient.rpc 解析 rpc。这正是浏览器树在测试/jsdom 环境里、在毫无链路或 Node 进程的情况下启动的方式——同一 ConnectionHandle 契约由夹具传输伺服,因此运行时层走的是完全相同的代码路径。非浏览器上下文默认 isLoopback 为真;设置作用域对这类客户端在内存模式时切换为 mode: 'memory'(持久化仅限环回)。

可用来渲染的粗粒度 ConnectionState 恰为 'connected' | 'reconnecting',并做了去重,使 onStateChange 仅在真实迁移时触发。连接前的区间不报告任何状态——UI 把「尚无状态」当作正在连接而非故障,而 reconnecting 会撤回当前 hostDescription 快照,使消费者绝不会渲染旧一代的事实。该描述本身由 HostDescriptionSourcegetSnapshot + subscribe)伺服,这是一个按代作用域划分的可观测对象,connection.start() 每次 onConnected 都会重发它。

本页涉及的包

@deepseek-ai/dsh-client-connection
@deepseek-ai/dsh-client-runtime
@deepseek-ai/dsh-client-modules
@deepseek-ai/dsh-client-hmr
@deepseek-ai/dsh-client-web
@deepseek-ai/dsh-client-web-react
@deepseek-ai/dsh-client-ui-slots

延伸阅读

  • 前端:UI 模块 — 运行时把这些镜像交给 shell 的哪些 slot 使用。
  • 前端:Web 前端 — 从 apps/web 到被伺服 dist 的完整启动链。
  • 前端:本地化ctx.locale 如何维护中英目录并安装渲染器 LocaleFace
  • 前端:Schema 表单 — 消费同一线上信封的 schema 驱动设置编辑器。
  • packages/client/connection/src/client/connection.tsConnectionController,重连循环与退避。
  • packages/client/modules/src/client/system.tsClientModuleSystem(懒 CJS 模块表)以及 packages/client/hmr/src/client/index.ts(原地 fiber 替换)。