设计:registry 插件支持 client half(浏览器端 UI 插件)

August 10, 2026 · View on GitHub

历史文档(2026-08 转向后):本文描述 plugin-registry 已移除的独立机制(patch/CLI/ctx.plugins),仅作决策依据与演进记录保留;当前形态见 official-0809-coveragepackages/plugin/console

状态:已实现(v3)。本文记录从设计到实现的全过程:现状链路、断点、登记/分发机制、构建契约、生命周期联动,以及两轮 subagent 评审的结论与修订。实现位于 packages/client/modulesregisterExternal/unregisterExternal)与 packages/plugin/pluginclient 声明、PluginLocalService 联动、ctx.inject 补登记);示例见 examples/greeter。本文的「设计」各节描述落地后的机制,与源码一致;「开放决策」记录拍板结论。

目标与范围

  • 能做:registry 插件声明 client 字段并携带浏览器 bundle → 安装/启用后进入 boot graph,浏览器加载其 bundle,fiber 创建(inject 等待生效)。
  • 不做:不改浏览器侧 ClientModuleLoaderboot.tsx(加载契约不变);不做 client 插件在线市场;不改变「安装默认禁用」信任边界。
  • 边界陈述:两条 client 通道并存——官方 client 插件dsh-client-* 包,随产品发布,进 Loader 树)与 registry client 插件(用户安装,运行时登记)。前者是产品结构,后者是用户扩展;同一能力建议先做官方包,registry client half 服务本地安装场景。

现状链路:官方 client 插件如何进浏览器

浏览器端插件的加载通道(packages/client/modules):

  1. Node 侧扫描ClientModuleHostServicesrc/index.ts)遍历 ctx.loader.entries()(Loader 配置树),对每个 entry 解析 package.json 的 dshClient 声明(platform: 'web')+ exports["./client"],得到 bundle 绝对路径(resolveMeta)。
  2. 组合 graphprocessOne(entryName) 把每个合格 entry 建成一行 WebPluginRecordWebBootEntryid/url/rev/inject/immediately),compose() 产出 WebBootGraph,经 injectBootManifest 注入 window.__DSH_BOOT__(index.html <head> 首个 script)。
  3. 路由服务serveBundle 处理 /plugins/<id>/client.js(及 .map),从 table 的 clientPath 读文件返回。
  4. 浏览器加载ClientModuleLoadersrc/client/system.ts)按 graph row 加载外部 classic script → 注册 factory(window.__ModuleLoader__.load({id, factory}))→ 首次 import 时 materialize;bundle 的 id 必须与 graph row id 精确相等arrive() 校验 factories.has(id),system.ts:104-107)。

扫描是增量式的:任何 internal/plugin 事件(fiber 构建/销毁)标记 entry name dirty,微任务 flush 与 loader entries 对照(entry.fiber !== undefined && !entry.disabled)。

断点:为什么 registry 插件进不了浏览器

processOne 只认 Loader 树 entry。registry 插件经 ctx.plugin() 运行时挂载,不在 Loader entries 里,其自身 id 永远不作为 entry name 进入扫描

注意机制细节(评审修正):vendored loader 的 internal/plugin handler(vendor/loader/lib/index.js:682-684)会把 entry-less fiber 关联到祖先 entryfiber.entry = fiber.parent[Entry.key],经 ctx 原型链 getTraceable 可达 plugin-local 的 entry ctx)——registry 插件 fiber 的 fiber.entry 通常不是 undefined,而是 @deepseek-ai/dsh-plugin(plugin-local 的 entry name)。后果:该 fiber 不会在 ClientModuleHostService 的 handler 里被 O(1) 丢弃,而是把 @deepseek-ai/dsh-plugin 标记 dirty,processOne 对其负缓存(无 dshClient 声明 → null)。结论不变:registry 插件自身 id 永远到不了 processOne;且 internal/plugin 事件不能作为按 registry 插件粒度的再触发钩子(见「补登记」)。

此外 registry 插件不是 npm 包:没有 package.json 的 dshClient 声明与 exports["./client"]resolveMeta 无从解析。

设计一:登记机制(ClientModuleHostService 增加动态来源)

ClientModuleHostService 增加两个公开方法,直接把 registry 插件建成一行 table 记录(绕过 Loader entries 检查),compose/路由/注入全部复用:

/** Register one external (non-Loader-entry) client row, e.g. a registry plugin. */
registerExternal(id: string, opts: {
  clientPath: string            // bundle 绝对路径(安装后位于 <dshHome>/plugins/...)
  inject?: string[]             // 图元数据(浏览器 fiber 的 inject 由 bundle 自身导出决定,见「模块表面契约」)
  immediately?: boolean
}): string                      // bundle rev

/** Remove a previously registered external row; unknown id is a no-op. */
unregisterExternal(id: string): void

要点:

  • registerExternal 读 bundle 内容算 rev(复用 shortHash),建成 graphRow(id, rev, inject, immediately) 存入 table;若 id 已存在则替换(重装/重建场景)。compose() + notifyGraphChanged() 与扫描路径共享同一实现。
  • 路由零改动serveBundle/plugins/<id>/client.jsclientPath(id) 读文件——id 含斜杠(acme/greeter)与 scoped 包名同构,pathname.slice(prefix.length, -suffix.length) 已支持(现有注释即声明此意)。
  • 注入零改动injectBootManifestthis.composed,外部行与扫描行一样进入 __DSH_BOOT__
  • 依赖方向@deepseek-ai/dsh-plugin(第三方 registry 包)调用 @deepseek-ai/dsh-client-modules(官方包)——官方包不被第三方反向依赖,方向正确。
  • id 碰撞守卫:manifest id 接受原生 publisher/name 与 scoped npm 包名 @scope/name增量兼容)——后者与 loader entry 名可能同名,registerExternal 拒绝 ctx.loader.entries() 中的名字(防扫描 delete/重建互踩与 Node half 双挂载),官方插件走 Loader 树,登记失败走 mount 回滚(N4:regex 仍防 ../? 破坏 url 契约)。
  • 时序:页面加载时 __DSH_BOOT__ 已固定;运行时 register 的变更对已加载页面不生效,下次页面刷新后可见(评审 N6:dev 下 HMR 只对已在图中的行生效,运行中新增的行仍需刷新)——MVP 接受「启用后刷新生效」。

补登记:host 缺席时怎么办(评审 R1)

plugin-local 激活即跑 reconcile()plugin/src/index.ts:64-68inject = []);ClientModuleHostService 要等 httpServer+loaderstatic inject = ['httpServer', 'loader'])。组合层(profile bundle patch)行序无加载语义——两者无顺序保证,且倾向 plugin-local 先激活。mount 期间 root 属性读 clientModuleHost 得 undefined → 登记静默跳过,且无再触发 → 进程重启后已启用插件的 client half 从 boot graph 永久消失

选型:ctx.inject 延迟补登记(推荐)ctx.inject(['clientModuleHost'], cb) 是 cordis 标准「等服务就绪再执行」机制(this.mixin("registry", ["inject", "plugin"]),任何 ctx 可用;vendor/cordis/lib/index.js:743,1583-1596):

  • PluginLocalService 在 apply 内注册一个惰性补登记:this.ctx.inject(['clientModuleHost'], () => this.retryExternalRegistrations())
  • mount(id) 时:host 存在 → 立即 registerExternal;host 缺席 → 把 id 记入 pendingExternal 集合。
  • unmount(id) 时:同步从 pendingExternal 删除该 id(评审 N1)——否则启动窗口内「入 pending → 程序化 disable → host 就绪」会把已禁用插件的 client half 登记进 boot graph,违反「只有 enable 才登记」不变式。
  • retryExternalRegistrations():遍历 pendingExternal 逐项**复查「当前仍 mounted 且 enabled」(以 mounts 集合为准)**后再登记(评审 N1);逐项 try/catch,单项失败(如 bundle 事后被删)保留在 pending,不阻断其余项,随下一次 onGraphChanged 或重装/重启用例重试(评审 N2)。
  • 纤维生命周期:inject 纤维由 cordis 管理,host 就绪时自动启动、plugin-local 卸载时自动释放。

备选(不采用,记录理由):① 订阅 internal/service 事件(reflect.js:226 每次服务提供/变更 emit)——事件从提供者 ctx 向上冒泡,plugin-local 与 modules 是兄弟 ctx,监听可达性依赖 isolate 过滤,脆弱;② modules 构造完成后广播补扫——官方包反向感知第三方包,破坏依赖方向。ctx.inject 无事件耦合、无反向依赖,是唯一干净选项。

验证补一条用例:「reconcile 时 host 缺席 → host 就绪后 retryExternalRegistrations 补登记」。

设计二:manifest client 字段(声明形态)

dsh.plugin.json 增加可选 client 对象,镜像官方 dshClient 声明的语义:

{
  "id": "acme/greeter",
  "client": {
    "main": "./client.js",
    "inject": ["@deepseek-ai/dsh-client-connection"],
    "immediately": false
  }
}
字段必填含义
main浏览器 bundle 相对路径(相对插件根;与 main 并列,指向构建产物)
inject可选图元数据:声明插件依赖的 client 服务,进 __DSH_BOOT__ 行(对齐官方 dshClient.inject 语义);浏览器 fiber 的实际 inject 由 bundle 自身导出决定
immediately可选是否 stage-one 预取;缺省 false(懒加载)

校验(评审 O2):installPlugin 已检查 manifest.main 存在(registry.ts:134-136),平行检查 client.main(若声明)——坏声明在安装时暴露,而不是启用时把仅浏览器侧缺陷放大为 web 组合启动失败。parseManifestclient 做结构校验(main 字符串、inject 字符串数组、immediately 布尔)。

路径安全(评审 Y1):client.main resolve 后断言仍在插件根内(join(pluginRoot, client.main) 可越出根,如 ../secret.js;插件本身是用户显式启用的可信代码,风险有限但校验成本极低)。

设计三:分发机制(tarball/目录携带 bundle)

  • registry 插件目录(或 tarball 解压后)内含 client.js 等构建产物;installPlugin 已把整个目录复制进 <dshHome>/plugins/<publisher>/<name>/,bundle 随目录落地,tarball 协议零改动
  • 启用时 client.main 相对插件根解析为绝对路径 → registerExternal(id, { clientPath })/plugins/<publisher>/<name>/client.js 可服务。
  • 与「安装默认禁用」一致:只有 enable 才登记,禁用的插件不进 boot graph、浏览器拿不到其 bundle。

构建契约(bundle 格式 + 模块表面)

registry 插件的 client bundle 必须满足浏览器侧加载契约,与官方 client 包同一约束:

  • factory 注册:脚本执行 window.__ModuleLoader__.load({ id, factory })factory(require) 返回导出面(CJS 工厂形态;tsdown client preset 的 banner/footer 生成此结构,packages/client/tsdown.client.ts)。id 必须与 graph row id 精确相等acme/greeter),否则 arrive() 校验失败。
  • 模块表面契约(评审 R2):bundle 默认导出的模块必须是 Cordis 插件(函数 / 类 / 带 apply(ctx) 的对象)——浏览器侧每个 graph row 都变成 loader entry(boot.tsxloader.create({ name })),entry 导入的模块交给 ctx.registry.plugin,非插件形状无法激活,boot sweep 报 failed。fiber 级服务等待由 bundle 自身导出的 inject 决定(boot.tsx 只传 {name});manifest client.inject 仅作为图元数据,不参与 fiber 注入。
  • 外部依赖只允许平台模块CLIENT_EXTERNALSPLATFORM_MODULES + runtime store 豁免)——即 connection/runtime/ui-slots 等官方 client 包走 module table 解析;其余 @deepseek-ai/* 值导入是构建错误(bundle purity gate),跨插件协作走 cordis 服务。
  • CSS Modules 由 lightningcss 内联,<style data-plugin> 注入;sourcemap 可选(.map 路由已存在)。

构建模板:交付物(评审 R3)

现有 clientBundle preset 与 monorepo 深度耦合(PLATFORM_MODULES/CLIENT_EXTERNALS 清单、purity gate 插件、CSS 虚拟模块、REPOSITORY_ROOT 都内嵌),仓库外作者无法使用。构建模板从「建议」提升为交付物,两部分:

  1. 仓库内 tsdown 骨架examples/greeter 加 client half,提供可跑的 tsdown.config.ts(引用 preset 或等价配置),产出 client.js——同时作为冒烟测试的构建步骤。
  2. standalone 构建文档:独立的作者指南,含 CLIENT_EXTERNALS 快照清单、banner/footer/intro 逐字模板(id 作为显式入参)、purity gate 规则、CSS Modules 处理说明——仓库外作者可据此用任意 bundler(tsdown/esbuild/rollup)产出合格 bundle。

这是进入实现阶段的硬门槛:设计稿的冒烟测试必须有可执行的构建步骤。

生命周期联动(PluginLocalService)

packages/plugin/plugin/src/service.tsPluginLocalService 是挂载入口,client 登记与其同步:

动作Node 侧client 侧
mount(id) 成功fiber 创建 + 贡献校验manifest.client → host 存在则 registerExternal,否则入 pendingExternal
unmount(id)fiber 释放unregisterExternal(id)
enable(id)mount + 索引置位随 mount 登记
disable(id)unmount + 索引复位随 unmount 注销
uninstall(id)unmount + 删目录随 unmount 注销
reconcile()(load 时)逐个 mount enabled随 mount 登记(host 缺席走 pending + 补登记)
host 就绪(补登记)retryExternalRegistrations() 遍历 pending 登记

可选服务模式clientModuleHost 只在 Web 组合存在(CLI/headless 组合无 modules 包)。mount 内用 root 属性读(与 verifyContributionstools 同模式):(this.ctx.root as { clientModuleHost?: ClientModuleHost }).clientModuleHost,undefined 时跳过登记——CLI-only 环境不受影响。实现选择了评审 O5 的更简替代PluginLocalService 内定义最小结构接口 ClientModuleHostregisterExternal/unregisterExternal),消除对官方包的 peer 依赖(@deepseek-ai/dsh-plugin 的 peerDependencies 不变)。

登记回滚(评审 Y2)registerExternal 放在 verifyContributions 成功之后,且与 fiber 创建同处一个失败回滚路径(service.ts:75-84 已有「verify 失败 → dispose fiber → 删 mounts」模式)——register 抛错同样回滚,避免「fiber 已挂载但 client 行未登记」半状态。

验证方案

  • modules 包单测node-half.spec.ts 扩展):registerExternal 加行/替换/unregisterExternal 删除后 graph() 变化;serveBundle/plugins/acme/greeter/client.js 返回 bundle 内容、未知 id 404;替换语义下 rev 更新(评审 Q4 的升级原子性)。
  • plugin 包测试service.spec.ts 扩展):fake clientModuleHost 置于 root → mount 触发 register、unmount 触发 unregister;root 无该服务时 mount 正常跳过;reconcile 时 host 缺席 → 补登记纤维在 host 就绪后登记 pending 集合(评审 Y4)。
  • 冒烟examples/greeter 加 client half(tsdown 骨架构建 client.js)→ install + enable → 浏览器 boot graph 含 acme/greeter 行、/plugins/acme/greeter/client.js 200、fiber 创建(bundle 导出 Cordis 插件)。

开放决策

  1. 白名单强制点(评审 O4/O7,已拍板)MVP 不设 client.inject 白名单,接受「用户启用即信任」的文档级约束——与现有信任模型(安装默认禁用、显式启用才执行)一致;强制点仅在构建期 externals 子集(registry 专用 CLIENT_EXTERNALS 白名单,构建时校验 bundle 只能 require 平台模块),模块边界与服务边界是两回事,前者构建期强制、后者文档约束。standalone 作者文档写明此信任模型。
  2. HMR(评审 O3,大半免费)rebuilt()/onGraphChanged/HMR watch 同步都泛化于 table/graph 通用结构——外部行登记后自动获得 bundle 内容 watch、rebuilt 帧与浏览器端 reload(对 boot 时已在图中的行)。真正缺失的只有:运行中 enable 的新行在浏览器端没有对应 entry 创建(hmr client 的 graph frame v1 未用)——超出「不改浏览器侧」范围,记为后续。
  3. 多 bundle:一个插件只支持一个 client bundle(本文假设)还是可声明多个(按 surface)——MVP 取一个。
  4. 单实例假设clientModuleHost 每进程一个,registerExternal 的 table 是进程内存态;多 dshHome/多 web 组合场景不讨论(MVP 可接受,实现时一句声明)。
  5. 升级原子性(评审 Q4/N8):同 id 重装新版本(带新 bundle)时 registerExternal 替换原子、rev 覆盖更新——补一条测试锁定。真实重装路径是 uninstall → unmount(行注销)→ install → enable(重登记),期间行消失再出现属正常中间态;「原子」仅指替换调用本身。

参考

  • 官方 client 通道实现:packages/client/modules/src/index.tssrc/client/system.tssrc/client/manifest.ts
  • 浏览器 entry 创建:packages/client/web/src/boot.tsxloader.createassertEntriesActive
  • 构建约定:packages/client/tsdown.client.ts(banner/footer、CLIENT_EXTERNALS、purity gate)
  • 挂载与校验:packages/plugin/plugin/src/service.tssrc/manifest.tssrc/registry.tsmanifest.main 安装时校验)
  • cordis 机制:vendor/cordis/lib/index.jsctx.inject 等待服务、internal/service 事件)、vendor/loader/lib/index.jsinternal/plugin fiber 关联祖先 entry)
  • 现有示例:examples/greeter(无 client half)