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 前缀(信任围栏 + 浏览器会话鉴权 + HTTP 桥)与 dsh-auth cookie 交换HTTP RPC 客户端 + ConnectionHandle(registerGenerationSource、start)、夹具传输、ctx.connection
@deepseek-ai/dsh-client-store—无 React 的可观测/快照存储原语(createSnapshotStore、SnapshotStore<T>),支撑一切客户端 store
@deepseek-ai/dsh-client-modules扫描 dsh.client、组装启动图、伺服 /plugins/??… 组合 bundle、clientModulesClientModuleSystem——懒模块表
@deepseek-ai/dsh-client-hmrstat 轮询 bundle、伺服 /plugins/events SSEEventSource 热替换插件 fiber
@deepseek-ai/dsh-client-web—Shell 内核:AppWebEntry、无框架 BootPage、平台种子表
@deepseek-ai/dsh-client-ui-renderer—SlotRegistry(一个 Cordis Service)、React slot 绑定、ctx.uiRenderer.mount

旧的 dsh-client-runtime 与 dsh-client-web-react 包已删除。运行时的 SlotRegistry 现在位于 packages/client/ui-renderer/src/client/registry.ts(类 SlotRegistry extends Service,即 ctx.slots);React 绑定拆分为 ui-renderer(渲染器自身)、ui-chat(会话 UI)与 ui-brand-official(品牌槽位占用者);新的 dsh-client-store 提供 ui-renderer 与每个 widget store 都构建于其上的无 React 工具包。

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

传输:一元 RPC 上行,一条 mux WebSocket 下行 ​

宿主面把一切都绑在 API_PATH = '/api' 下(packages/client/connection/src/api-path.ts)——这个常量如今是唯一的路径常量;旧的 MUX_EVENTS_PATH/HOST_EVENTS_PATH(/api/events.mux、/api/events.host)已删除。

ts
export const API_PATH = '/api'

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

载体上行下行何时
createWebConnectionRpc()fetch POST 到 /api/<method>一条共享的 Gateway WebSocket;各插件流在其上多路复用真实页面
createFixtureConnectionRpc()内存中—?fixture=... 启动模式(页面 URL 携带 fixture)

旧的 WebApiClient/AbstractApiClient 类,以及来自 @deepseek-ai/dsh-host-apiproxy/api 的 serverRequestSchema/hostFrameSchema/muxFrameSchema 已全部删除(该包已删除)。一元调用如今携带来自 packages/client/connection/src/rpc.ts 的类型化信封——ClientRequest({ type: 'client-request', rpcId, method, payload })由 ServerResponse({ type: 'server-response', rpcId, result })应答,RpcMessage = ClientRequest | ServerResponse——由 clientRequestSchema/serverResponseSchema(rpc-schema.ts)校验。

下游事件流是经一条共享 WebSocket 的 Typert Remote 流:浏览器打开 ws(s)://<origin>/api/remote.mux(REMOTE_STREAM_MUX_PATH,属主为 packages/api/gateway),共享的 RemoteStreamMuxClient 把帧扇出给彼此独立可取消的按流读者——RemoteStream/RemoteSnapshotStream/RemoteJournalStream(见 API 层)。流失败或 socket 关闭会终结载体的代,网关据此触发重连。

txt
浏览器                                Node 宿主
  ├─ fetch POST /api/session.prompt ──►  /api 前缀(信任 + 鉴权,300 MiB 上限)
  ├─ fetch POST /api/settings.mutate ──►  精确 fetch 路由 → Typert 网关
  └─ WS  /api/remote.mux ◄──────────────  网关流多路复用(Ping 保活)
        ├─ session/control  ├─ session/follow  └─ workspace/follow
  └─ EventSource /plugins/events ◄──────(仅 HMR)graph/rebuilt 帧

类型门:浏览器包从 @deepseek-ai/dsh-client-connection/client 导入链路词汇(ConnectionHandle、RpcId、RpcRequest、RpcResponse、RpcResult、StreamChunk、MessageId…),从 @deepseek-ai/dsh-api-remotes/client 导入组装的 Remote 类型——绝不导入宿主包根。

信任围栏与浏览器会话鉴权 ​

/api 不再意味着「无凭据」。路由由两道闸门串联守卫:

  1. 浏览器会话鉴权(browser-auth.ts)——进程铸造一个按进程的 HMAC 启动令牌,并打印已鉴权 URL(?token=…);打开它的浏览器一次性把它换成签名的 dsh-auth-<authority> cookie(HttpOnly; SameSite=Strict,绑定权威,默认 30 天寿命),凡不带有效 cookie 的请求一律 401。ctx.connection.authorizeIndex 用同一规则把关 index 伺服。
  2. 信任围栏(api-request-trust.ts)——isTrustedApiRequest/assertTrustedAuthority 依旧拒绝任何 Host 既非环回、又不在 trustedHosts 配置权威列表中的请求(DNS 重绑定防御)。旧的 PRIVILEGED_METHODS 环回钉死清单已删除(模型目录的局域网暴露被并入普通信任模型);只剩这两个助手。

连接生命周期 ​

唯一的恢复循环是 ConnectionController(client/connection.ts),但它由 API 网关驱动:网关用 ctx.connection.registerGenerationSource(source) 注册长寿命代源,再调用 ctx.connection.start(sinks, config)(第二次 start 会抛)。sinks 是:

ts
connection.start({
  onConnected:          (host) => …,   // 代源报告就绪,含首次连接
  onStateChange:        (state) => …,  // 'connected' | 'disconnected' | 'connecting',去重
  onReconnectRequested: () => …,       // 每次逻辑重试前启动一次全新物理载体尝试
})

ConnectionState 恰为 'connected' | 'disconnected' | 'connecting'(首次尝试有结果后才发布;等价状态去重)。退避与就绪默认值(client/connection.ts:24,31):

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

旧的就绪握手(host.describe + 两个流 onOpen,受 streamOpenTimeoutMs 约束)被代就绪握手取代:注册的代源先挂好它的增量监听器,交付上线后报告 ready,受 generationReadyTimeoutMs 约束。任何失联都降级为 disconnected 并以带抖动的指数退避重试;onStateChange !== 'connected' 会撤回当前代快照,让消费者绝不渲染旧一代的事实。connection/reset 是逐流缓存(命令目录、队列镜像)需重新拉取的缓存失效事件。

在客户端镜像宿主服务 ​

宿主服务绝不会存在于浏览器中——它们被镜像到窄契约之后,而这些契约如今随控制器包的客户端面一起交付:

  • ctx.sessions 的类型是 ISessions(packages/api/session-controller/src/client/contract/sessions.ts),由 session-controller 浏览器面里的 ClientSessions(sessions/service.ts)实现——一个架在 ctx.remote.session 一元调用加 Gateway 流工厂(createSessionControlStream、SessionEventStream)之上的对象层。ctx.workspaces 是 IWorkspaces(packages/api/workspace-controller/src/client/service.ts,WorkspaceController extends Service)。具体面沿用同一套快照存储纪律(createSnapshotStore,来自 dsh-client-store);读取走快照存储与选择器钩子,写入回调 RPC 客户端。功能包被刻意挡在具体服务之外——加宽接口本身就是加宽功能可用能力的显式行为。
  • Remote(ctx.remote)是第二种、RPC 惯用的镜像:一个架在 Gateway 流之上的 Typert 客户端。贡献用 ctx.remote.$mount(contribution) 挂载(12 命名空间的组装在 packages/api/remotes/src/client/index.ts),转发来的宿主事件经网关内部事件流到达,投递给 ctx.remote.$on(...) 订阅者。旧的 $dispatch 机制与 host/remote-event 帧已删除。

Agent 作用域使用 Typert:createScope/scopeOf(session-controller 的 scope.ts)为每个会话铸造一个作用域(agent id === 会话 id),Agent 作用域的 Remote 事件经它解析。

客户端模块系统 ​

ClientModuleSystem(packages/client/modules/src/client/system.ts)是 Node ESM loader 的浏览器对应物。宿主 Node 面扫描 dsh.client 包、对其 ./client.js bundle 求哈希(sha1 → 12 位十六进制),并以结构化 IndexInjection 行把入口图注入为 window.__DSH_BOOT__(bootInjections)。浏览器内核在任何 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[] }

bundle 以组合 URL 伺服——/plugins/??<pkg>/client.js,<pkg2>/client.js&rev=<hash>——带 cache-control: public, max-age=31536000, immutable(只有 HMR 与 source map 才用按插件的 /plugins/<id>/client.js URL)。解析分支顺序(懒 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/renderer)会自然级联到其 UI 依赖。Shell 变更仍意味着整页刷新——只有 dsh.client 插件入口可热替换。失败一律不回滚。

包关系一览 ​

提供方消费方依赖种类
dsh-client-connection(ctx.connection)api-remotes、ui-*、apps/web 启动服务 + 类型化信封
dsh-client-storeui-renderer、每个 widget store无 React 快照存储
dsh-client-ui-renderer(ctx.slots、ctx.uiRenderer)每个 ui 模块SlotRegistry 服务 + 渲染器
dsh-client-modules(浏览器面)shell 内核、dsh-client-hmr模块表
dsh-client-modules(Node 面)web-app bundle、dsh-client-hmr(Node 面)clientModules 服务
dsh-client-ui-slotsui-renderer 绑定、每个 slot 注册者类型契约(SlotMap),零运行时代码

dsh-client-connection 是唯一也带宿主面的浏览器包——其 Node 面绑定 /api 前缀与浏览器会话 cookie 交换,因此同样是 bundle/web-app 的依赖。

浏览器面会从 @deepseek-ai/dsh-client-connection/client、@deepseek-ai/dsh-api-remotes/client 以及属主包的 ./types 子路径导入类型——即浏览器安全通道——而绝不导入宿主包根,否则会把宿主专用符号拖进页面 bundle。ui-renderer 还拥有 cordis Events 合并:slots/changed(key) 在 slot 重新变更时发出,connection/reset() 丢弃逐流缓存。

夹具(fixture)模式 ​

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

连接前的区间不报告任何状态——UI 把「尚无状态」当作正在连接而非故障,而 disconnected/connecting 迁移会撤回当前代快照。代本身由 ConnectionGenerationState(getSnapshot + subscribe)伺服,这是一个按代作用域划分的可观测对象,ConnectionController 每次 onConnected 都会重发它。

本页涉及的包 ​

包
@deepseek-ai/dsh-client-connection
@deepseek-ai/dsh-client-store
@deepseek-ai/dsh-client-modules
@deepseek-ai/dsh-client-hmr
@deepseek-ai/dsh-client-web
@deepseek-ai/dsh-client-ui-renderer
@deepseek-ai/dsh-client-ui-slots

延伸阅读 ​

  • 前端:UI 模块 — 运行时把这些镜像交给 shell 的哪些 slot 使用。
  • 前端:Web 前端 — 从 apps/web 到被伺服 dist 的完整启动链。
  • 前端:本地化 — ctx.locale 如何维护中英目录并安装渲染器 LocaleFace。
  • 前端:设置 Schema 与表单 — 骑在同一链路上的设置模型层。
  • LLM 平台:API 层 — /api 路由、remote-stream mux 与浏览器会话鉴权。
  • 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 替换)。