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 客户端、带重连的 ConnectionController、ctx.connection |
@deepseek-ai/dsh-client-runtime | (空 apply) | SlotRegistry、SessionRuntime、WorkspaceRuntime、流泵 sink |
@deepseek-ai/dsh-client-modules | 扫描 dsh.client、组装启动图、伺服 /plugins/<id>/client.js、clientModules | ClientModuleSystem——懒模块表 |
@deepseek-ai/dsh-client-hmr | stat 轮询 bundle、伺服 /plugins/events SSE | EventSource 热替换插件 fiber |
@deepseek-ai/dsh-client-web | — | Shell 内核:AppWebEntry、AppRoot、模块表种子 |
@deepseek-ai/dsh-client-web-react | — | React 绑定:createSlotRenderer、bindSnapshotSelector、SessionProvider |
这一划分遵循客户端 bundle 纯净门(packages/client/tsdown.client.ts):插件 bundle 不得互相做值导入;协作一律经由 Cordis 服务(ctx.*),从而让浏览器 bundle 保持解耦、可 HMR。
传输:HTTP 上行,WebSocket 下行
宿主面把一切绑在 API_PATH = '/api' 下(packages/client/connection/src/api-path.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 契约承载三种载体:
| 载体 | 上行 | 下行 | 何时 |
|---|---|---|---|
WebApiClient | fetch POST 到 /api/<method> | 每条流一条下行 WebSocket(events.mux、events.host) | 真实页面 |
FixtureApiClient | 内存中 | — | ?fixture=... 启动模式(页面 URL 携带 fixture) |
createWebConnectionRpc() | fetch POST | — | 通用底层 RPC 通道 |
WebApiClient(client/web-api-client.ts)继承 AbstractApiClient:一元调用(settings.*、sessions.*…)走 globalThis.fetch;下游事件流是仅下行 WebSocket,用 @deepseek-ai/dsh-host-apiproxy/api 里共享的 serverRequestSchema/hostFrameSchema/muxFrameSchema 解析。收到 stream/error 载荷或 socket 关闭即结束生成器,控制器据此触发重连。
浏览器 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/assertTrustedAuthority(api-request-trust.ts)会拒绝任何 Host 既非环回、又不在 trustedHosts 配置权威列表中的请求(DNS 重绑定防御)。其中一部分方法还会经由 PRIVILEGED_METHODS 额外钉死在环回——包括 settings.describe、settings.mutate、credentials.set、credentials.describe、host.pickDirectory、host.openPath、agentPreset.read/remove 与 llm.discoverModels。而模型目录(llm.providers、llm.models)刻意不在此列:局域网客户端的模型选择器确实需要它。
连接生命周期
唯一的流循环是 ConnectionController(client/connection.ts),由 ctx.connection.start(sinks) 驱动。它同时开启两条流,等待严格就绪握手(host.describe + 两个 onOpen,受 streamOpenTimeoutMs 约束),随后发出 onConnected;任何失联都会降级为 reconnecting 并以带抖动的指数退避重试:
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
backoffBaseMs: 500, backoffFactor: 2,
backoffMaxMs: 10_000, streamOpenTimeoutMs: 3_000,
}控制器与消费者无关:ConnectionSinks 只是把帧/状态交给调用 start() 的那一方。运行时插件(src/client/index.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 的类型是 ISessions(contract/sessions.ts),ctx.workspaces 是 IWorkspaces(contract/workspaces.ts);具体实现 SessionRuntime/WorkspaceRuntime 都以 createSnapshotStore(contract/store.ts)的 push 模型快照为表头。读取走快照存储与选择器钩子;写入回调 RPC 客户端。功能包被刻意挡在具体服务之外——加宽接口本身就是加宽功能可用能力的显式行为。
Remote(ctx.remote)提供第二种、RPC 惯用的镜像:每帧 host/remote-event 会被 $dispatch 给 ctx.remote.$on 订阅者,而 session/<domain> 帧会更新各会话镜像——例如 jobsBySession 列表。
Agent 作用域使用 Typert:ctx.typert.contexts.registerClient('agent', …) 让 Agent 作用域解析成会话 id;createScope/scopeOf(agents/scope.ts)为每个会话铸造一个作用域,agent id === 会话 id。
客户端模块系统
ClientModuleSystem(packages/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):
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-hmr(packages/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-connection(ctx.connection) | runtime、ui-*、apps/web 启动 | 服务 + IApiClient |
dsh-client-runtime(ctx.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-slots | web-react 绑定、每个 slot 注册者 | 类型契约(SlotMap),零运行时代码 |
dsh-client-connection 是唯一也带宿主面的浏览器包——其 Node 面绑定 /api 网关,因此同样是 bundle/web-app 的依赖。
浏览器面会从 @deepseek-ai/dsh-host-apiproxy/api 与 …/client 导入类型——即浏览器安全通道——而绝不导入 apiproxy 包根,否则会把 bootHost/cordis 拖进页面 bundle。runtime 还合并了几个 cordis Events:slots/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 快照,使消费者绝不会渲染旧一代的事实。该描述本身由 HostDescriptionSource(getSnapshot + 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.ts—ConnectionController,重连循环与退避。packages/client/modules/src/client/system.ts—ClientModuleSystem(懒 CJS 模块表)以及packages/client/hmr/src/client/index.ts(原地 fiber 替换)。