Ver Fonte

refactor(client): close module loader bootstrap loop

imccyu há 1 mês atrás
pai
commit
c60f132b9c

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: aefb8a59008176eb7da955c08803a68925cb01de
-2026-07-23-client-plugin-loading-model.zh.md: f5e71e77b095a3273b96b39f07b0c3dbe35b02b8
+2026-07-23-client-plugin-loading-model.md: 02dadf6e1dc1f2c4fd99907446bc6d07b35ba471
+2026-07-23-client-plugin-loading-model.zh.md: 06ba5512a5a46b2a0d447024e143871ab56088fb

+ 6 - 6
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -28,13 +28,13 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers
 
 The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster.
 
-The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its ordinary factory before the Vite main module so the kernel can construct the module system. Runtime arrives through the same queue; static React, Cordis, and UI library identities come from the shell seed.
+The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its ordinary factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Runtime arrives through the same pending queue; static React, Cordis, and UI library identities come from the shell seed.
 
 ### One module system, one plugin governor
 
 The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an exports; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.**
 
-`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. Import and prefetch recursively register declared dynamic requests before their consumer; a factory then materializes any registered-but-unmaterialized request synchronously. The table resolves through a fixed branch order: seed word → memoized record → static registration (the adopted modules bootstrap) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (register the requested dynamic factories and the row's own factory; concurrent arrivals share one task) and `invalidate(id)` (drop the factory and record so the next arrival reloads it).
+`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. Import and prefetch recursively register declared dynamic requests before their consumer; a factory then materializes any registered-but-unmaterialized request synchronously. The table resolves through a fixed branch order: seed word → memoized record → graph-row classic-script registration → registered-factory materialization → loud throw. The modules factory is the bootstrap exception: the HTML facade materializes it first, and construction places those same exports directly in the memoized table. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (register the requested dynamic factories and the row's own factory; concurrent arrivals share one task) and `invalidate(id)` (drop a non-bootstrap factory and record so the next arrival reloads it).
 
 The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`.
 
@@ -44,11 +44,11 @@ Each graph row's `url` goes to a same-origin external classic `<script src>` wit
 
 The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages/<group>/<package>/src/...`. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged; `sourcesContent` carries the source, so the host only serves the map at `/plugins/<id>/client.js.map` and exposes no source route. The Vite shell also emits source maps, letting both shell code and out-of-graph plugins map stacks and performance profiles back to TypeScript/TSX.
 
-`rev` remains the script URL's query parameter and content-consistency anchor, and the bundle and map are both served with `no-cache`. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin host and build-stamped handoff id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
+`rev` remains the script URL's query parameter and content-consistency anchor, and the bundle and map are both served with `no-cache`. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
 
 ### The loading flow, end to end
 
-What happens between `dsh web` starting and the UI appearing? Three stages: the host composes a graph and parser-preloads bootstrap factories, the shell claims the module system and prefetches, then Cordis orchestrates.
+What happens between `dsh web` starting and the UI appearing? Three stages: the host composes a graph and parser-preloads bootstrap factories, the HTML facade creates the module system and the shell prefetches, then Cordis orchestrates.
 
 **Host side — compose the graph.**
 
@@ -58,12 +58,12 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
 
-**Phase one — the module face.** The injected HTML creates a handoff queue, executes the modules and runtime graph rows as blocking classic scripts, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel claims and materializes modules with a bootstrap `require` that rejects every external, constructs `ClientModuleSystem`, registers the same exports for the modules row, and lets the system adopt runtime's queued factory. It then prefetches every `immediately` row in parallel; prefetch recursively registers declared dynamic requests and the row itself without materializing either. A row's prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains an arrival mark, not a lifecycle barrier or package identity.
+**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, executes the modules and runtime graph rows as blocking classic scripts, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, and retains the instance in its module closure; construction switches the same facade to live registration before draining runtime's pending factory. The kernel then prefetches every `immediately` row in parallel; prefetch recursively registers declared dynamic requests and the row itself without materializing either. A row's prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains an arrival mark, not a lifecycle barrier or package identity.
 
 **Phase two — the plugin face.**
 
 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
-2. It creates the modules entry from the adopted bootstrap exports and one entry per remaining graph row. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
+2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` provides the closed-over system as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting.
 4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
 5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.

+ 6 - 6
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -28,13 +28,13 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
 
 [Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row,且每个包只有一个普通 `lib/client.js` factory bundle。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
 
-Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其普通 factory,让内核得以构造模块系统。Runtime 经同一 queue 到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
+Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其普通 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。Runtime 经同一个 pending queue 到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
 
 ### 一套模块系统,一个插件治理器
 
 浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
 
-`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其 factory——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在 factory 闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。Import 和 prefetch 会先递归登记已声明的动态请求,再登记消费者;随后 factory 会同步物化任何已登记但尚未物化的请求。模块表按固定分支顺序解析:seed word → 记忆化记录 → 静态登记(已接纳的 modules bootstrap)→ 已登记 factory → graph row 外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的动态 factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(丢弃 factory 与记录,下次到达即重新加载)。
+`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其 factory——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在 factory 闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。Import 和 prefetch 会先递归登记已声明的动态请求,再登记消费者;随后 factory 会同步物化任何已登记但尚未物化的请求。模块表按固定分支顺序解析:seed word → 记忆化记录 → graph row classic-script 登记 → 已登记 factory 物化 → 大声抛错。Modules factory 是自举例外:HTML facade 先物化它,构造过程再把同一 exports 直接写入记忆化表。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的动态 factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(丢弃非 bootstrap factory 与记录,下次到达即重新加载)。
 
 vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`。
 
@@ -44,11 +44,11 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages/<group>/<package>/src/...`。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样;`sourcesContent` 承载源码,因此 host 只需在 `/plugins/<id>/client.js.map` 供给 map,无需开放源码路由。Vite 壳也产出 sourcemap,使壳代码与图外插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-`rev` 继续作为脚本 URL 的查询参数和内容一致性锚点,bundle 与 map 都以 `no-cache` 供给。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 host 供给与构建期写入的 handoff id 是身份边界,`load` 后的工厂存在性检查负责拒绝未登记预期 id 的产物。
+`rev` 继续作为脚本 URL 的查询参数和内容一致性锚点,bundle 与 map 都以 `no-cache` 供给。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 host 供给与构建期写入的 registration id 是身份边界,`load` 后的工厂存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
-从 `dsh web` 启动到 UI 出现之间发生了什么?三个阶段:host 组合 graph 并由 parser 预载 bootstrap factory,外壳认领模块系统并预取,然后 Cordis 编排。
+从 `dsh web` 启动到 UI 出现之间发生了什么?三个阶段:host 组合 graph 并由 parser 预载 bootstrap factory,HTML facade 创建模块系统且外壳执行预取,然后 Cordis 编排。
 
 **host 侧——组合这张图。**
 
@@ -58,12 +58,12 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
 
-**第一阶段——模块面。**注入的 HTML 建立 handoff queue,以阻塞式 classic script 执行 modules 与 runtime graph row,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核用一个拒绝所有 external 的 bootstrap `require` 认领并物化 modules,构造 `ClientModuleSystem`,把同一 exports 登记给 modules row,再让系统接纳 queue 中的 runtime factory。随后它并行预取每个 `immediately` row;prefetch 会递归登记已声明的动态请求和 row 自身,但不物化任一项。单行预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是到达标记,不是生命周期屏障或包身份。
+**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,以阻塞式 classic script 执行 modules 与 runtime graph row,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports,并在模块闭包中保留该实例;构造过程先把同一 facade 切换到 live registration,再排空 runtime 的 pending factory。随后内核并行预取每个 `immediately` row;prefetch 会递归登记已声明的动态请求和 row 自身,但不物化任一项。单行预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是到达标记,不是生命周期屏障或包身份。
 
 **第二阶段——插件面。**
 
 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
-2. 它基于已接纳的 bootstrap exports 创建 modules entry,并为其余 graph row 各创建一个 entry。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
+2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 把闭包中的系统提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
-2026-08-15-client-shells-and-dynamic-packages.md: f71a53b65030106d9289931aac52c2dfce4be181
-2026-08-15-client-shells-and-dynamic-packages.zh.md: d185c62e8e7ac004f064b32f60f68327176009bc
+2026-08-15-client-shells-and-dynamic-packages.md: a92300663bac3bfe04768cf2a4f0c354c4c0c66f
+2026-08-15-client-shells-and-dynamic-packages.zh.md: a0bf695b32c8f2f903555f9f2c75d666eddeabd6

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md

@@ -45,13 +45,13 @@ There is no general `dsh.client.provide` alias mechanism. Dynamic rows and stati
 
 The modules Node half injects the startup protocol into the served HTML in this order:
 
-1. Create the `window.__ModuleLoader__` handoff queue.
+1. Install `window.__ModuleLoader__` in queue mode with `pendingQueue`, `load()`, and `create()`.
 2. Execute the modules graph row's ordinary `lib/client.js` as a blocking classic script.
 3. Execute runtime's ordinary `lib/client.js` the same way.
 4. Assign `window.__DSH_BOOT__`.
 5. Execute the Vite main module.
 
-Both early scripts only register factories. The startup kernel claims the modules handoff and materializes it with a `require` function that rejects every external, constructs `ClientModuleSystem` from those exports, registers the same exports for the modules row, and lets the system drain runtime's queued factory. The modules client face consequently has a zero-runtime-external bootstrap requirement.
+Both early scripts only register factories. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, and retains the system in a module closure. Construction switches the same facade to live mode before draining runtime's pending factory. The modules client face consequently has a zero-runtime-external bootstrap requirement.
 
 After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
 
@@ -79,7 +79,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
 
 Bundle contents stay stable when an npm dependency moves between peer and development sections, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance.
 
-The startup protocol depends on the modules and runtime package ids, and modules must remain self-contained at runtime. Missing bootstrap handoffs fail before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
+The startup protocol depends on the modules and runtime package ids, and modules must remain self-contained at runtime. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
 
 The shell consumes built `lib/` products, so source and browser artifacts can drift until the relevant build or watcher runs. Typechecking source alone does not prove the served application uses the same code.
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md

@@ -45,13 +45,13 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
 
 Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
 
-1. 建立 `window.__ModuleLoader__` handoff queue。
+1. 以 queue 模式安装 `window.__ModuleLoader__`,包含 `pendingQueue`、`load()` 与 `create()`。
 2. 以阻塞式 classic script 执行 modules graph row 的普通 `lib/client.js`。
 3. 以相同方式执行 runtime 的普通 `lib/client.js`。
 4. 赋值 `window.__DSH_BOOT__`。
 5. 执行 Vite 主模块。
 
-两个提前执行的脚本都只注册 factory。启动内核认领 modules handoff,用一个拒绝所有 external 的 `require` 函数物化它,使用导出的类构造 `ClientModuleSystem`,把同一导出注册给 modules row,再由模块系统排空 queue 中的 runtime factory。因此 modules client face 必须满足零 runtime external 的自举要求。
+两个提前执行的脚本都只注册 factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row,并在模块闭包中保留该系统。构造过程先把同一 facade 切换到 live 模式,再排空 runtime 的 pending factory。因此 modules client face 必须满足零 runtime external 的自举要求。
 
 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
 
@@ -79,7 +79,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
 
 Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
 
-启动协议依赖 modules 和 runtime 的 package id,modules 还必须保持运行期自包含。缺少 bootstrap handoff 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
+启动协议依赖 modules 和 runtime 的 package id,modules 还必须保持运行期自包含。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
 
 外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
 

+ 8 - 12
apps/web/tests/assembled-boot.ts

@@ -11,9 +11,8 @@ import { readFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { act, cleanup } from '@testing-library/react'
 import { afterEach, beforeEach, vi } from 'vitest'
-import type {
-  ClientModuleHandoffTarget, ClientPluginHandoff, WebBootEntry,
-} from '@deepseek-ai/dsh-client-modules/client'
+import { injectBootManifest } from '@deepseek-ai/dsh-client-modules'
+import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
 import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
 /** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
@@ -59,7 +58,7 @@ const bundles = new Map(PLUGINS.map(plugin => [
 
 interface FixtureWindow extends Window {
   __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: ClientModuleHandoffTarget
+  __ModuleLoader__?: ClientModuleLoaderTarget
 }
 
 class ResizeObserverStub {
@@ -123,14 +122,11 @@ export function mountAssembledApp(): void {
   root.id = 'root'
   document.body.appendChild(root)
   win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
-  const handoffs: ClientPluginHandoff[] = []
-  win.__ModuleLoader__ = {
-    mode: 'queue',
-    handoffs,
-    load(handoff) { handoffs.push(handoff) },
-  }
-  // Mirror the blocking Host-injected scripts: the kernel claims modules,
-  // then the resulting module system adopts runtime from the same queue.
+  const html = injectBootManifest('<head></head>', win.__DSH_BOOT__)
+  const facadeSource = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
+  if (facadeSource === undefined) throw new Error('missing injected ModuleLoader facade')
+  ;(0, eval)(facadeSource)
+  // Mirror the blocking Host-injected scripts before the Vite entry calls create().
   for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
     const plugin = PLUGINS.find(candidate => candidate.id === id)
     if (plugin === undefined) throw new Error(`missing parser-preloaded fixture row ${id}`)

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: f9f1dcf5b7928bdf18e581826a85b47e7c59bf05
-README.zh.md: eb6aaf194592c8fb232cc795aae6de82f4743d00
+README.md: eaf64599bc5fc5d9663d00c3b341764a4ccd38c8
+README.zh.md: ea051338bb7837cb49f7a5ecd4cdad5b6b3ad71d

+ 3 - 1
packages/client/modules/README.md

@@ -6,7 +6,9 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
 
 Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively; graph composition places declared dynamic requests before their consumers, and require cycles throw because factory-form CJS cannot deliver partial exports. `<id>/client` and the bare id resolve to the same exports (a plugin bundle IS its package's client half).
 
-Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; bootstrap static registry (`registerStatic`, used for modules itself) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
+The Host installs `window.__ModuleLoader__` before parser preloads run. Its queue-mode `load()` retains early registrations; `create()` materializes this package's factory with an external-rejecting bootstrap require and calls its `createClientModuleSystem` export. Construction caches those same exports as the modules row, switches the same facade to live registration, and drains the remaining queue. The bundle retains the resulting system in a module closure, so its later Cordis `apply()` provides the identical instance as `ctx.modules` without another page global.
+
+Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its classic-script factory; registered factory → materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous graph-row load and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops a non-bootstrap factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
 
 The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, carries package-specific `dsh.client.external` requests, orders dynamic providers before consumers, and serves each bundle with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
 

+ 3 - 1
packages/client/modules/README.zh.md

@@ -6,7 +6,9 @@
 
 惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它;图组合会把声明的动态请求提供方放在消费者之前,而 require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
 
-解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;自举静态注册表(`registerStatic`,仅 modules 自身使用)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载 classic script + 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
+Host 会在 parser preload 运行前安装 `window.__ModuleLoader__`。其 queue 模式的 `load()` 保存提前到达的 registration;`create()` 使用拒绝 external 的 bootstrap require 物化本包 factory,并调用其 `createClientModuleSystem` 导出。构造过程把同一组导出缓存为 modules row,把同一个 facade 切换到 live registration,再排空余下 queue。Bundle 通过模块闭包保留生成的系统,因此随后 Cordis `apply()` 能把同一实例提供为 `ctx.modules`,无需另一个页面全局变量。
+
+解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其 classic-script factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并登记 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃非 bootstrap factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
 
 Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的 bundle 哈希和包专属 `dsh.client.external` 请求写入启动图,把动态提供方排在消费者之前,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
 

+ 41 - 15
packages/client/modules/src/client/index.ts

@@ -3,34 +3,60 @@
  * wire contract, plus the enrollment plugin face. The module system itself is
  * built by the shell kernel BEFORE cordis exists (the bootstrap exception —
  * the mechanism that loads plugins cannot arrive through itself). The host
- * parser-preloads this ordinary client bundle into the handoff queue; the
- * kernel claims and materializes that handoff, constructs the system, and
- * registers the same exports for this package's graph row. The plugin face
- * only enrolls that pre-existing instance by providing it as `ctx.modules`.
+ * parser-preloads this ordinary client bundle into the pending registration
+ * queue. The HTML-installed loader facade materializes this bundle and calls
+ * its bootstrap export, which constructs the system and retains the same
+ * exports for this package's graph row. The plugin face only enrolls that
+ * pre-existing instance by providing it as `ctx.modules`.
  * @module @deepseek-ai/dsh-client-modules/client
  */
 import type { Context } from '@deepseek-ai/cordis'
-import type { DshWindow } from './manifest.ts'
+import { ClientModuleSystem } from './system.ts'
+import { parseBootManifest } from './manifest.ts'
+import type {
+  ClientBootstrapModule, ClientModuleCreateOptions, ClientModuleLoaderTarget,
+} from './manifest.ts'
 
-export { ClientModuleSystem } from './system.ts'
+export { ClientModuleSystem }
 export { parseBootManifest, stripClientSuffix } from './manifest.ts'
 export type {
-  BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
-  ClientModuleHandoffQueue, ClientModuleHandoffSink, ClientModuleHandoffTarget,
-  ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
+  BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,
+  ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord,
+  ClientModuleSystemOptions, DshWindow,
   WebBootEntry, WebBootGraph,
 } from './manifest.ts'
 
+let moduleSystem: ClientModuleSystem | undefined
+
+/**
+ * Build the live module system from the HTML facade's materialized modules bundle.
+ * @param target - Stable registration facade whose pending queue becomes the live sink.
+ * @param bootstrapModule - This bundle's id and already-materialized exports.
+ * @param options - Raw boot graph, platform seed, and optional bundle transport.
+ * @returns The created module system, also published for this package's Cordis plugin face.
+ */
+export function createClientModuleSystem(
+  target: ClientModuleLoaderTarget,
+  bootstrapModule: ClientBootstrapModule,
+  options: ClientModuleCreateOptions,
+): ClientModuleSystem {
+  moduleSystem = new ClientModuleSystem({
+    manifest: parseBootManifest(options.boot),
+    staticModules: options.staticModules,
+    registrationTarget: target,
+    bootstrapModule,
+    ...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }),
+  })
+  return moduleSystem
+}
+
 /**
  * Enroll the kernel-built module system as `ctx.modules`.
  * @param ctx - client root context.
  */
 export function apply(ctx: Context): void {
-  const modules = (globalThis as DshWindow).__DSH_MODULES__
-  // The kernel writes the slot right after constructing the instance, before
-  // any cordis entry exists — a missing slot means the kernel sequencing broke.
-  if (modules === undefined) {
-    throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot')
+  if (moduleSystem === undefined) {
+    throw new Error('client-modules: createClientModuleSystem must run before plugin boot')
   }
-  ctx.reflect.provide('modules', modules)
+  ctx.reflect.provide('modules', moduleSystem)
 }

+ 49 - 50
packages/client/modules/src/client/manifest.ts

@@ -16,10 +16,9 @@
  * so load order needs no external sequencing.
  *
  * Resolution branch order (import): seed word → shell instance; memoized
- * record → exports; static registry (pre-materialized bootstrap modules) →
- * module; registered factory → materialize; graph row → load + materialize;
- * anything else → throw (loud — the runtime mirror of the build-time bundle
- * purity gate).
+ * record → exports; graph row → register its dependency factories and own
+ * factory; registered factory → materialize; anything else → throw (loud —
+ * the runtime mirror of the build-time bundle purity gate).
  * The synchronous `require` handed to factories walks the same order minus
  * the load branch. Loading is async, so a requested dynamic package must have
  * registered its factory before a consumer materializes.
@@ -188,8 +187,8 @@ export function parseBootManifest(wire: unknown): BootManifest {
   return { rev: graph.rev, modules, plugins }
 }
 
-/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff). */
-export interface ClientPluginHandoff {
+/** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */
+export interface ClientBundleRegistration {
   /** Plugin id (package name) — the registration key; must match the graph row being executed. */
   id: string
   /**
@@ -200,43 +199,42 @@ export interface ClientPluginHandoff {
   factory: (require: (spec: string) => unknown) => Record<string, unknown>
 }
 
-/** Inline HTML queue installed before a preloaded client bundle executes. */
-export interface ClientModuleHandoffQueue {
-  /** Discriminant that lets the module system distinguish the bootstrap queue from a live sink. */
-  mode: 'queue'
-  /**
-   * Handoffs received before {@link ClientModuleSystem} exists; the kernel
-   * claims modules before the rest drain.
-   */
-  handoffs: ClientPluginHandoff[]
-  /** Append one preloaded bundle handoff for later adoption. */
-  load(handoff: ClientPluginHandoff): void
+/** Inputs passed by the web entry when it creates the client module system. */
+export interface ClientModuleCreateOptions {
+  /** Raw Host-injected boot graph; the modules bundle owns validation and projection. */
+  boot: unknown
+  /** Module-table seed: platform-singleton specifier → shell instance. */
+  staticModules: Record<string, unknown>
+  /** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
+  loadBundle?: (url: string) => Promise<void>
 }
 
-/** Live registration sink installed by {@link ClientModuleSystem}. */
-export interface ClientModuleHandoffSink {
-  /** Discriminant used to reject a second module-system boot. */
-  mode: 'live'
-  /** Register one bundle factory immediately. */
-  load(handoff: ClientPluginHandoff): void
+/** The modules bundle after its factory has been materialized by the HTML bootstrap facade. */
+export interface ClientBootstrapModule {
+  /** Graph/module id carried by the modules bundle registration. */
+  id: string
+  /** Materialized exports reused when Cordis later activates the modules entry. */
+  exports: Record<string, unknown>
 }
 
-/** Bootstrap queue before module-system construction, then the live registration sink. */
-export type ClientModuleHandoffTarget = ClientModuleHandoffQueue | ClientModuleHandoffSink
+/** Stable page-global facade: queues early bundle registrations, then registers them live. */
+export interface ClientModuleLoaderTarget {
+  /** Queue before {@link create}; live registration after it returns. */
+  mode: 'queue' | 'live'
+  /** Registrations submitted by parser-preloaded scripts before the module system exists. */
+  pendingQueue: ClientBundleRegistration[]
+  /** Queue or immediately register one bundle factory according to {@link mode}. */
+  load(registration: ClientBundleRegistration): void
+  /** Create the module system exactly once from the parser-preloaded modules bundle. */
+  create(options: ClientModuleCreateOptions): ClientModuleSystem
+}
 
-/** Window API of the web boot protocol: the host-injected graph, registration sink, and kernel handoff slot. */
+/** Window API of the web boot protocol: the host-injected graph and registration facade. */
 export interface DshWindow {
   /** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
   __DSH_BOOT__?: unknown
-  /** Bundle handoff target: an HTML bootstrap queue, then the live module-system sink. */
-  __ModuleLoader__?: ClientModuleHandoffTarget
-  /**
-   * Kernel handoff slot: the shell kernel stores the instance here right
-   * after construction (before cordis exists) so the `./client` wrapper
-   * plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
-   * time = kernel sequencing bug, thrown loud.
-   */
-  __DSH_MODULES__?: ClientModuleSystem
+  /** HTML-installed facade: a pending registration queue, then the live module-system target. */
+  __ModuleLoader__?: ClientModuleLoaderTarget
 }
 
 /** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph boundary, flat today). */
@@ -259,6 +257,8 @@ export interface ClientModuleRecord {
 export interface ClientModuleLoader {
   /** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
   version: 'client'
+  /** Parsed Host boot graph shared with the web entry after module-system creation. */
+  manifest: BootManifest
   /** Materialized-module registry: id → record. The governance-side read API for entry exports. */
   loadCache: Map<string, ClientModuleRecord>
   /**
@@ -271,37 +271,36 @@ export interface ClientModuleLoader {
    * @returns the module's exports.
    */
   import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
-  /**
-   * Register an already-materialized bootstrap module whose handoff was
-   * removed from the HTML queue before this system was constructed.
-   * @param id - graph entry name.
-   * @param module - the materialized module exports.
-   */
-  registerStatic(id: string, module: unknown): void
   /**
    * Stage-one arrival: load the entry's declared dynamic requests, then its
    * own script, to register their factories (no materialization — module side
    * effects wait for import).
-   * No-op for bootstrap-registered ids and ids whose factory is already
-   * registered; concurrent calls share one in-flight task. To force a fresh
-   * load (HMR), {@link invalidate} first.
+   * No-op for materialized bootstrap ids. A registered graph row still
+   * registers any unresolved declared requests before skipping its own script;
+   * concurrent arrivals share one in-flight task. To force a fresh load (HMR),
+   * {@link invalidate} first.
    * @param id - graph entry name.
    */
   prefetch(id: string): Promise<void>
   /**
-   * Full reset of one module: drop its registered factory and materialized
-   * record so the next prefetch/import reloads it (the HMR invalidation hook).
+   * Full reset of one non-bootstrap module: drop its registered factory and
+   * materialized record so the next prefetch/import reloads it (the HMR
+   * invalidation hook). The bootstrap module remains materialized.
    * @param id - entry name to invalidate.
    */
   invalidate(id: string): void
 }
 
-/** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
+/** Internal construction inputs assembled by the modules bundle's bootstrap export. */
 export interface ClientModuleSystemOptions {
-  /** Boot rows in the module-table view (from {@link parseBootManifest}). */
-  modules: BootModuleRow[]
+  /** Parsed boot graph owned by the resulting module system. */
+  manifest: BootManifest
   /** Module-table seed: platform-singleton specifier → shell instance. */
   staticModules: Record<string, unknown>
+  /** Stable HTML-installed registration facade to switch from queue to live mode. */
+  registrationTarget: ClientModuleLoaderTarget
+  /** Already-materialized modules bundle consumed while creating the system. */
+  bootstrapModule: ClientBootstrapModule
   /** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
   loadBundle?: (url: string) => Promise<void>
 }

+ 54 - 59
packages/client/modules/src/client/system.ts

@@ -6,8 +6,8 @@
  */
 import { stripClientSuffix } from './manifest.ts'
 import type {
-  BootModuleRow, ClientModuleLoader, ClientModuleRecord,
-  ClientModuleHandoffSink, ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
+  BootManifest, BootModuleRow, ClientBundleRegistration, ClientModuleLoader, ClientModuleRecord,
+  ClientModuleSystemOptions,
 } from './manifest.ts'
 
 /** Default bundle-load hook: same-origin external classic script. */
@@ -46,16 +46,18 @@ const claimStyles = (id: string): string[] => {
 /**
  * The client module system: state tables plus the arrival/materialization
  * machinery implementing {@link ClientModuleLoader} (whose members carry the
- * contract documentation). Construction indexes the boot rows and installs the
- * `window.__ModuleLoader__` registration sink — once per page.
+ * contract documentation). Construction indexes the boot rows, retains the
+ * already-materialized bootstrap module, and switches the HTML-installed
+ * loader facade from its pending queue to live registration.
  */
 export class ClientModuleSystem implements ClientModuleLoader {
   readonly version = 'client'
+  readonly manifest: BootManifest
   readonly loadCache = new Map<string, ClientModuleRecord>()
 
   private readonly seed: Map<string, unknown>
-  private readonly statics = new Map<string, unknown>()
-  private readonly factories = new Map<string, ClientPluginHandoff['factory']>()
+  private readonly factories = new Map<string, ClientBundleRegistration['factory']>()
+  private readonly bootstrapIds = new Set<string>()
   /** In-flight prefetch (script load) per id; concurrent callers share it. */
   private readonly pendingArrival = new Map<string, Promise<void>>()
   /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
@@ -65,38 +67,46 @@ export class ClientModuleSystem implements ClientModuleLoader {
 
   /**
    * Build the module system over the parsed boot rows.
-   * @param options - Module rows, module-table staticModules, and bundle-load hook.
+   * @param options - Parsed graph, platform seed, bootstrap module, registration facade, and transport.
    */
   constructor(options: ClientModuleSystemOptions) {
+    this.manifest = options.manifest
     this.seed = new Map(Object.entries(options.staticModules))
     this.loadBundle = options.loadBundle ?? defaultLoadBundle
 
-    for (const row of options.modules) {
+    for (const row of options.manifest.modules) {
       if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
       this.graphRows.set(row.id, row)
     }
 
-    const win = globalThis as DshWindow
-    const queued = win.__ModuleLoader__
-    if (queued !== undefined && queued.mode !== 'queue') {
-      throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
+    const bootstrapId = stripClientSuffix(options.bootstrapModule.id)
+    this.bootstrapIds.add(bootstrapId)
+    this.loadCache.set(bootstrapId, {
+      id: bootstrapId,
+      exports: options.bootstrapModule.exports,
+      styles: [],
+      edges: new Set(),
+    })
+
+    const target = options.registrationTarget
+    if (target.mode !== 'queue') {
+      throw new Error('client-modules: window.__ModuleLoader__.create called after module-system boot')
     }
-    const sink: ClientModuleHandoffSink = {
-      mode: 'live',
-      load: (handoff) => { this.register(handoff) },
-    }
-    // Replace first: a bundle that executes while queued handoffs are draining
-    // must register against the live sink rather than append behind the drain.
-    win.__ModuleLoader__ = sink
-    for (const handoff of queued?.handoffs.splice(0) ?? []) sink.load(handoff)
+    const pending = target.pendingQueue.splice(0)
+    // Switch first: a bundle that executes while pending registrations drain
+    // must register live rather than append behind the drain.
+    target.mode = 'live'
+    target.load = (registration) => { this.register(registration) }
+    for (const registration of pending) target.load(registration)
   }
 
   /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
-  private register(handoff: ClientPluginHandoff): void {
-    if (this.factories.has(handoff.id)) {
-      throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
+  private register(registration: ClientBundleRegistration): void {
+    const id = stripClientSuffix(registration.id)
+    if (this.bootstrapIds.has(id) || this.factories.has(id)) {
+      throw new Error(`client-modules: duplicate factory registration for "${registration.id}" (bundle executed twice without invalidate?)`)
     }
-    this.factories.set(handoff.id, handoff.factory)
+    this.factories.set(id, registration.factory)
   }
 
   /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
@@ -104,7 +114,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
     const { id, url } = row
     const pending = this.pendingArrival.get(id)
     if (pending !== undefined) return pending
-    if (this.factories.has(id)) return Promise.resolve()
+    if (this.loadCache.has(id) || this.factories.has(id)) return Promise.resolve()
     const task = this.loadBundle(url).then(() => {
       if (!this.factories.has(id)) {
         throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
@@ -125,8 +135,9 @@ export class ClientModuleSystem implements ClientModuleLoader {
     }
     const next = [...open, row.id]
     for (const request of row.external) {
-      if (this.seed.has(request) || this.bootstrapModuleKey(request) !== undefined) continue
-      const dependency = this.graphRows.get(stripClientSuffix(request))
+      const id = stripClientSuffix(request)
+      if (this.seed.has(request) || this.loadCache.has(id)) continue
+      const dependency = this.graphRows.get(id)
       if (dependency !== undefined) await this.arriveGraphRow(dependency, next)
     }
     await this.arrive(row)
@@ -155,8 +166,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
   }
 
   /**
-   * The synchronous require answered to factories: seed → static → memoized
-   * record → registered factory. Fetching is async and therefore unreachable
+   * The synchronous require answered to factories: seed → memoized record →
+   * registered factory. Fetching is async and therefore unreachable
    * from here; an external dynamic package must have arrived before its
    * consumer materializes.
    */
@@ -164,14 +175,12 @@ export class ClientModuleSystem implements ClientModuleLoader {
     return (spec: string): unknown => {
       edges.add(spec)
       if (this.seed.has(spec)) return this.seed.get(spec)
-      const bootstrapKey = this.bootstrapModuleKey(spec)
-      if (bootstrapKey !== undefined) return this.statics.get(bootstrapKey)
       const id = stripClientSuffix(spec)
       const record = this.loadCache.get(id)
       if (record !== undefined) return record.exports
       if (this.factories.has(id)) return this.materialize(id).exports
       throw new Error(
-        `client-modules: require("${spec}") missed the module table — not a platform seed word, not a bootstrap module, `
+        `client-modules: require("${spec}") missed the module table — not a platform seed word, not a materialized module, `
         + 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
       )
     }
@@ -179,47 +188,33 @@ export class ClientModuleSystem implements ClientModuleLoader {
 
   async import(specifier: string): Promise<unknown> {
     if (this.seed.has(specifier)) return this.seed.get(specifier)
-    const existing = this.loadCache.get(specifier)
+    const id = stripClientSuffix(specifier)
+    const existing = this.loadCache.get(id)
     if (existing !== undefined) return existing.exports
-    const bootstrapKey = this.bootstrapModuleKey(specifier)
-    if (bootstrapKey !== undefined) {
-      const exports = this.statics.get(bootstrapKey)
-      this.loadCache.set(specifier, { id: specifier, exports, styles: [], edges: new Set() })
-      return exports
-    }
-    const row = this.graphRows.get(specifier)
+    const row = this.graphRows.get(id)
     if (row !== undefined) {
       await this.arriveGraphRow(row)
-    } else if (!this.factories.has(specifier)) {
+    } else if (!this.factories.has(id)) {
       throw new Error(
-        `client-modules: cannot resolve "${specifier}" — not a seed word, not a bootstrap module, `
+        `client-modules: cannot resolve "${specifier}" — not a seed word, not a materialized module, `
         + 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
       )
     }
-    return this.materialize(specifier).exports
-  }
-
-  registerStatic(id: string, module: unknown): void {
-    if (this.statics.has(id)) throw new Error(`client-modules: bootstrap module "${id}" registered twice`)
-    this.statics.set(id, module)
+    return this.materialize(id).exports
   }
 
   async prefetch(id: string): Promise<void> {
-    if (this.bootstrapModuleKey(id) !== undefined) return
-    const row = this.graphRows.get(id)
+    const normalized = stripClientSuffix(id)
+    if (this.loadCache.has(normalized)) return
+    const row = this.graphRows.get(normalized)
     if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
     await this.arriveGraphRow(row)
   }
 
   invalidate(id: string): void {
-    this.factories.delete(id)
-    this.loadCache.delete(id)
-  }
-
-  /** Resolve a bootstrap package name or its client entrypoint onto the registered package row. */
-  private bootstrapModuleKey(specifier: string): string | undefined {
-    if (this.statics.has(specifier)) return specifier
-    const id = stripClientSuffix(specifier)
-    return id !== specifier && this.statics.has(id) ? id : undefined
+    const normalized = stripClientSuffix(id)
+    if (this.bootstrapIds.has(normalized)) return
+    this.factories.delete(normalized)
+    this.loadCache.delete(normalized)
   }
 }

+ 29 - 6
packages/client/modules/src/index.ts

@@ -238,19 +238,42 @@ function escapeHtmlAttribute(value: string): string {
 }
 
 /**
- * Inject the boot protocol into index.html. The inline handoff queue precedes
+ * Inject the boot protocol into index.html. The inline registration queue precedes
  * blocking classic scripts for modules' and runtime's ordinary
- * `lib/client.js` artifacts. The shell claims the modules handoff to construct
- * the module system, which then adopts the remaining queued registrations.
- * The graph script follows before the shell reads it. `<` is escaped in JSON
- * so a plugin-controlled string cannot break out of the script element.
+ * `lib/client.js` artifacts. Its `create()` method materializes the modules
+ * bundle, delegates construction to that bundle, and leaves the same facade
+ * in live-registration mode. The graph script follows before the shell reads
+ * it. `<` is escaped in JSON so a plugin-controlled string cannot break out
+ * of the script element.
  * @param html - the index.html source.
  * @param graph - the composed entry graph.
  * @returns the html with the graph script injected.
  */
 export function injectBootManifest(html: string, graph: WebBootGraph): string {
   const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
-  const queue = '<script>(()=>{const handoffs=[];window.__ModuleLoader__={mode:"queue",handoffs,load(handoff){handoffs.push(handoff)}}})()</script>'
+  const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
+  const queue = `<script>(()=>{
+const pendingQueue=[]
+window.__ModuleLoader__={
+  mode:"queue",
+  pendingQueue,
+  load(registration){pendingQueue.push(registration)},
+  create(options){
+    if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot")
+    const index=pendingQueue.findIndex(registration=>registration.id===${bootstrapId})
+    const registration=pendingQueue[index]
+    if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js")
+    pendingQueue.splice(index,1)
+    const exports=registration.factory(specifier=>{
+      throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed')
+    })
+    if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){
+      throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face")
+    }
+    return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
+  }
+}
+})()</script>`
   const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
     .filter((entry): entry is WebBootEntry => entry !== undefined)
     .map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)

+ 87 - 60
packages/client/modules/tests/loader.client.spec.ts

@@ -7,15 +7,19 @@
  * default transport hook, and the loud failure modes (duplicate
  * registration, cycles, table misses, double boot).
  */
+import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
-  ClientModuleSystem, parseBootManifest,
-  type BootModuleRow, type ClientModuleLoader, type ClientPluginHandoff, type DshWindow,
+  apply, createClientModuleSystem, parseBootManifest,
+  type BootModuleRow, type ClientBundleRegistration, type ClientModuleCreateOptions,
+  type ClientModuleLoader, type ClientModuleLoaderTarget, type DshWindow,
 } from '../src/client/index.ts'
 
+const MODULES_ID = '@deepseek-ai/dsh-client-modules'
 const win = globalThis as DshWindow
+const bootstrapExports = { apply, createClientModuleSystem }
 
-type Factory = ClientPluginHandoff['factory']
+type Factory = ClientBundleRegistration['factory']
 
 afterEach(() => {
   vi.unstubAllGlobals()
@@ -28,10 +32,26 @@ const row = (id: string, fields: Partial<BootModuleRow> = {}): BootModuleRow =>
 
 interface Bench {
   loader: ClientModuleLoader
+  target: ClientModuleLoaderTarget
   fetched: string[]
   gates: Map<string, () => void>
 }
 
+/** Build the page-global facade shape consumed by the module system. */
+function registrationTarget(pending: ClientBundleRegistration[] = []): ClientModuleLoaderTarget {
+  const pendingQueue = [...pending]
+  const target: ClientModuleLoaderTarget = {
+    mode: 'queue',
+    pendingQueue,
+    load: (registration) => { pendingQueue.push(registration) },
+    create: options => createClientModuleSystem(target, {
+      id: MODULES_ID,
+      exports: bootstrapExports,
+    }, options),
+  }
+  return target
+}
+
 /**
  * Loader over scripted bundles: load records the row URL, optionally waits on
  * a release callback, then registers the scripted factory through the window
@@ -40,41 +60,52 @@ interface Bench {
 function bench(
   entries: BootModuleRow[],
   bundles: Record<string, Factory | null> = {},
-  opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
+  opts: {
+    seed?: Record<string, unknown>
+    gated?: string[]
+    pending?: ClientBundleRegistration[]
+    defaultTransport?: boolean
+  } = {},
 ): Bench {
   const fetched: string[] = []
   const gates = new Map<string, () => void>()
-  const loader = new ClientModuleSystem({
-    modules: entries,
+  const target = registrationTarget(opts.pending)
+  win.__ModuleLoader__ = target
+  const loadBundle = async (url: string): Promise<void> => {
+    fetched.push(url)
+    if (opts.gated?.includes(url) === true) {
+      await new Promise<void>((resolve) => { gates.set(url, resolve) })
+    }
+    const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
+    const factory = id === undefined ? undefined : bundles[id]
+    if (factory == null || id === undefined) return
+    win.__ModuleLoader__?.load({ id, factory })
+  }
+  const loader = target.create({
+    boot: { rev: 'graph', entries },
     staticModules: opts.seed ?? {},
-    loadBundle: async (url) => {
-      fetched.push(url)
-      if (opts.gated?.includes(url) === true) {
-        await new Promise<void>((resolve) => { gates.set(url, resolve) })
-      }
-      const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
-      const factory = id === undefined ? undefined : bundles[id]
-      if (factory == null || id === undefined) return
-      win.__ModuleLoader__?.load({ id, factory })
-    },
+    ...(opts.defaultTransport === true ? {} : { loadBundle }),
   })
-  return { loader, fetched, gates }
+  return { loader, target, fetched, gates }
 }
 
+describe('Cordis plugin face', () => {
+  it('rejects activation before the HTML facade creates the module system', () => {
+    expect(() => apply(new Context())).toThrow('createClientModuleSystem must run before plugin boot')
+  })
+})
+
 describe('lazy CJS arrival', () => {
-  it('adopts handoffs queued by parser-blocking preload scripts', async () => {
-    const handoffs: ClientPluginHandoff[] = [{ id: 'runtime', factory: () => ({ marker: 'preloaded' }) }]
-    win.__ModuleLoader__ = {
-      mode: 'queue',
-      handoffs,
-      load: (handoff) => { handoffs.push(handoff) },
-    }
-    const b = bench([row('runtime')])
+  it('drains registrations queued by parser-blocking preload scripts into the same live facade', async () => {
+    const b = bench([row('runtime')], {}, {
+      pending: [{ id: 'runtime', factory: () => ({ marker: 'preloaded' }) }],
+    })
     const exports = await b.loader.import('runtime', '', {})
     expect((exports as { marker: string }).marker).toBe('preloaded')
-    expect(handoffs).toEqual([])
+    expect(b.target.pendingQueue).toEqual([])
     expect(b.fetched).toEqual([])
-    expect(win.__ModuleLoader__?.mode).toBe('live')
+    expect(win.__ModuleLoader__).toBe(b.target)
+    expect(b.target.mode).toBe('live')
   })
 
   it('prefetch loads and registers but does not run the factory', async () => {
@@ -83,7 +114,7 @@ describe('lazy CJS arrival', () => {
     await b.loader.prefetch('a')
     expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
     expect(ran).toEqual([])
-    expect(b.loader.loadCache.size).toBe(0)
+    expect(b.loader.loadCache.has('a')).toBe(false)
   })
 
   it('import materializes once and memoizes the exports', async () => {
@@ -204,39 +235,32 @@ describe('require resolution', () => {
   })
 })
 
-describe('static registry', () => {
-  it('serves shell-own modules to import and require without any fetch', async () => {
-    const shell = { marker: 'app-shell' }
-    const b = bench([row('a')], {
-      a: req => ({ dep: req('app-shell') }),
-    })
-    b.loader.registerStatic('app-shell', shell)
-    await b.loader.prefetch('app-shell')
-    expect(await b.loader.import('app-shell', '', {})).toBe(shell)
-    expect(b.loader.loadCache.get('app-shell')?.styles).toEqual([])
-    expect((await b.loader.import('a', '', {}) as { dep: unknown }).dep).toBe(shell)
-    expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
-  })
-
-  it('satisfies a graph request from a bootstrap package without reloading its row', async () => {
-    const shell = { marker: 'app-shell' }
+describe('bootstrap module', () => {
+  it('caches the materialized modules exports under the package id and /client alias', async () => {
     const b = bench([
-      row('consumer', { external: ['app-shell/client'] }),
-      row('app-shell'),
+      row('consumer', { external: [`${MODULES_ID}/client`] }),
+      row(MODULES_ID),
     ], {
-      consumer: req => ({ dep: req('app-shell/client') }),
+      consumer: req => ({ dep: req(`${MODULES_ID}/client`) }),
     })
-    b.loader.registerStatic('app-shell', shell)
+    await b.loader.prefetch(MODULES_ID)
     const exports = await b.loader.import('consumer', '', {}) as { dep: unknown }
-    expect(exports.dep).toBe(shell)
-    expect(await b.loader.import('app-shell/client', '', {})).toBe(shell)
+    expect(exports.dep).toBe(bootstrapExports)
+    expect(await b.loader.import(`${MODULES_ID}/client`, '', {})).toBe(bootstrapExports)
     expect(b.fetched).toEqual(['/plugins/consumer/client.js?rev=0'])
   })
 
-  it('duplicate static registration is loud', () => {
+  it('publishes the same closed-over system when the modules Cordis plugin activates', () => {
     const b = bench([])
-    b.loader.registerStatic('app-shell', {})
-    expect(() => { b.loader.registerStatic('app-shell', {}) }).toThrow('registered twice')
+    const ctx = new Context()
+    apply(ctx)
+    expect(ctx.modules).toBe(b.loader)
+  })
+
+  it('rejects a second queued registration for the bootstrap id', () => {
+    expect(() => bench([], {}, {
+      pending: [{ id: `${MODULES_ID}/client`, factory: () => ({}) }],
+    })).toThrow(`duplicate factory registration for "${MODULES_ID}/client"`)
   })
 })
 
@@ -276,9 +300,12 @@ describe('failure modes', () => {
   })
 
   it('double boot is loud', () => {
-    bench([])
-    expect(() => new ClientModuleSystem({ modules: [], staticModules: {} }))
-      .toThrow('already installed (double boot?)')
+    const b = bench([])
+    const options: ClientModuleCreateOptions = {
+      boot: { rev: 'graph', entries: [] },
+      staticModules: {},
+    }
+    expect(() => b.target.create(options)).toThrow('create called after module-system boot')
   })
 })
 
@@ -365,8 +392,8 @@ describe('default transport seam', () => {
         script.dispatchEvent(new Event('load'))
       })
     })
-    const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
-    const exports = await loader.import('dee', '', {})
+    const b = bench([row('dee')], {}, { defaultTransport: true })
+    const exports = await b.loader.import('dee', '', {})
     expect((exports as { marker: string }).marker).toBe('via-script')
     expect(append).toHaveBeenCalledOnce()
     expect([...document.querySelectorAll('script')]).toEqual([])
@@ -378,8 +405,8 @@ describe('default transport seam', () => {
       if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
       queueMicrotask(() => { script.dispatchEvent(new Event('error')) })
     })
-    const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
-    await expect(loader.prefetch('dee')).rejects.toThrow(
+    const b = bench([row('dee')], {}, { defaultTransport: true })
+    await expect(b.loader.prefetch('dee')).rejects.toThrow(
       'bundle script /plugins/dee/client.js?rev=0 failed to load',
     )
     expect([...document.querySelectorAll('script')]).toEqual([])

+ 86 - 2
packages/client/modules/tests/node-half.client.spec.ts

@@ -5,11 +5,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { pathToFileURL } from 'node:url'
+import { runInNewContext } from 'node:vm'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
-import { ClientModuleRegistry, orderByModuleGraph } from '../src/index.ts'
-import type { WebBootEntry } from '../src/index.ts'
+import * as modulesClient from '../src/client/index.ts'
+import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts'
+import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
+
+const MODULES_ID = '@deepseek-ai/dsh-client-modules'
+const RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
 
 let root: string | undefined
 
@@ -76,6 +81,85 @@ function construct(packageNames: string[]): ClientModuleRegistry {
   return constructWithRoute(packageNames).service
 }
 
+/** Execute the exact first inline script emitted by the Host HTML transform. */
+function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
+  const html = injectBootManifest('<html><head></head><body><script type="module" src="/index.js"></script></body></html>', graph)
+  const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
+  if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
+  const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
+  runInNewContext(source, { window })
+  if (window.__ModuleLoader__ === undefined) throw new Error('facade script did not install __ModuleLoader__')
+  return { html, target: window.__ModuleLoader__ }
+}
+
+const bootGraph = (): WebBootGraph => ({
+  rev: 'graph',
+  entries: [
+    { id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' },
+    { id: RUNTIME_ID, url: '/plugins/runtime.js?rev=r', rev: 'r' },
+  ],
+})
+
+describe('HTML bootstrap facade', () => {
+  it('precedes blocking preloads and the boot graph, then becomes the live registration target', async () => {
+    const graph = bootGraph()
+    const { html, target } = injectedFacade(graph)
+    const facadeAt = html.indexOf('window.__ModuleLoader__=')
+    const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
+    const runtimeAt = html.indexOf('<script src="/plugins/runtime.js?rev=r"></script>')
+    const graphAt = html.indexOf('window.__DSH_BOOT__ = ')
+    const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
+    expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([
+      facadeAt, modulesAt, runtimeAt, graphAt, entryAt,
+    ])].sort((a, b) => a - b))
+
+    target.load({ id: MODULES_ID, factory: () => modulesClient })
+    target.load({ id: RUNTIME_ID, factory: () => ({ marker: 'runtime' }) })
+    const system = target.create({ boot: graph, staticModules: {} })
+
+    expect(target.mode).toBe('live')
+    expect(target.pendingQueue).toEqual([])
+    expect(system.manifest.rev).toBe('graph')
+    expect(await system.import(MODULES_ID)).toBe(modulesClient)
+    expect(await system.import(`${RUNTIME_ID}/client`)).toEqual({ marker: 'runtime' })
+    expect(() => target.create({ boot: graph, staticModules: {} }))
+      .toThrow('create called after module-system boot')
+  })
+
+  it('rejects a page that did not preload the modules bundle', () => {
+    const graph = bootGraph()
+    const { target } = injectedFacade(graph)
+    expect(() => target.create({ boot: graph, staticModules: {} }))
+      .toThrow(`HTML did not preload ${MODULES_ID}/client.js`)
+  })
+
+  it('rejects a bootstrap bundle with a runtime external', () => {
+    const graph = bootGraph()
+    const { target } = injectedFacade(graph)
+    target.load({
+      id: MODULES_ID,
+      factory: (require) => {
+        require('react')
+        return modulesClient
+      },
+    })
+    expect(() => target.create({ boot: graph, staticModules: {} }))
+      .toThrow(`${MODULES_ID}/client.js requested external "react"`)
+  })
+
+  it.each([
+    null,
+    { ...modulesClient, createClientModuleSystem: undefined },
+    { ...modulesClient, apply: undefined },
+  ])('rejects a bootstrap bundle without the complete module face', (exports) => {
+    const graph = bootGraph()
+    const { target } = injectedFacade(graph)
+    target.load({ id: MODULES_ID, factory: () => exports as unknown as Record<string, unknown> })
+    expect(() => target.create({ boot: graph, staticModules: {} }))
+      .toThrow(`${MODULES_ID}/client.js did not export the bootstrap module face`)
+  })
+})
+
 describe('client bundle activation', () => {
   it('allows sibling dsh roles', () => {
     const currentName = '@fixture/current-client-field'

+ 2 - 2
packages/client/web/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/web/README.md
-README.md: 705c04b8499465a44ed76fc9acaa2648b1a09362
-README.zh.md: 141236df4d6a606b1d7d62e3160bfa2392548829
+README.md: c7b77d3eba8cf56017c816362e9ffc4fcb3be403
+README.zh.md: 2a52bcdfe7395466866b80160f0646cf3b7fe111

+ 2 - 2
packages/client/web/README.md

@@ -2,9 +2,9 @@
 
 English | [中文](README.zh.md)
 
-Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage claims the parser-preloaded `@deepseek-ai/dsh-client-modules` factory, builds its module system over the host-provided `window.__DSH_BOOT__` graph, adopts the queued runtime factory, and prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The host graph owns the roster, parser preloads, and prefetch marks; this package adds only the adopted modules bootstrap entry.
+Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage calls the Host-installed `window.__ModuleLoader__.create()` with `window.__DSH_BOOT__`, the shell's static modules, and any test transport override; the facade returns the constructed module system and parsed manifest after adopting parser-preloaded registrations. This package then prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry uniformly, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The Host owns the graph, parser preloads, and facade; AppWebEntry does not know the bootstrap package id or parse the wire format.
 
-The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). Modules is the only package registered through `registerStatic`: its ordinary `lib/client.js` remains the graph row's artifact, but the kernel must claim and materialize that factory before the module system can load anything.
+The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). The modules bundle caches its own materialized exports and provides the closed-over system when its ordinary graph entry activates; Cordis service waiting makes graph-row creation order independent from that activation.
 
 `PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shell-seeded shared modules. Together with `PRELOADED_CLIENT_EXTERNALS`, it defines the implicit external baseline for every dynamic bundle; `dsh.client.external` adds only exact non-baseline requests.
 

+ 2 - 2
packages/client/web/README.zh.md

@@ -2,9 +2,9 @@
 
 [English](README.md) | 中文
 
-Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段认领 HTML parser 预载的 `@deepseek-ai/dsh-client-modules` factory,基于宿主提供的 `window.__DSH_BOOT__` 图构建模块系统,接纳 queue 中的 runtime factory,并预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。名册、parser 预载项和预取标记归宿主图所有;本包只额外加入已接纳的 modules 启动 entry。
+Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段调用 Host 安装的 `window.__ModuleLoader__.create()`,传入 `window.__DSH_BOOT__`、外壳静态模块以及可选测试传输覆盖;facade 接纳 parser 预载的 registration 后返回构造好的模块系统与已解析 manifest。本包随后预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,统一创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。Graph、parser preload 与 facade 归 Host 所有;AppWebEntry 不感知 bootstrap package id,也不解析 wire 格式。
 
-启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。modules 是唯一通过 `registerStatic` 注册的包:普通 `lib/client.js` 仍是其 graph row 产物,但内核必须在模块系统能够加载任何内容前认领并物化该 factory。
+启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。Modules bundle 会缓存自身已物化导出,并在其普通图 entry 激活时提供闭包中的系统;Cordis service 等待使图 row 创建顺序不依赖该激活时点。
 
 `PLATFORM_MODULES`(src/platform.ts)是外壳播种共享模块的唯一事实来源。它与 `PRELOADED_CLIENT_EXTERNALS` 一起定义全部动态 bundle 的隐式 external 基座;`dsh.client.external` 只添加基座之外的精确请求。
 

+ 10 - 48
packages/client/web/src/boot.ts

@@ -7,7 +7,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import type {
-  BootManifest, ClientModuleSystem, ClientModuleSystemOptions, DshWindow,
+  BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow,
 } from '@deepseek-ai/dsh-client-modules/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { BootPage } from './boot-page.ts'
@@ -16,46 +16,7 @@ import { STATE_LABELS } from './loader-status.ts'
 import './base.css'
 
 /** Module transport hook replaced by jsdom tests. */
-export type BootSeams = Pick<ClientModuleSystemOptions, 'loadBundle'>
-
-/** Runtime exports materialized from the parser-preloaded modules bundle. */
-type ModulesClientExports = typeof import('@deepseek-ai/dsh-client-modules/client')
-
-/** Statically adopted bootstrap package that constructs the client module system. */
-const MODULES_ID = '@deepseek-ai/dsh-client-modules'
-
-/**
- * Claim and materialize the modules handoff before the module system exists.
- * The bootstrap bundle must be self-contained because no module table can
- * answer a runtime external yet.
- * @param win - Browser boot globals populated by the host HTML transform.
- * @returns The modules package's browser exports.
- */
-function claimModulesClient(win: DshWindow): ModulesClientExports {
-  const queue = win.__ModuleLoader__
-  if (queue === undefined || queue.mode !== 'queue') {
-    throw new Error('web boot: window.__ModuleLoader__ bootstrap queue is missing')
-  }
-  const index = queue.handoffs.findIndex(handoff => handoff.id === MODULES_ID)
-  const handoff = queue.handoffs[index]
-  if (handoff === undefined) {
-    throw new Error(`web boot: HTML did not preload ${MODULES_ID}/client.js`)
-  }
-  queue.handoffs.splice(index, 1)
-  const exports = handoff.factory((specifier) => {
-    throw new Error(
-      `web boot: ${MODULES_ID}/client.js requested external "${specifier}" before the module system existed`,
-    )
-  })
-  if (
-    typeof exports.ClientModuleSystem !== 'function'
-    || typeof exports.parseBootManifest !== 'function'
-    || typeof exports.apply !== 'function'
-  ) {
-    throw new Error(`web boot: ${MODULES_ID}/client.js did not export the bootstrap module face`)
-  }
-  return exports as unknown as ModulesClientExports
-}
+export type BootSeams = Pick<ClientModuleCreateOptions, 'loadBundle'>
 
 /** Browser boot entry consumed by `apps/web`. */
 export class AppWebEntry {
@@ -85,15 +46,16 @@ export class AppWebEntry {
   async run(): Promise<void> {
     try {
       const win = globalThis as DshWindow
-      const modulesClient = claimModulesClient(win)
-      this.manifest = modulesClient.parseBootManifest(win.__DSH_BOOT__)
-      this.modules = new modulesClient.ClientModuleSystem({
-        modules: this.manifest.modules,
+      const moduleLoader = win.__ModuleLoader__
+      if (moduleLoader === undefined) {
+        throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing')
+      }
+      this.modules = moduleLoader.create({
+        boot: win.__DSH_BOOT__,
         staticModules: getStaticModules(),
         ...this.seams,
       })
-      this.modules.registerStatic(MODULES_ID, modulesClient)
-      win.__DSH_MODULES__ = this.modules
+      this.manifest = this.modules.manifest
 
       const prefetching = this.prefetchImmediateTier()
       const ctx = new Context()
@@ -143,7 +105,7 @@ export class AppWebEntry {
       this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state])
     })
 
-    const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID)]
+    const rows = this.manifest.plugins.map(row => row.id)
     this.page.setTotal(rows.length)
     await prefetching
     await Promise.all(rows.map(async (name) => {

+ 79 - 42
packages/client/web/tests/boot.client.spec.ts

@@ -1,7 +1,9 @@
 // @vitest-environment jsdom
+import type { Context } from '@deepseek-ai/cordis'
 import * as modulesClient from '@deepseek-ai/dsh-client-modules/client'
 import type {
-  ClientModuleHandoffQueue, ClientPluginHandoff, DshWindow,
+  ClientBundleRegistration, ClientModuleCreateOptions, ClientModuleLoaderTarget, DshWindow,
+  WebBootEntry,
 } from '@deepseek-ai/dsh-client-modules/client'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { AppWebEntry } from '../src/boot.ts'
@@ -13,19 +15,26 @@ const moduleFace = modulesClient as unknown as Record<string, unknown>
 afterEach(() => {
   vi.restoreAllMocks()
   delete win.__DSH_BOOT__
-  delete win.__DSH_MODULES__
   delete win.__ModuleLoader__
   document.body.innerHTML = ''
 })
 
-function installQueue(factory: ClientPluginHandoff['factory']): void {
-  const handoffs: ClientPluginHandoff[] = [{ id: MODULES_ID, factory }]
-  const queue: ClientModuleHandoffQueue = {
+/** Install the stable facade shape that the Host injects before AppWebEntry runs. */
+function installFacade(
+  create?: (options: ClientModuleCreateOptions) => modulesClient.ClientModuleSystem,
+): ClientModuleLoaderTarget {
+  const pendingQueue: ClientBundleRegistration[] = []
+  const target: ClientModuleLoaderTarget = {
     mode: 'queue',
-    handoffs,
-    load: (handoff) => { handoffs.push(handoff) },
+    pendingQueue,
+    load: (registration) => { pendingQueue.push(registration) },
+    create: create ?? (options => modulesClient.createClientModuleSystem(target, {
+      id: MODULES_ID,
+      exports: moduleFace,
+    }, options)),
   }
-  win.__ModuleLoader__ = queue
+  win.__ModuleLoader__ = target
+  return target
 }
 
 async function expectBootFailure(setup: () => void, message: string): Promise<void> {
@@ -41,58 +50,86 @@ async function expectBootFailure(setup: () => void, message: string): Promise<vo
 }
 
 describe('bootstrap failure rendering', () => {
-  it('renders a missing bootstrap queue', async () => {
+  it('renders a missing bootstrap facade', async () => {
     await expectBootFailure(
       () => { delete win.__ModuleLoader__ },
-      'window.__ModuleLoader__ bootstrap queue is missing',
+      'window.__ModuleLoader__ bootstrap facade is missing',
     )
   })
 
-  it('renders an already-live bootstrap target', async () => {
-    await expectBootFailure(
-      () => { win.__ModuleLoader__ = { mode: 'live', load: () => {} } },
-      'window.__ModuleLoader__ bootstrap queue is missing',
-    )
-  })
-
-  it('renders a missing modules handoff', async () => {
-    await expectBootFailure(() => {
-      installQueue(() => moduleFace)
-      const queue = win.__ModuleLoader__ as ClientModuleHandoffQueue
-      queue.handoffs.splice(0)
-    }, `HTML did not preload ${MODULES_ID}/client.js`)
-  })
-
-  it('renders a bootstrap runtime external', async () => {
+  it('renders a create failure owned by the facade', async () => {
     await expectBootFailure(() => {
-      installQueue((require) => {
-        require('react')
-        return moduleFace
-      })
-    }, `${MODULES_ID}/client.js requested external "react"`)
+      installFacade(() => { throw new Error('facade create failed') })
+    }, 'facade create failed')
   })
 
-  it.each(['ClientModuleSystem', 'parseBootManifest', 'apply'] as const)(
-    'renders a modules handoff missing %s',
-    async (missing) => {
-      await expectBootFailure(() => {
-        installQueue(() => ({ ...moduleFace, [missing]: undefined }))
-      }, `${MODULES_ID}/client.js did not export the bootstrap module face`)
-    },
-  )
-
   it('renders a malformed boot manifest', async () => {
     await expectBootFailure(() => {
-      installQueue(() => moduleFace)
+      installFacade()
       delete win.__DSH_BOOT__
     }, 'window.__DSH_BOOT__ is missing or not an object')
   })
 
   it('renders a module-system construction failure', async () => {
     await expectBootFailure(() => {
-      installQueue(() => moduleFace)
+      installFacade()
       const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' }
       win.__DSH_BOOT__ = { rev: 'graph', entries: [duplicate, duplicate] }
     }, 'duplicate graph entry "duplicate"')
   })
 })
+
+describe('plugin activation', () => {
+  it('allows a modules-dependent row to be created before the modules row', async () => {
+    const events: string[] = []
+    const container = document.createElement('div')
+    document.body.append(container)
+    const target = installFacade()
+    const entries: WebBootEntry[] = [
+      { id: 'consumer', url: '/consumer.js', rev: '1' },
+      { id: MODULES_ID, url: '/modules.js', rev: '1' },
+      { id: 'renderer', url: '/renderer.js', rev: '1' },
+    ]
+    win.__DSH_BOOT__ = { rev: 'graph', entries }
+    const registrations = new Map<string, ClientBundleRegistration>([
+      ['/consumer.js', {
+        id: 'consumer',
+        factory: () => ({
+          inject: ['modules'],
+          apply: (ctx: Context) => {
+            expect(ctx.modules).toBeDefined()
+            events.push('consumer')
+          },
+        }),
+      }],
+      ['/renderer.js', {
+        id: 'renderer',
+        factory: () => ({
+          apply: (ctx: Context) => {
+            ctx.reflect.provide('uiRenderer', {
+              mount: (element: HTMLElement) => {
+                events.push('mount')
+                element.textContent = 'mounted'
+                return () => {}
+              },
+            })
+          },
+        }),
+      }],
+    ])
+    const entry = new AppWebEntry(container, {
+      loadBundle: async (url) => {
+        const registration = registrations.get(url)
+        if (registration === undefined) throw new Error(`missing fixture registration ${url}`)
+        target.load(registration)
+      },
+    })
+
+    await entry.run()
+
+    expect(target.mode).toBe('live')
+    expect(events).toEqual(['consumer', 'mount'])
+    expect(container.textContent).toBe('mounted')
+    await entry.dispose()
+  })
+})