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、clientModules | ClientModuleSystem——懒模块表 |
@deepseek-ai/dsh-client-hmr | stat 轮询 bundle、伺服 /plugins/events SSE | EventSource 热替换插件 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)已删除。
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 关闭会终结载体的代,网关据此触发重连。
浏览器 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 不再意味着「无凭据」。路由由两道闸门串联守卫:
- 浏览器会话鉴权(
browser-auth.ts)——进程铸造一个按进程的 HMAC 启动令牌,并打印已鉴权 URL(?token=…);打开它的浏览器一次性把它换成签名的dsh-auth-<authority>cookie(HttpOnly; SameSite=Strict,绑定权威,默认 30 天寿命),凡不带有效 cookie 的请求一律 401。ctx.connection.authorizeIndex用同一规则把关 index 伺服。 - 信任围栏(
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 是:
connection.start({
onConnected: (host) => …, // 代源报告就绪,含首次连接
onStateChange: (state) => …, // 'connected' | 'disconnected' | 'connecting',去重
onReconnectRequested: () => …, // 每次逻辑重试前启动一次全新物理载体尝试
})ConnectionState 恰为 'connected' | 'disconnected' | 'connecting'(首次尝试有结果后才发布;等价状态去重)。退避与就绪默认值(client/connection.ts:24,31):
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):
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-store | ui-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-slots | ui-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 替换)。