Appearance
第 17 章 Web 客户端与 UI 架构
本章解析浏览器端:从页面加载、客户端 Cordis 运行时启动,到连接 Host、Slot 系统、主题 token 与类型化 RPC。核心包:apps/web、packages/client/*、packages/host/*、packages/api/remotes。
17.1 页面加载:apps/web 只是薄壳
apps/web 不是独立应用:
index.html只有<div id="root">与<script src="/src/main.ts">;main.ts只调用new AppWebEntry(el).run()(注释:Everything lives in@deepseek-ai/dsh-client-web; this file only finds the mount point);vite.config.ts的rejectStandaloneServe插件在vite dev/preview直接抛错——bare Vite 无法注入window.__DSH_BOOT__,只有dsh web(web profile)会在 HTML 里注入 boot 清单。这正是本书环境说明里"不要另起炉灶跑替代服务器"的原因:页面必须由 dsh web 宿主注入。
Vite 构建把 shell 源码别名打进 bundle(@deepseek-ai/dsh-client-web → packages/client/web/src/boot.tsx),但插件包从不在此打包——插件 bundle 运行时经客户端模块系统(fetch)到达。define 把 vendored Loader 里的 Node-only 探测替换掉(process.versions.node: '0.0.0'、node:module → stub)——客户端 loader 浏览器化的关键。
17.2 启动协议:window.DSH_BOOT
宿主把"客户端插件入口图"注入页面首屏 <head>(packages/client/modules/src/index.ts 的 injectBootManifest):window.__DSH_BOOT__ = JSON 作为第一个 script;< 转义为 \u003c——插件可控字符串不能逃出 script 元素。
ts
interface WebBootEntry {
id: string // 入口名 == 包名
url: string // '/plugins/<id>/client.js?rev=<rev>'
rev: string // bundle 内容 hash(缓存一致性锚)
inject?: string[] // 依赖边(信息性)
immediately?: boolean // 阶段一预取标记
}清单缺失/畸形直接 loud throw——没有合法清单就什么都引导不起来。
17.3 运行时启动:两阶段引导
boot.tsx 的 AppWebEntry.run():
- 模块面(module face):解析
__DSH_BOOT__建ClientModuleSystem(lazy-CJS 模块表,Node ESM loader 的浏览器对应物),注册 shell 自有模块(app-shell等),预取所有immediately行; - 插件面(plugin face):
new Context()→ctx.plugin(Loader),先注入loader.internal = modules(模块系统挂到 Loader 上,否则 bundle import 在浏览器必炸)→ 并行loader.create({name})装载全部插件行 +app-shell→await loader.await(); - 启动审计:sweep 每个 entry——无 fiber = import 失败;pending = 等某个服务没等到(cordis inject 等待无超时,这个 sweep 是 fail-loud 补偿);
- 全部 active → AppRoot 一次性从 loading 页切到真实 UI。
设计亮点:shell 自足——loading 页在插件全部失败时也必须能工作(fail-loud 呈现不依赖它报告失败的那个系统)。
17.4 客户端插件形态
package.json 的 dsh.client 字段声明客户端插件:
jsonc
"dsh": { "client": { "inject": ["@deepseek-ai/dsh-client-runtime", ...], "platform": "web" } }platform必须'web';inject是依赖边;immediately标记阶段一预取(runtime就声明了immediately: true);- Host 半
parseDshClient要求exports["./client"]指向构建出的 bundle; - 增量扫描:
ClientModuleRegistry订阅internal/plugin,fiber 构造/销毁标记 entry dirty,微任务批量处理——稳态一个坏包只 warning 不拖垮别人; - bundle 路由:
GET /plugins/<id>/client.js?rev=<rev>,no-cache(缓存一致性靠 rev query 而非 HTTP 缓存)。
客户端模块系统
执行插件 bundle 只注册 factory(window.__ModuleLoader__.load({ id, factory })),bundle body 副作用(含 CSS 注入)在 factory 闭包内、materialization 时才跑。require 走注册表查找,无 load 分支(跨插件 value import 是构建错误);seed.ts 用 satisfies 把 PLATFORM_MODULES(react/cordis/ui-slots/web-react/ui-primitives/ui-attachment/schema-form)钉死——漏一个静态 import 编译即失败。
17.5 连接 Host:双向异质通道
连接不是单 WebSocket 流,而是双向异质:
- 上行(浏览器→Host)= HTTP POST RPC:
fetch POST /api/<endpoint>,body{ type: 'client-request', rpcId, method, payload }; - 下行(Host→浏览器)= 两个只读 WebSocket:
/api/events.mux(多路复用流)与/api/events.host(host 流)。客户端在 WS 上发消息是协议违规——服务端直接close(1008, 'downlink only'); - 连接循环:
ConnectionController每代实例私有,并行 pump 两条流,严格握手(unary describe 证明单发可达 + onOpen 证明物理流建立),失败指数退避(500ms 起 ×2,上限 10s); - 信任模型:
isTrustedApiRequest+trustedHosts是 DNS-rebinding fence 而非认证;PRIVILEGED_METHODS把 settings/credentials/pickDirectory/llm.discoverModels 等钉死为 loopback-only。
17.6 类型化 RPC:@Remote 与 Gateway
三层包:typert/protocol(声明+修饰器,零运行时反射副作用)、typert/generator(TS 项目分析 → InvocationDescriptor 生成物)、api/remotes(装配)。
@Remote标记 Host 公开实例方法;@RemoteScope(key)按 merge 声明的 scoped Context kind 选接收者;TypertRemoteService把super(ctx, serviceKey)绑成默认 namespace;- 例证(
packages/host/plugin-inventory):class PluginInventoryGateway extends TypertRemoteService,@Remote('list') list()返回插件清单快照——Host 侧零缓存 Remote-only 服务,每次直接读 Loader; InvocationDescriptor是两侧共享的运行时形式:service/namespace/method/invocation(direct|context)/有序 parameters(codec:strict Zod schema 或 src-json);参数可以是lookup(Host 对象 ↔ wire id,需注册 resolver)或json;cancellation把AbortSignal参数标记为保留注入点(不进 wire args);- Host Gateway
ctx.typertGateway在/api分派到<namespace>/<method>;Client 侧ctx.remote.<namespace>.<method>调用、ctx.remote.$on订阅转发事件(事件 allowlist 在api/remotes/src/remote-events.ts)。
17.7 Slot 系统
三层解耦:纯核心 ui-slots(零运行时依赖)→ Service 层 ctx.slots(runtime/src/client/slots.ts)→ 渲染器 web-react/src/scoped-slots.tsx。
注册协议
ctx.slots.register(options, component):options 声明children(否则报 "slot not declared")、kind(single | list | keyed | chain)、scope(root | session-maybe | session);- 声明即认领:一个 slot 只允许一个父入口 declare;重复 declare loud throw;
- shadowing:single 整槽一格、keyed 按 key 一格、list 按 id 一格;每格取最低 priority 的存活 entry(tie 保持注册序);同格同 priority 二次注册 throws;chain 不 shadow(election 消费所有 entry);
- 失败隔离:single/keyed/list 的 entry 崩溃经 "abdicate" 退位让位下一个存活者(one-shot);chain 崩溃不退位(select 时再找替代);
- 生命周期:disposer 移除贡献并递归 collapse 声明的子 slot;
slots.inject等待 slot 声明生命周期(声明已存在 → 同步跑回调,否则声明提交后跑,collapse 时 dispose); - inject 面:
inject: (...args) => Record<string, unknown>为注册者业务面;业务数据走 apply 闭包 ctx,不存在 binding 对象参数。
作用域标准 props
框架注入的标准 props 由多个包声明合并合成:
SessionStandardProps(strict session scope):useSession、sessionId、useProjection;SessionMaybeStandardProps(session-maybe):可空变体;GlobalStandardProps(所有 slot):useSessions、useWorkspaces。
每个 use<Name> 钩子由 bindSnapshotSelector(use-sync-external-store/with-selector)构造——这是客户端栈里唯一的 hook 构造点:引擎与 host 只流动 bare observable 源,绑定发生在 React 侧。会话子树在 SessionProvider 里按 key={sessionId} remount。
主题 token 系统
--dsw-*token;override 层要求{light, dark}成对(单值在另一个色板下不可读);ThemeRuntime:getTheme()返回不可变快照;setTheme()是唯一偏好写入口;register()注册第三主题;overrideTokens(source, tokens)是 token 级 shadowing(后层胜出,同一 source 再调替换整层并置顶,返回精确层 disposer);- 首屏防闪烁:
injectBootTheme在<body>后插内联脚本(只解析system与 matchMedia,写colorScheme与data-ds-dark-theme)——插件装载前的空隙期也有正确主题; - 呈现层职责分离:ui-theme 从不碰 DOM,ui-layout 的 ThemePresenter 消费快照落到 DOM;
exportInspectTokens()导出 inspect 目录——这是动态 CordisTheme.listTokensinspect provider 的数据源。
17.8 会话快照:事件流折叠
SessionRuntime 用 mintScope 模式为每会话建作用域(agent id == session id);list / currentProvideInfo 两条 HostObservable 经 bindSnapshotSelector 绑成 use<Name> 钩子;事件流(session/event 下行)经 ConversationNodeAssembler 折叠成 ConversationSnapshot 提供给 UI——UI 永远渲染"从日志折叠出的快照",而不是直接操作活对象(数据最小化原则)。
17.9 Host 侧服务
packages/host/webserver:ctx.webServer——HTTP 服务、tapIndex(注入 boot manifest 与首屏主题脚本的 transform 钩子);frontend-static:托管构建出的 web 资源;plugin-inventory:@Remote('list')插件清单服务(上面例证)。
17.10 web profile 组成
packages/bundle/web-app 把 base bundle + webserver + client modules + UI 组件包等行组合成 web profile。第 8 章的启动链路在 Host 侧完成装载后,Host 启动 webServer,浏览器访问 3080 → 页面加载 → boot 清单 → 客户端运行时 → 连接 Host——一条完整的链路。
17.11 设计亮点小结
- 两阶段引导(模块面 → 插件面)与 shell 自足;
- 双向异质连接(HTTP 上行 + 只读 WS 下行)与 DNS-rebinding fence 信任模型;
- 客户端插件"注册 factory、materialize 才执行副作用";
- Slot 的声明即认领、shadowing 选举、abdicate 失败隔离;
- 主题 token 覆盖成对校验与首屏防闪烁;
- @Remote 生成式契约,两侧共享 InvocationDescriptor;
- 会话快照折叠:UI 只见日志派生快照。
文档提醒:
docs/subsystems/web.md讲的是ctx.web(Web 搜索/抓取工具),不是 Web 客户端——读官方文档时注意区分;ctx.clientModules(Host 侧)与ctx.modules(浏览器侧)也易混淆。
下一部分进入全书的应用篇:插件开发技巧。