Przeglądaj źródła

revert(client): remove eager chunk scanning

imccyu 6 dni temu
rodzic
commit
7f5385bf01

+ 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: 4e2a04281963985e2febe3fc87a91d0bdcf09656
-2026-07-23-client-plugin-loading-model.zh.md: 4d053071e88d9efc4304251cf49936f02cbdf2d4
+2026-07-23-client-plugin-loading-model.md: e9dc734c05eb5a73a7c8ef531bd3042548e0f06e
+2026-07-23-client-plugin-loading-model.zh.md: f4df6c0a108bd2b99756764df089fd3c7f58839f

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

@@ -34,17 +34,17 @@ The web kernel remains framework-free and imports no dynamic package value. Modu
 
 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 an entry or sibling chunk only **registers** its factory — the artifact calls `window.__ModuleLoader__.load({ id, chunk?, 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 package requests before their consumer. A generated relative `require()` with no registered factory fetches the sibling chunk under the owning package revision and resolves to its memoized exports; a chunk's `require('./client.js')` reads the already-materialized owner entry. A factory otherwise materializes registered-but-unmaterialized requests synchronously. The table resolves through a fixed branch order: seed word → memoized record → graph-row or sibling 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 package factories and the row's own factory; concurrent arrivals share one task) and `invalidate(id)` (drop a non-bootstrap package's entry and chunk factories and records so the next arrival reloads them).
+`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`.
 
 ### Combo external-script arrival and source maps
 
-The Host snapshots every built plugin entry and the sibling chunks recursively referenced by generated relative `require()` calls, then partitions each scheduling phase's ordered entry rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each startup script is addressed by its package entries, for example `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`; sibling chunks use `/plugins/<package>/<chunk>?rev=<rev>` and share the package revision. A chunk response concatenates its package-local dependency closure in dependency-first order, so every static relative CJS request resolves synchronously when the target factory materializes. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by URL, so concurrent entry or chunk arrivals execute one script. Successful settlement still requires each requested factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
+The Host snapshots every built plugin bundle and partitions each scheduling phase's ordered rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each script is addressed by its package resources, for example `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by combo URL, so concurrent row arrivals within one group execute one script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
 
 The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository form `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` points to `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. One resource follows the same rule and still produces an indexed map with one section. The Vite shell also emits source maps, letting shell code and combo-loaded plugins map stacks and performance profiles back to TypeScript/TSX.
 
-The graph retains each row's revisioned one-resource combo URL for HMR and adds a content-addressed descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted entry and sibling responses immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes that package's entry and referenced chunk bytes and publishes the resulting revision. Startup combo revisions cover the combined entry inputs and indexed map. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching. The Host serves only exact generated URLs; stale revisions and unadvertised resources return 404 instead of aliasing different bytes. 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 graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and publishes the resulting revision. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. 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
 
@@ -78,7 +78,7 @@ The Host SSE adapter forwards existing graph-change notifications and sends the
 
 Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
 
-How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures stat baselines for the entry and every referenced chunk and exposes them through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that file set. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a missing artifact keeps its row dirty so reappearance forces a re-hash even with identical metadata. On any entry or chunk mtime or size delta, or a dirty row, `rebuilt(id)` re-snapshots and hashes all executable bytes. A successful re-snapshot replaces the complete watched file set, so an entry-first multi-file write retries a missing chunk and a later chunk write cannot remain hidden behind the new entry baseline. A map-only write does not remount unchanged code. When the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol.
+How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a bundle mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; it hashes only executable bundle bytes, so a map-only write does not remount unchanged executable code. When the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one bundle stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn bundle read self-heals: its stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
 
 On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation:
 

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

@@ -34,17 +34,17 @@ Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules
 
 浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
 
-`ClientModuleSystem` 是一张 lazy CJS 表。执行一个 entry 或 sibling chunk 只会**登记**其 factory——产物调用 `window.__ModuleLoader__.load({ id, chunk?, factory })`,不会发生其他动作。每个模块主体副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行:第一次 `require`/import 该 id 时执行,之后记忆化。Import 与 prefetch 会在消费方之前递归登记已声明的 package request。生成的相对 `require()` 没有已登记 factory 时会按所属 package revision 获取 sibling chunk,并解析为记忆化导出;chunk 的 `require('./client.js')` 会读取已经物化的所属 entry。其他情况下,factory 会同步物化任何已登记但尚未物化的请求。模块表按固定分支顺序解析:seed word → 记忆化记录 → graph row 或 sibling classic-script 登记 → 已登记 factory 物化 → 大声抛错。Modules factory 是自举例外:HTML facade 先物化它,构造过程再把同一 exports 直接写入记忆化表。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的 package factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(丢弃非 bootstrap 包的 entry 与 chunk 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`。
 
 ### Combo 外部脚本到达与源码映射
 
-Host 会快照每个已构建插件 entry 及生成的相对 `require()` 递归引用的 sibling chunk,再把每个调度阶段的有序 entry row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。启动脚本由其中的 package entry 寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`;sibling chunk 使用 `/plugins/<package>/<chunk>?rev=<rev>`,并与该包共用 revision。Chunk 响应会按依赖优先顺序拼接包内依赖闭包,因此目标 factory 物化时,每个静态相对 CJS request 都能同步解析。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 URL 复用进行中的传输,因此并发 entry 或 chunk 到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
 共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的 entry 与 sibling 响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 哈希该包的 entry 与引用的 chunk 字节,并发布所得 revision。启动 combo revision 覆盖合并后的 entry 输入与 indexed map。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle,并发布所得 revision。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -78,7 +78,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 entry 与每个引用 chunk 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这组文件比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的任一产物会让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。任一 entry 或 chunk 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 会重新快照并哈希全部可执行字节。成功的重新快照会替换完整的被监视文件集,因此 entry 先落盘的多文件写入会重试缺失 chunk,随后发生的 chunk 写入也不会隐藏在新的 entry 基线后面。仅写入 map 不会重新挂载未变化的代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它只哈希可执行 bundle 字节,所以仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
 浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
 

+ 9 - 36
apps/web/tests/assembled-boot.ts

@@ -93,7 +93,6 @@ function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): s
 
 const comboUrl = (ids: readonly string[], rev: string): string =>
   `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
-const CLIENT_CHUNK_REQUIRE = /\brequire\((["'])\.\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\1\)/g
 
 /** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
 function loadAssembledPlugins(): readonly AssembledPlugin[] {
@@ -160,44 +159,18 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
   }
 }
 
-/** Build package entry, sibling chunk, and startup combo bodies for one fixture composition. */
+/** Build single-resource and startup combo script bodies for one fixture composition. */
 function bundleTable(graph: WebBootGraph, plugins: readonly AssembledPlugin[]): Map<string, string> {
-  const bundles = new Map<string, string>()
-  const entries = new Map<string, string>()
-  for (const plugin of plugins) {
-    const artifacts = new Map<string, string>([
-      ['client.js', readFileSync(plugin.bundlePath, 'utf8')],
-    ])
-    for (const fileName of globSync('client.*.js', { cwd: dirname(plugin.bundlePath) })) {
-      artifacts.set(fileName, readFileSync(join(dirname(plugin.bundlePath), fileName), 'utf8'))
-    }
-    const closure = (root: string, open: readonly string[] = [], visited = new Set<string>()): string[] => {
-      if (visited.has(root)) return []
-      if (open.includes(root)) {
-        throw new Error(`assembled boot: package-local chunk cycle ${[...open, root].join(' -> ')}`)
-      }
-      const source = artifacts.get(root)
-      if (source === undefined) {
-        throw new Error(`assembled boot: missing built bundle ${join(dirname(plugin.bundlePath), root)}`)
-      }
-      const next = [...open, root]
-      const dependencies = [...source.matchAll(CLIENT_CHUNK_REQUIRE)].flatMap(match =>
-        closure(match[2] as string, next, visited))
-      visited.add(root)
-      return [...dependencies, source]
-    }
-    const entry = closure('client.js').join('\n;\n')
-    entries.set(plugin.id, entry)
-    bundles.set(plugin.url, entry)
-    for (const fileName of artifacts.keys()) {
-      if (fileName === 'client.js') continue
-      bundles.set(`/plugins/${plugin.id}/${fileName}?rev=${plugin.rev}`, closure(fileName).join('\n;\n'))
-    }
-  }
+  const bundles = new Map(plugins.map(plugin => [
+    plugin.url,
+    readFileSync(plugin.bundlePath, 'utf8'),
+  ]))
   for (const batch of graph.batches) {
     bundles.set(batch.url, batch.entries.map((id) => {
-      const code = entries.get(id)
-      if (code === undefined) throw new Error(`assembled boot: missing built entry ${id}`)
+      const plugin = plugins.find(candidate => candidate.id === id)
+      if (plugin === undefined) throw new Error(`assembled boot: batch names unknown plugin ${id}`)
+      const code = bundles.get(plugin.url)
+      if (code === undefined) throw new Error(`assembled boot: missing built bundle ${plugin.url}`)
       return code
     }).join('\n;\n'))
   }

+ 2 - 2
docs/subsystems/client-modules.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 docs/subsystems/client-modules.md
-client-modules.md: e3a99f9e413e383f747b81ee69452ad4a3397d16
-client-modules.zh.md: f770a67ded37151c4327b59c4cffd56a9d4714e9
+client-modules.md: aff74a1da1ce524689d1a746a7267eb1544e4fef
+client-modules.zh.md: 8100708edd88d35f123d658de42525f04a90ebd6

+ 10 - 13
docs/subsystems/client-modules.md

@@ -82,26 +82,23 @@ Package metadata — including the negative "not a client package" verdict — i
 
 ## The bundle route and index injection
 
-`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. A package-local tsdown chunk uses `/plugins/<package>/<chunk>?rev=<rev>` and shares the entry revision. The Host snapshots chunks recursively from generated relative `require()` calls, and the browser loads each chunk only when its dynamic import executes. Each script is built once on its first `GET` and ends with an absolute `sourceMappingURL`. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured artifact and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with an absolute `sourceMappingURL` whose resource suffixes are `.js.map`. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
 
 ## The service
 
 ```ts type-equiv
 /** Filesystem baseline captured before a client artifact snapshot is read. */
 interface ClientArtifactBaseline {
-  /** Entry and recursively referenced chunks watched for package rebuilds. */
-  readonly files: readonly {
-    /** Absolute artifact path. */
-    readonly path: string
-    /** Artifact modification time in milliseconds. */
-    readonly mtimeMs: number
-    /** Artifact size in bytes. */
-    readonly size: number
-  }[]
+  /** Absolute path of the client bundle. */
+  readonly path: string
+  /** Bundle modification time in milliseconds. */
+  readonly mtimeMs: number
+  /** Bundle size in bytes. */
+  readonly size: number
 }
 ```
 
-`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the entry bundle's absolute path, and `artifactBaseline(id)` returns the entry and chunk stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed executable artifacts reach the graph: it re-snapshots the entry and referenced chunks, then only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed package with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
+`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle bytes, and only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
 
 [`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
 
@@ -154,8 +151,8 @@ async fetchBundle(request: Request): Promise<Response>
 artifactBaseline(id: string): ClientArtifactBaseline | undefined
 
 /**
- * Re-snapshot one package's entry and chunks (the HMR watch's registration
- * hook — the only entry point through which executable changes reach the graph).
+ * Re-hash one bundle (the HMR watch's registration hook — the only entry
+ * point through which bundle content changes reach the graph).
  * @param id - entry id (package name).
  * @returns the new rev, or undefined for an unknown id.
  */

+ 10 - 13
docs/subsystems/client-modules.zh.md

@@ -82,26 +82,23 @@ interface WebBootGraph {
 
 ## bundle 路由与 index 注入
 
-`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。包内 tsdown chunk 使用 `/plugins/<package>/<chunk>?rev=<rev>`,并与 entry 共用 revision。Host 从生成的相对 `require()` 递归快照 chunk,浏览器仅在对应 dynamic import 执行时加载该 chunk。每份脚本在首次 `GET` 时只构建一次,并以绝对 `sourceMappingURL` 结尾。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的产物,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以绝对 `sourceMappingURL` 结尾,其中每个资源后缀改为 `.js.map`。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
 
 ## 服务
 
 ```ts type-equiv
 /** Filesystem baseline captured before a client artifact snapshot is read. */
 interface ClientArtifactBaseline {
-  /** Entry and recursively referenced chunks watched for package rebuilds. */
-  readonly files: readonly {
-    /** Absolute artifact path. */
-    readonly path: string
-    /** Artifact modification time in milliseconds. */
-    readonly mtimeMs: number
-    /** Artifact size in bytes. */
-    readonly size: number
-  }[]
+  /** Absolute path of the client bundle. */
+  readonly path: string
+  /** Bundle modification time in milliseconds. */
+  readonly mtimeMs: number
+  /** Bundle size in bytes. */
+  readonly size: number
 }
 ```
 
-`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 entry bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 entry 与 chunk stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的可执行产物到达图的唯一入口:它重新快照 entry 及其引用的 chunks,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
+`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它重新哈希 bundle 字节,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
 随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
 
@@ -154,8 +151,8 @@ async fetchBundle(request: Request): Promise<Response>
 artifactBaseline(id: string): ClientArtifactBaseline | undefined
 
 /**
- * Re-snapshot one package's entry and chunks (the HMR watch's registration
- * hook — the only entry point through which executable changes reach the graph).
+ * Re-hash one bundle (the HMR watch's registration hook — the only entry
+ * point through which bundle content changes reach the graph).
  * @param id - entry id (package name).
  * @returns the new rev, or undefined for an unknown id.
  */

+ 2 - 2
packages/client/hmr/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/hmr/README.md
-README.md: ff596bcf3b055d0d83b80f535a520ba5399e4a17
-README.zh.md: 423fafecc7650e40b58c51d37d0604ca93d5491a
+README.md: da7750e26827148d3bcd41395c50b98d6c8c6678
+README.zh.md: 9e1291dbd1e3c3ceb75504e4461cd41a3fe95ade

+ 3 - 3
packages/client/hmr/README.md

@@ -39,7 +39,7 @@ Each successful reload re-executes the plugin bundle and remounts the plugin wit
 
 | Field | Default | Meaning |
 |---|---|---|
-| `pollIntervalMs` | `500` | Entry/chunk stat-poll interval in milliseconds |
+| `pollIntervalMs` | `500` | Bundle stat-poll interval in milliseconds |
 
 The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-hmr) is the exhaustive source for every accepted field and its JSDoc.
 
@@ -59,7 +59,7 @@ This section explains how the reload chain is built; observable behavior is cove
 
 ### Design concept
 
-The Host half watches each package's Client entry and referenced chunks and serves `/plugins/events`. It forwards existing graph-change and rebuilt notifications; every new connection receives the current full graph. A graph describes the browser’s desired entries and carries no Host cleanup-completion guarantee. Host activation and cleanup remain owned by the Host lifecycle. Artifact polling reports rebuilt revisions; unchanged artifacts require no content read. The browser half delegates both frame kinds to Client Modules, which serializes entry changes and waits for browser resource cleanup.
+The Host half watches bundle artifacts and serves `/plugins/events`. It forwards existing graph-change and rebuilt notifications; every new connection receives the current full graph. A graph describes the browser’s desired entries and carries no Host cleanup-completion guarantee. Host activation and cleanup remain owned by the Host lifecycle. Artifact polling reports rebuilt revisions; unchanged artifacts require no content read. The browser half delegates both frame kinds to Client Modules, which serializes entry changes and waits for browser resource cleanup.
 
 ### The browser swap
 
@@ -77,7 +77,7 @@ Download failures leave the running plugin active. After the old fiber is torn d
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Node half: entry/chunk stat polling, `rebuilt` reporting, `/plugins/events` SSE channel |
+| [`src/index.ts`](src/index.ts) | Node half: bundle stat-poll, `rebuilt` reporting, `/plugins/events` SSE channel |
 | [`src/client/index.ts`](src/client/index.ts) | Browser half: SSE subscription and delegation to the shared entry controller |
 | [`src/events.ts`](src/events.ts) | Shared frame types (`graph` / `rebuilt`) and the endpoint constant |
 

+ 3 - 3
packages/client/hmr/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-reference"
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
-| `pollIntervalMs` | `500` | entry/chunk stat 轮询间隔,单位为毫秒 |
+| `pollIntervalMs` | `500` | bundle stat 轮询间隔,单位为毫秒 |
 
 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-hmr)是所有受支持字段及其 JSDoc 的完整真源。
 
@@ -59,7 +59,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-Host 半侧监听每个包的 Client entry 与引用的 chunk,并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。产物轮询报告重建 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理。
+Host 半侧监听 bundle 产物并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。产物轮询报告重建 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理。
 
 ### 浏览器侧替换
 
@@ -77,7 +77,7 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | node 半侧:entry/chunk stat 轮询、`rebuilt` 上报、`/plugins/events` SSE 通道 |
+| [`src/index.ts`](src/index.ts) | node 半侧:bundle stat 轮询、`rebuilt` 上报、`/plugins/events` SSE 通道 |
 | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:SSE 订阅与共享条目控制器调用 |
 | [`src/events.ts`](src/events.ts) | 共享帧类型(`graph` / `rebuilt`)与端点常量 |
 

+ 26 - 45
packages/client/hmr/src/index.ts

@@ -1,6 +1,6 @@
 /**
  * Host transport for Web client graph changes and rebuilt bundles. One interval
- * stat-polls every graph row's client entry and chunks (polling by design: network mounts
+ * stat-polls every graph row's client bundle (polling by design: network mounts
  * deliver no inotify events), reports changes through
  * `clientModules.rebuilt(id)`, and serves the `/plugins/events` SSE channel
  * broadcasting graph/rebuilt frames to the browser half (src/client/).
@@ -28,7 +28,7 @@ export const inject = ['clientModules', 'webServer']
 
 /** Plugin config, validated by the same-named schemastery schema. */
 export interface Config {
-  /** Entry/chunk stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
+  /** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
   pollIntervalMs?: number
 }
 
@@ -41,34 +41,22 @@ function sseData(frame: PluginsEventFrame): string {
   return `data: ${JSON.stringify(frame)}\n\n`
 }
 
-type WatchedBundleStat = ClientArtifactBaseline['files'][number]
+type WatchedBundleStat = Omit<ClientArtifactBaseline, 'path'>
 
-interface WatchedBundle {
-  files: Map<string, Omit<WatchedBundleStat, 'path'>>
-  dirty: boolean
-}
-
-/** Index an advertised artifact baseline by path. */
-function baselineFiles(baseline: ClientArtifactBaseline): WatchedBundle['files'] {
-  return new Map(baseline.files.map(file => [file.path, { mtimeMs: file.mtimeMs, size: file.size }]))
-}
+type WatchedBundle = {
+  -readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K]
+} & { dirty: boolean }
 
-/** Snapshot every executable artifact metadata field that drives reloads. */
-function bundleStat(paths: Iterable<string>): WatchedBundle['files'] {
-  return new Map([...paths].map((path) => {
-    const bundle = statSync(path)
-    return [path, { mtimeMs: bundle.mtimeMs, size: bundle.size }]
-  }))
+/** Snapshot the executable bundle metadata that drives reloads. */
+function bundleStat(path: string): WatchedBundleStat {
+  const bundle = statSync(path)
+  return { mtimeMs: bundle.mtimeMs, size: bundle.size }
 }
 
-/** Whether every executable artifact is unchanged since the last successful re-hash. */
-function sameBundleStat(left: WatchedBundle['files'], right: WatchedBundle['files']): boolean {
-  if (left.size !== right.size) return false
-  for (const [path, stat] of left) {
-    const other = right.get(path)
-    if (other === undefined || other.mtimeMs !== stat.mtimeMs || other.size !== stat.size) return false
-  }
-  return true
+/** Whether the executable bundle is unchanged since the last successful re-hash. */
+function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): boolean {
+  return left.mtimeMs === right.mtimeMs
+    && left.size === right.size
 }
 
 /**
@@ -83,7 +71,7 @@ export function apply(ctx: Context, config: Config): void {
   // --- bundle watch: one HMR-owned stat poll ------------------------------
   const watched = new Map<string, WatchedBundle>()
 
-  const rehash = (id: string, watch: WatchedBundle): void => {
+  const rehash = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => {
     try {
       // rebuilt() replaces the opaque startup rev on its first call; later
       // calls stay silent when the content hash is unchanged.
@@ -96,21 +84,17 @@ export function apply(ctx: Context, config: Config): void {
       }
       ctx.logger.warn(error)
     }
-    const baseline = ctx.clientModules.artifactBaseline(id)
-    if (baseline === undefined) {
-      watched.delete(id)
-      return
-    }
-    watch.files = baselineFiles(baseline)
+    watch.mtimeMs = current.mtimeMs
+    watch.size = current.size
     watch.dirty = false
   }
 
   const watchRow = (id: string, baseline: ClientArtifactBaseline): void => {
-    const watch: WatchedBundle = { files: baselineFiles(baseline), dirty: false }
+    const watch: WatchedBundle = { ...baseline, dirty: false }
     watched.set(id, watch)
-    let current: WatchedBundle['files']
+    let current: WatchedBundleStat
     try {
-      current = bundleStat(watch.files.keys())
+      current = bundleStat(baseline.path)
     } catch (error) {
       watch.dirty = true
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
@@ -118,23 +102,23 @@ export function apply(ctx: Context, config: Config): void {
     }
     // The module host captured its baseline before reading the bytes in the
     // startup batch. Only a mismatch crosses into the content-hash path.
-    if (!sameBundleStat(current, watch.files)) rehash(id, watch)
+    if (!sameBundleStat(current, watch)) rehash(id, watch, current)
   }
 
   const pollWatches = (): void => {
     for (const [id, watch] of watched) {
-      let current: WatchedBundle['files']
+      let current: WatchedBundleStat
       try {
-        current = bundleStat(watch.files.keys())
+        current = bundleStat(watch.path)
       } catch (error) {
         watch.dirty = true
         if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
         continue
       }
-      if (!watch.dirty && sameBundleStat(current, watch.files)) continue
+      if (!watch.dirty && sameBundleStat(current, watch)) continue
       // Stat-before-hash preserves a detectable older baseline for writes that
       // land during hashing. Repeated stat changes heal a torn read.
-      rehash(id, watch)
+      rehash(id, watch, current)
     }
   }
 
@@ -147,10 +131,7 @@ export function apply(ctx: Context, config: Config): void {
       if (watch !== undefined) rows.set(row.id, watch)
     }
     for (const [id, watch] of watched) {
-      const baseline = rows.get(id)
-      if (baseline !== undefined
-        && baseline.files.length === watch.files.size
-        && baseline.files.every(file => watch.files.has(file.path))) continue
+      if (rows.get(id)?.path === watch.path) continue
       watched.delete(id)
     }
     for (const [id, watch] of rows) {

+ 8 - 31
packages/client/hmr/tests/node-half.client.spec.ts

@@ -30,21 +30,17 @@ type FakeHost = ClientModuleRegistry & { rebuiltCalls: string[]; fireGraphChange
 interface FakeHostOptions {
   beforeGraphRead?: () => void
   rebuilt?: (id: string) => string | undefined
-  artifacts?: (id: string, entryPath: string) => readonly string[]
 }
 
-function artifactBaseline(paths: readonly string[]): ClientArtifactBaseline {
-  return { files: paths.map((path) => {
-    const bundle = statSync(path)
-    return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
-  }) }
+function artifactBaseline(path: string): ClientArtifactBaseline {
+  const bundle = statSync(path)
+  return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
 }
 
 function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
   const graphListeners = new Set<() => void>()
   const rebuiltCalls: string[] = []
-  const artifacts = (id: string, path: string): readonly string[] => options.artifacts?.(id, path) ?? [path]
-  const baselines = new Map([...rows].map(([id, path]) => [id, artifactBaseline(artifacts(id, path))]))
+  const baselines = new Map([...rows].map(([id, path]) => [id, artifactBaseline(path)]))
   const fake: Pick<FakeHost, 'graph' | 'artifactBaseline' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
     rebuiltCalls,
     fireGraphChanged: () => { for (const l of graphListeners) l() },
@@ -60,19 +56,15 @@ function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOption
       const path = rows.get(id)
       if (path === undefined) return undefined
       let baseline = baselines.get(id)
-      const paths = artifacts(id, path)
-      if (baseline === undefined || baseline.files.map(file => file.path).join('\0') !== paths.join('\0')) {
-        baseline = artifactBaseline(paths)
+      if (baseline?.path !== path) {
+        baseline = artifactBaseline(path)
         baselines.set(id, baseline)
       }
-      return { files: baseline.files.map(file => ({ ...file })) }
+      return { ...baseline }
     },
     rebuilt: (id) => {
       rebuiltCalls.push(id)
-      const result = options.rebuilt?.(id) ?? 'r2'
-      const path = rows.get(id)
-      if (path !== undefined) baselines.set(id, artifactBaseline(artifacts(id, path)))
-      return result
+      return options.rebuilt?.(id) ?? 'r2'
     },
     onRebuilt: () => () => {},
     onGraphChanged: (listener) => {
@@ -192,21 +184,6 @@ describe('hmr node half', () => {
     await fiber.dispose()
   })
 
-  it('rehashes when a sibling chunk changes without an entry rewrite', async () => {
-    const bundle = join(dir, 'chunk-entry.js')
-    const chunk = join(dir, 'client.pdf.js')
-    writeFileSync(bundle, 'module.exports = require("./client.pdf.js")')
-    writeFileSync(chunk, 'v1')
-    const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
-      artifacts: () => [bundle, chunk],
-    })
-    const fiber = await mount(clientModuleHost, fakeHttpServer([]))
-
-    writeFileSync(chunk, 'v2-longer')
-    await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
-    await fiber.dispose()
-  })
-
   it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
     const bundle = join(dir, 'replace.js')
     writeFileSync(bundle, 'seed')

+ 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: 4a8c8f701de95e79477233e2e53a88bb63940e6e
-README.zh.md: 6e2fdef08a645ce1af56bdf6650621ceb608a0ec
+README.md: 1ea4976743eb0b396b02a765e51dec2a17f3c31b
+README.zh.md: 648af567b7d12f036ae50d5468cc88725bc052d5

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

@@ -65,13 +65,13 @@ The package has two sides: the Node half is the composition and serving side (`c
 
 ### Lazy-CJS model
 
-Executing a plugin bundle only registers its factory; every module-body side effect (CSS injection included) lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`). A package may also register sibling chunks emitted by tsdown; the relative `require()` generated for `import()` loads and memoizes that chunk on demand, while a chunk's `require('./client.js')` reads its already-materialized owner entry. A factory that requires another registered-but-unmaterialized module materializes it recursively; require cycles throw because factory-form CJS cannot deliver partial exports. Resolution checks the platform seed table, memoized records, boot-graph rows, and registered factories in that order; anything else throws. Ordinary module-table `require` uses the same order without asynchronous graph-row loading and records observed edges into the module record.
+Executing a plugin bundle only registers its factory; every module-body side effect (CSS injection included) lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`). A factory that requires another registered-but-unmaterialized module materializes it recursively; require cycles throw because factory-form CJS cannot deliver partial exports. Resolution checks the platform seed table, memoized records, boot-graph rows, and registered factories in that order; anything else throws. The synchronous `require` uses the same order without asynchronous graph-row loading and records observed edges into the module record.
 
 ### Incremental composition
 
 The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
 
-The Node half snapshots each client entry and every recursively referenced sibling chunk before publication, then creates combo descriptors without building response bodies. It groups startup entries into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; package-local chunks remain separate `/plugins/<package>/<chunk>?rev=...` resources and load only when their dynamic import executes. A requested chunk response registers its sibling dependencies in dependency-first order before the target factory, so generated static CJS requests remain synchronous. Each startup phase is partitioned before a URL exceeds 3 KiB. A script body is built once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. Every generated map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged artifact. Initial per-plugin revisions use process nonces, HMR hashes the entry and its chunks, and combo revisions derive from the ordered row revisions. Advertised responses are immutable after first materialization, and an unknown resource or revision returns 404.
+The Node half snapshots each client bundle before publication and creates combo descriptors without building response bodies. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. A script body is combined once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. Every combo map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces, HMR hashes only a changed bundle, and combo revisions derive from the ordered row revisions. Advertised responses are immutable after first materialization, and an unknown combination or revision returns 404.
 
 ### Boot manifest injection
 

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

@@ -65,13 +65,13 @@ application combo 脚本在启动时仅注册一次插件 factory;模块主体
 
 ### 惰性 CJS 模型
 
-执行插件 bundle 只注册其 factory;每个模块主体副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出,在 `loadCache` 中记忆化)。一个包也可以注册 tsdown 产出的 sibling chunk;`import()` 生成的相对 `require()` 会按需加载并记忆该 chunk,而 chunk 的 `require('./client.js')` 会读取已经物化的所属 entry。factory 依赖另一个已注册但未物化的模块时会递归物化它;require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。解析会依次检查平台 seed 表、已记忆记录、启动图 row 与已注册 factory;其他情况一律抛错。普通模块表 `require` 使用相同顺序,但不含异步 graph-row 加载,并把观察到的边记录到模块记录中。
+执行插件 bundle 只注册其 factory;每个模块主体副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出,在 `loadCache` 中记忆化)。factory 依赖另一个已注册但未物化的模块时会递归物化它;require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。解析会依次检查平台 seed 表、已记忆记录、启动图 row 与已注册 factory;其他情况一律抛错。交给 factory 的同步 `require` 使用相同顺序,但不含异步图 row 加载,并把观察到的边记录到模块记录中。
 
 ### 增量组合
 
 Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
 
-Node 半侧会在发布前快照每个客户端 entry 及其递归引用的全部 sibling chunk,并在不构建响应 body 的情况下创建 combo descriptor。它把启动 entry 分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;包内 chunk 保持为独立的 `/plugins/<package>/<chunk>?rev=...` 资源,仅在其 dynamic import 执行时加载。请求一个 chunk 时,响应会先按依赖顺序登记其 sibling dependency,再登记目标 factory,使生成的静态 CJS request 保持同步。每个启动阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只构建一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。每份生成的 map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包产物生成 identity section。初始逐插件 revision 使用进程 nonce,HMR 哈希 entry 及其 chunks,combo revision 从有序 row revision 派生。已公告响应在首次物化后保持不可变;未知资源或 revision 返回 404。
+Node 半侧会在发布前快照每个客户端 bundle,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只组合一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,HMR 只哈希变化的 bundle,combo revision 从有序 row revision 派生。已公告响应在首次物化后保持不可变;未知组合或 revision 返回 404。
 
 ### 启动 manifest 注入
 

+ 5 - 8
packages/client/modules/src/client/manifest.ts

@@ -306,13 +306,10 @@ export function parseBootManifest(wire: unknown): BootManifest {
 export interface ClientBundleRegistration {
   /** Plugin id (package name) — the registration key; must match the graph row being executed. */
   id: string
-  /** Package-local chunk filename; absent for the package's `client.js` entry. */
-  chunk?: string
   /**
    * Closure factory holding the whole bundle body: receives the synchronous
-   * require bound to the module table and returns the bundle's exports. A
-   * generated relative chunk require returns that chunk's loading promise.
-   * The factory runs once, at materialization.
+   * require bound to the module table and returns the bundle's exports. Runs
+   * once, at materialization.
    */
   factory: (require: (spec: string) => unknown) => Record<string, unknown>
 }
@@ -357,7 +354,7 @@ export interface DshWindow {
 
 /** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (flat module-graph boundary). */
 export interface ClientModuleRecord {
-  /** Module id (entry package name or package-local chunk key). */
+  /** Module id (entry name / package name). */
   id: string
   /** Materialized exports (`module.exports` from a factory or bootstrap registration). */
   exports: unknown
@@ -403,8 +400,8 @@ export interface ClientModuleLoader {
    */
   prefetch(id: string): Promise<void>
   /**
-   * Full reset of one non-bootstrap package: drop its entry and chunk factories
-   * and materialized records so the next prefetch/import loads its one-resource
+   * Full reset of one non-bootstrap module: drop its registered factory and
+   * materialized record so the next prefetch/import loads its one-resource
    * combo script rather than the initial multi-resource request. The bootstrap
    * module remains materialized.
    * @param id - entry name to invalidate.

+ 14 - 87
packages/client/modules/src/client/system.ts

@@ -36,28 +36,6 @@ function atRevision(url: string, rev: string): string {
   return url.replace(/([?&]rev=)[^&#]*/, `$1${encodeURIComponent(rev)}`)
 }
 
-const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
-
-/** Internal module-table key for one package-local chunk. */
-function chunkId(ownerId: string, fileName: string): string {
-  return `${ownerId}/${fileName}`
-}
-
-/** Resolve a sibling chunk against the package's one-resource URL and current revision. */
-function chunkUrl(row: BootModuleRow, fileName: string, rev: string): string {
-  const url = atRevision(row.url, rev)
-  const marker = '/??'
-  const resourceStart = url.indexOf(marker)
-  const revisionStart = url.indexOf('&rev=', resourceStart + marker.length)
-  const resource = resourceStart < 0 || revisionStart < 0
-    ? undefined
-    : url.slice(resourceStart + marker.length, revisionStart)
-  if (resource !== `${row.id}/client.js`) {
-    throw new Error(`client-modules: cannot resolve chunk ${JSON.stringify(fileName)} from bundle URL ${url}`)
-  }
-  return `${url.slice(0, resourceStart)}/${row.id}/${fileName}?${url.slice(revisionStart + 1)}`
-}
-
 /**
  * Claim and inventory the <style> tags a factory injected during
  * materialization: preset-emitted tags arrive pre-tagged with data-plugin;
@@ -144,23 +122,13 @@ export class ClientModuleSystem implements ClientModuleLoader {
 
   /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
   private register(registration: ClientBundleRegistration): void {
-    const ownerId = stripClientSuffix(registration.id)
-    if (registration.chunk !== undefined && !CLIENT_CHUNK.test(registration.chunk)) {
-      throw new Error(`client-modules: invalid package-local chunk ${JSON.stringify(registration.chunk)}`)
-    }
-    const id = registration.chunk === undefined ? ownerId : chunkId(ownerId, registration.chunk)
-    const rev = this.factories.get(ownerId)?.rev ?? this.reloadTargets.get(ownerId)?.rev ?? this.graphRows.get(ownerId)?.rev
-    const previous = this.factories.get(id)
-    if (registration.chunk !== undefined && previous !== undefined && previous.rev === rev) return
-    if (this.bootstrapIds.has(id)) {
+    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?)`)
     }
-    if (previous !== undefined) {
-      throw new Error(`client-modules: duplicate factory registration for "${id}" (bundle executed twice without invalidate?)`)
-    }
     this.factories.set(id, {
       factory: registration.factory,
-      rev,
+      rev: this.reloadTargets.get(id)?.rev ?? this.graphRows.get(id)?.rev,
     })
   }
 
@@ -215,7 +183,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
   }
 
   /** Materialize a registered factory (synchronous; memoized in loadCache). */
-  private materialize(id: string, ownerId = id): ClientModuleRecord {
+  private materialize(id: string): ClientModuleRecord {
     const existing = this.loadCache.get(id)
     if (existing !== undefined) return existing
     const registered = this.factories.get(id)
@@ -227,12 +195,12 @@ export class ClientModuleSystem implements ClientModuleLoader {
     this.materializing.add(id)
     try {
       const edges = new Set<string>()
-      const exports = registered.factory(this.makeRequire(ownerId, edges))
-      const record: ClientModuleRecord = { id, exports, styles: claimStyles(ownerId), edges }
+      const exports = registered.factory(this.makeRequire(edges))
+      const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
       this.loadCache.set(id, record)
       return record
     } catch (error) {
-      removeOwnedStyles(ownerId)
+      removeOwnedStyles(id)
       throw error
     } finally {
       this.materializing.delete(id)
@@ -240,30 +208,14 @@ export class ClientModuleSystem implements ClientModuleLoader {
   }
 
   /**
-   * The require answered to factories: a generated relative chunk request
-   * returns its asynchronous load, while ordinary module-table requests stay
-   * synchronous: seed → memoized record → registered factory.
+   * 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.
    */
-  private makeRequire(ownerId: string, edges: Set<string>): (spec: string) => unknown {
+  private makeRequire(edges: Set<string>): (spec: string) => unknown {
     return (spec: string): unknown => {
       edges.add(spec)
-      if (spec.startsWith('./')) {
-        if (spec === './client.js') {
-          const owner = this.loadCache.get(ownerId)
-          /* v8 ignore next -- a chunk factory is materialized only from its cached owner entry. */
-          if (owner === undefined) throw new Error(`client-modules: package chunk loaded before entry "${ownerId}"`)
-          return owner.exports
-        }
-        const fileName = spec.slice(2)
-        if (!CLIENT_CHUNK.test(fileName)) {
-          throw new Error(`client-modules: invalid relative chunk request ${JSON.stringify(spec)}`)
-        }
-        const id = chunkId(ownerId, fileName)
-        const record = this.loadCache.get(id)
-        if (record !== undefined) return record.exports
-        if (this.factories.has(id)) return this.materialize(id, ownerId).exports
-        return this.importChunk(ownerId, fileName)
-      }
       if (this.seed.has(spec)) return this.seed.get(spec)
       const id = stripClientSuffix(spec)
       const record = this.loadCache.get(id)
@@ -276,27 +228,6 @@ export class ClientModuleSystem implements ClientModuleLoader {
     }
   }
 
-  /** Load, register, and materialize one package-local tsdown chunk. */
-  private async importChunk(ownerId: string, fileName: string): Promise<unknown> {
-    const id = chunkId(ownerId, fileName)
-    const row = this.graphRows.get(ownerId)
-    if (row === undefined) throw new Error(`client-modules: chunk owner "${ownerId}" is not a boot graph entry`)
-    const revision = this.factories.get(ownerId)?.rev
-    /* v8 ignore next -- graph entry registration always records its row or reload revision. */
-    if (revision === undefined) throw new Error(`client-modules: chunk owner "${ownerId}" has no artifact revision`)
-    const url = chunkUrl(row, fileName, revision)
-    let transport = this.pendingArrival.get(url)
-    if (transport === undefined) {
-      transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
-      this.pendingArrival.set(url, transport)
-    }
-    await transport
-    if (!this.factories.has(id)) {
-      throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
-    }
-    return this.materialize(id, ownerId).exports
-  }
-
   async import(specifier: string): Promise<unknown> {
     if (this.seed.has(specifier)) return this.seed.get(specifier)
     const id = stripClientSuffix(specifier)
@@ -371,11 +302,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
       const revision = rev ?? row.rev
       this.reloadTargets.set(normalized, { url: atRevision(row.url, revision), rev: revision })
     } else this.reloadTargets.delete(normalized)
-    for (const key of this.factories.keys()) {
-      if (key === normalized || key.startsWith(`${normalized}/client.`)) this.factories.delete(key)
-    }
-    for (const key of this.loadCache.keys()) {
-      if (key === normalized || key.startsWith(`${normalized}/client.`)) this.loadCache.delete(key)
-    }
+    this.factories.delete(normalized)
+    this.loadCache.delete(normalized)
   }
 }

+ 33 - 143
packages/client/modules/src/index.ts

@@ -58,15 +58,12 @@ interface WebBootRowFields {
 
 /** Filesystem baseline captured before a client artifact snapshot is read. */
 export interface ClientArtifactBaseline {
-  /** Entry and recursively referenced chunks watched for package rebuilds. */
-  readonly files: readonly {
-    /** Absolute artifact path. */
-    readonly path: string
-    /** Artifact modification time in milliseconds. */
-    readonly mtimeMs: number
-    /** Artifact size in bytes. */
-    readonly size: number
-  }[]
+  /** Absolute path of the client bundle. */
+  readonly path: string
+  /** Bundle modification time in milliseconds. */
+  readonly mtimeMs: number
+  /** Bundle size in bytes. */
+  readonly size: number
 }
 
 /** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
@@ -94,8 +91,6 @@ const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
 
 /** Missing built client export, retained as structured data for activation-error grouping. */
 class MissingClientBundleError extends Error {
-  readonly code = 'ENOENT'
-
   constructor(
     readonly packageName: string,
     readonly clientPath: string,
@@ -142,8 +137,6 @@ interface WebPluginRecord {
   meta: PkgMeta
   /** Exact build artifact included in the startup batches. */
   bundle: Buffer
-  /** Package-local chunks referenced by the entry or another captured chunk. */
-  chunks: Map<string, Buffer>
   /** Pre-read filesystem baseline handed to the HMR watcher. */
   baseline: ClientArtifactBaseline
 }
@@ -153,7 +146,6 @@ interface ComboResource {
   id: string
   rev: string
   clientPath: string
-  fileName: string
   bundle: Buffer
 }
 
@@ -187,8 +179,6 @@ const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
 const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
 /** Debugger source name appended to page bundles in the WebWorker image. */
 const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
-/** Relative CJS requests emitted by tsdown for package-local dynamic imports. */
-const CLIENT_CHUNK_REQUIRE = /\brequire\((["'])\.\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\1\)/g
 
 /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
 function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
@@ -215,12 +205,9 @@ function framedHash(domain: string, parts: readonly Buffer[]): string {
   return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
 }
 
-/** Hash the entry and every package-local chunk served under one plugin revision. */
-function artifactRevision(bundle: Buffer, chunks: ReadonlyMap<string, Buffer>): string {
-  return framedHash('plugin-artifact', [bundle, ...[...chunks].sort(([left], [right]) => (
-    left < right ? -1 : left > right ? 1 : 0
-  ))
-    .flatMap(([name, body]) => [Buffer.from(name), body])])
+/** Hash the executable artifact served after HMR observes one plugin change. */
+function artifactRevision(bundle: Buffer): string {
+  return framedHash('plugin-artifact', [bundle])
 }
 
 /** Address one ordered plugin-file list through the shared combo route. */
@@ -229,11 +216,6 @@ function comboUrl(ids: readonly string[], rev: string, sourceMap = false): strin
   return `/plugins/??${resources}&rev=${rev}`
 }
 
-/** Address one package-local chunk through the same revision as its entry. */
-function chunkUrl(id: string, fileName: string, rev: string, sourceMap = false): string {
-  return `/plugins/${id}/${fileName}${sourceMap ? '.map' : ''}?rev=${rev}`
-}
-
 /** Measure the longer map-form URL used to partition a startup resource list. */
 function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
   return Buffer.byteLength(comboUrl(
@@ -283,7 +265,7 @@ function prepareSource(resource: ComboResource): PreparedSource {
   source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
   if (!source.endsWith('\n')) source += '\n'
   const fallbackSource = sourceUrl === undefined
-    ? `/plugins/${resource.id}/${resource.fileName}`
+    ? `/plugins/${resource.id}/client.js`
     : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
   return { source, fallbackSource }
 }
@@ -329,7 +311,7 @@ function newlineCount(value: string): number {
 function comboSectionMap(resource: ComboResource, original: Record<string, unknown>): Record<string, unknown> {
   const sourcePaths = original.sources as string[]
   const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
-  const base = new URL(`/plugins/${resource.id}/${resource.fileName}.map`, 'http://dsh.invalid')
+  const base = new URL(`/plugins/${resource.id}/client.js.map`, 'http://dsh.invalid')
   const relocated = sourcePaths.map((source) => {
     const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
     const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
@@ -383,7 +365,6 @@ function buildComboScript(resources: readonly ComboResource[], sourceMapUrl: str
 function buildComboSourceMap(
   resources: readonly ComboResource[],
   sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
-  fileName = 'client.js',
 ): Buffer {
   const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
   let line = 0
@@ -402,7 +383,7 @@ function buildComboSourceMap(
     sections.push({ offset: { line, column: 0 }, map: section })
     line += newlineCount(`${prepared.source};\n`)
   }
-  return Buffer.from(`${JSON.stringify({ version: 3, file: fileName, sections })}\n`)
+  return Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
 }
 
 /** Describe one combo and defer its executable and debug payloads independently. */
@@ -411,25 +392,14 @@ function buildCombo(
   sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
   revision?: string,
 ): ComboArtifact {
-  const entries = records.map(record => record.entry.id)
-  const entryResources = records.map(record => ({
+  const resources = records.map(record => ({
     id: record.entry.id,
     rev: record.entry.rev,
     clientPath: record.meta.clientPath,
-    fileName: 'client.js',
     bundle: record.bundle,
   }))
-  const resources = records.flatMap(record => [
-    ...chunkDependencies(record, record.bundle),
-    {
-      id: record.entry.id,
-      rev: record.entry.rev,
-      clientPath: record.meta.clientPath,
-      fileName: 'client.js',
-      bundle: record.bundle,
-    },
-  ])
-  const rev = revision ?? comboRevision(entryResources)
+  const rev = revision ?? comboRevision(resources)
+  const entries = resources.map(resource => resource.id)
   const url = comboUrl(entries, rev)
   const sourceMapUrl = comboUrl(entries, rev, true)
   return {
@@ -442,56 +412,6 @@ function buildCombo(
   }
 }
 
-/** Order package-local chunk roots and dependencies for synchronous CJS materialization. */
-function orderedChunks(record: WebPluginRecord, roots: Iterable<string>): ComboResource[] {
-  const resources: ComboResource[] = []
-  const visited = new Set<string>()
-  const open: string[] = []
-  const visit = (fileName: string): void => {
-    if (visited.has(fileName)) return
-    const cycleStart = open.indexOf(fileName)
-    if (cycleStart !== -1) {
-      throw new Error(`client-modules: package-local chunk cycle ${[...open.slice(cycleStart), fileName].join(' -> ')}`)
-    }
-    const bundle = record.chunks.get(fileName)
-    /* v8 ignore next -- artifactSnapshot captures every relative chunk dependency before composition. */
-    if (bundle === undefined) {
-      throw new Error(`client-modules: chunk ${JSON.stringify(fileName)} is absent from package ${record.entry.id}`)
-    }
-    open.push(fileName)
-    for (const match of bundle.toString('utf8').matchAll(CLIENT_CHUNK_REQUIRE)) {
-      const dependency = match[2] as string
-      visit(dependency)
-    }
-    open.pop()
-    visited.add(fileName)
-    resources.push({
-      id: record.entry.id,
-      rev: record.entry.rev,
-      clientPath: join(dirname(record.meta.clientPath), fileName),
-      fileName,
-      bundle,
-    })
-  }
-  for (const root of roots) visit(root)
-  return resources
-}
-
-/** Package-local static dependencies referenced directly by one generated artifact. */
-function chunkDependencies(record: WebPluginRecord, bundle: Buffer): ComboResource[] {
-  const roots: string[] = []
-  for (const match of bundle.toString('utf8').matchAll(CLIENT_CHUNK_REQUIRE)) {
-    const dependency = match[2] as string
-    roots.push(dependency)
-  }
-  return orderedChunks(record, roots)
-}
-
-/** Order one chunk and its package-local dependencies for synchronous CJS materialization. */
-function chunkClosure(record: WebPluginRecord, rootFileName: string): ComboResource[] {
-  return orderedChunks(record, [rootFileName])
-}
-
 /** Add initial-load scheduling metadata to a combo artifact. */
 function buildBatch(
   phase: WebBootBatchPhase,
@@ -731,25 +651,25 @@ export class ClientModuleRegistry extends Service {
    */
   artifactBaseline(id: string): ClientArtifactBaseline | undefined {
     const baseline = this.table.get(id)?.baseline
-    return baseline === undefined ? undefined : { files: baseline.files.map(file => ({ ...file })) }
+    return baseline === undefined ? undefined : { ...baseline }
   }
 
   /**
-   * Re-snapshot one package's entry and chunks (the HMR watch's registration
-   * hook — the only entry point through which executable changes reach the graph).
+   * Re-hash one bundle (the HMR watch's registration hook — the only entry
+   * point through which bundle content changes reach the graph).
    * @param id - entry id (package name).
    * @returns the new rev, or undefined for an unknown id.
    */
   rebuilt(id: string): string | undefined {
     const record = this.table.get(id)
     if (record === undefined) return undefined
-    const snapshot = this.artifactSnapshot(id, record.meta.clientPath)
-    const rev = artifactRevision(snapshot.bundle, snapshot.chunks)
-    record.baseline = snapshot.baseline
+    const baseline = this.captureArtifactBaseline(record.meta.clientPath)
+    const bundle = readFileSync(record.meta.clientPath)
+    const rev = artifactRevision(bundle)
+    record.baseline = baseline
     if (rev === record.entry.rev) return rev
     record.entry = graphRow(id, rev, record.meta)
-    record.bundle = snapshot.bundle
-    record.chunks = snapshot.chunks
+    record.bundle = bundle
     this.composed = this.compose()
     for (const notify of this.rebuildListeners) {
       // Containment: rebuilt() runs inside the HMR watch callback — a
@@ -825,19 +745,6 @@ export class ClientModuleRegistry extends Service {
         body: artifact.sourceMapBody,
         contentType: 'application/json; charset=utf-8',
       })
-      for (const fileName of record.chunks.keys()) {
-        const resources = chunkClosure(record, fileName)
-        const url = chunkUrl(record.entry.id, fileName, record.entry.rev)
-        const sourceMapUrl = chunkUrl(record.entry.id, fileName, record.entry.rev, true)
-        responses.set(url, this.responses.get(url) ?? {
-          body: lazyBody(() => buildComboScript(resources, sourceMapUrl)),
-          contentType: 'text/javascript; charset=utf-8',
-        })
-        responses.set(sourceMapUrl, this.responses.get(sourceMapUrl) ?? {
-          body: lazyBody(() => buildComboSourceMap(resources, this.readSourceMap, fileName)),
-          contentType: 'application/json; charset=utf-8',
-        })
-      }
     }
     this.previousBatchResponses = this.batchResponses
     this.batchResponses = batchResponses
@@ -973,11 +880,11 @@ export class ClientModuleRegistry extends Service {
     return `${baseUrl}\0${loaderName}`
   }
 
-  /** Capture one artifact's stats before reading its bytes. */
-  private captureArtifactBaseline(path: string): ClientArtifactBaseline['files'][number] {
-    const bundle = statSync(path)
+  /** Capture the bundle stats before reading its bytes. */
+  private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
+    const bundle = statSync(clientPath)
     return {
-      path,
+      path: clientPath,
       mtimeMs: bundle.mtimeMs,
       size: bundle.size,
     }
@@ -989,39 +896,23 @@ export class ClientModuleRegistry extends Service {
   }
 
   /**
-   * Read the activation-time entry and recursively referenced chunk snapshot.
+   * Read the activation-time bundle snapshot.
    * @param pkgName - package that declares the client bundle.
    * @param clientPath - absolute path of the built client artifact.
    * @returns the immutable bytes plus the pre-read filesystem baseline.
    * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
    */
-  private artifactSnapshot(pkgName: string, clientPath: string): {
+  private initialBundleSnapshot(pkgName: string, clientPath: string): {
     bundle: Buffer
-    chunks: Map<string, Buffer>
     baseline: ClientArtifactBaseline
   } {
     try {
-      const files = [this.captureArtifactBaseline(clientPath)]
+      const baseline = this.captureArtifactBaseline(clientPath)
       const bundle = readFileSync(clientPath)
-      const chunks = new Map<string, Buffer>()
-      const pending = [bundle]
-      for (const artifact of pending) {
-        const source = artifact.toString('utf8')
-        for (const match of source.matchAll(CLIENT_CHUNK_REQUIRE)) {
-          const fileName = match[2] as string
-          if (chunks.has(fileName)) continue
-          const chunkPath = join(dirname(clientPath), fileName)
-          files.push(this.captureArtifactBaseline(chunkPath))
-          const chunk = readFileSync(chunkPath)
-          chunks.set(fileName, chunk)
-          pending.push(chunk)
-        }
-      }
-      return { bundle, chunks, baseline: { files } }
+      return { bundle, baseline }
     } catch (error) {
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-      const missingPath = (error as NodeJS.ErrnoException).path
-      throw new MissingClientBundleError(pkgName, typeof missingPath === 'string' ? missingPath : clientPath, error)
+      throw new MissingClientBundleError(pkgName, clientPath, error)
     }
   }
 
@@ -1094,7 +985,7 @@ export class ClientModuleRegistry extends Service {
     if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
     // The opaque initial rev rides the row until HMR observes a file change;
     // a fiber restart from the same source reuses the existing row.
-    const snapshot = this.artifactSnapshot(packageName, source.meta.clientPath)
+    const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
     const rev = this.allocateInitialRevision()
     this.table.set(packageName, {
       entry: graphRow(packageName, rev, source.meta),
@@ -1102,7 +993,6 @@ export class ClientModuleRegistry extends Service {
       sourceKey: source.sourceKey,
       meta: source.meta,
       bundle: snapshot.bundle,
-      chunks: snapshot.chunks,
       baseline: snapshot.baseline,
     })
     return true

+ 1 - 99
packages/client/modules/tests/loader.client.spec.ts

@@ -12,8 +12,6 @@ const MODULES_ID = '@deepseek-ai/dsh-client-modules'
 
 const comboUrl = (ids: readonly string[], rev: string): string =>
   `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
-const chunkUrl = (id: string, fileName: string, rev = '0'): string =>
-  `/plugins/${id}/${fileName}?rev=${rev}`
 const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'bootstrap')
 const APPLICATION_URL = comboUrl(['a', 'b'], 'application')
 const win = globalThis as DshWindow
@@ -73,7 +71,6 @@ function bench(
     gated?: string[]
     pending?: ClientBundleRegistration[]
     defaultTransport?: boolean
-    chunks?: Record<string, Factory | null>
   } = {},
 ): Bench {
   const fetched: string[] = []
@@ -95,18 +92,10 @@ function bench(
     const singleId = combo?.split(',').length === 1 && combo.endsWith('/client.js')
       ? combo.slice(0, -'/client.js'.length)
       : undefined
-    const sibling = /^\/plugins\/(.+)\/(client\.[^/]+\.js)$/.exec(parsed.pathname)
     for (const id of batchIds ?? (singleId === undefined ? [] : [singleId])) {
       const factory = bundles[id]
       if (factory != null) win.__ModuleLoader__?.load({ id, factory })
     }
-    if (sibling !== null) {
-      const id = sibling[1]!
-      const chunk = sibling[2]!
-      const key = `${id}/${chunk}`
-      const factory = opts.chunks?.[key]
-      if (factory != null) win.__ModuleLoader__?.load({ id, chunk, factory })
-    }
   }
   const bootstrapEntries = entries.filter(entry => entry.initialUrl === BOOTSTRAP_URL).map(entry => entry.id)
   const applicationEntries = entries.filter(entry => entry.initialUrl === APPLICATION_URL).map(entry => entry.id)
@@ -234,57 +223,6 @@ describe('lazy CJS arrival', () => {
     await b.loader.prefetch('a')
     expect(b.fetched).toHaveLength(1)
   })
-
-  it('fetches a dynamic sibling chunk once on demand', async () => {
-    const b = bench([row('a')], {
-      a: req => ({
-        marker: 'entry',
-        load: () => Promise.resolve().then(() => req('./client.pdf.js')),
-      }),
-    }, {
-      chunks: {
-        'a/client.pdf.js': req => ({
-          marker: 'pdf',
-          owner: req('./client.js'),
-          store: req('./client.store.js'),
-        }),
-      },
-    })
-    b.target.load({ id: 'a', chunk: 'client.store.js', factory: () => ({ shared: true }) })
-    const entry = await b.loader.import('a', '', {}) as {
-      marker: string
-      load: () => Promise<{ marker: string; owner: unknown; store: unknown }>
-    }
-    expect(b.fetched).toEqual([APPLICATION_URL])
-
-    const [first, second] = await Promise.all([entry.load(), entry.load()])
-    const third = await entry.load()
-    expect(first).toBe(second)
-    expect(third).toBe(first)
-    expect(first).toEqual({ marker: 'pdf', owner: entry, store: { shared: true } })
-    expect(b.fetched).toEqual([APPLICATION_URL, chunkUrl('a', 'client.pdf.js')])
-  })
-
-  it('reloads package-local chunks at the invalidated entry revision', async () => {
-    const b = bench([row('a')], {
-      a: req => ({ load: () => Promise.resolve().then(() => req('./client.pdf.js')) }),
-    }, {
-      chunks: { 'a/client.pdf.js': () => ({ marker: 'pdf' }) },
-    })
-    const first = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
-    await first.load()
-
-    b.loader.invalidate('a', 'rebuilt')
-    const second = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
-    await second.load()
-
-    expect(b.fetched).toEqual([
-      APPLICATION_URL,
-      chunkUrl('a', 'client.pdf.js'),
-      comboUrl(['a'], 'rebuilt'),
-      chunkUrl('a', 'client.pdf.js', 'rebuilt'),
-    ])
-  })
 })
 
 describe('require resolution', () => {
@@ -384,46 +322,10 @@ describe('bootstrap module', () => {
 
 describe('failure modes', () => {
   it('duplicate factory registration is loud', () => {
-    const b = bench([])
+    bench([])
     win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) })
     expect(() => win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) }))
       .toThrow('duplicate factory registration for "x"')
-    b.target.load({ id: 'x', chunk: 'client.pdf.js', factory: () => ({}) })
-    expect(() => { b.target.load({ id: 'x', chunk: 'client.pdf.js', factory: () => ({}) }) }).not.toThrow()
-  })
-
-  it('rejects malformed chunk registrations and relative requests', async () => {
-    const b = bench([])
-    expect(() => { b.target.load({ id: 'a', chunk: 'client.js', factory: () => ({}) }) })
-      .toThrow('invalid package-local chunk')
-
-    const malformed = bench([row('a')], { a: req => ({ value: req('./other.js') }) })
-    await expect(malformed.loader.import('a', '', {})).rejects.toThrow('invalid relative chunk request')
-  })
-
-  it('rejects a chunk without a graph owner or registration', async () => {
-    const ownerless = bench([], {}, {
-      pending: [{
-        id: 'ghost',
-        factory: req => ({ load: () => Promise.resolve().then(() => req('./client.pdf.js')) }),
-      }],
-    })
-    const ghost = await ownerless.loader.import('ghost', '', {}) as { load: () => Promise<unknown> }
-    await expect(ghost.load()).rejects.toThrow('chunk owner "ghost" is not a boot graph entry')
-
-    const unregistered = bench([row('a')], {
-      a: req => ({ load: () => Promise.resolve().then(() => req('./client.pdf.js')) }),
-    }, { chunks: { 'a/client.pdf.js': null } })
-    const entry = await unregistered.loader.import('a', '', {}) as { load: () => Promise<unknown> }
-    await expect(entry.load()).rejects.toThrow('loaded without registering "a/client.pdf.js"')
-  })
-
-  it('rejects a chunk whose owner URL is not a one-resource combo', async () => {
-    const b = bench([row('a', { url: '/plugins/a/client.js?rev=0' })], {
-      a: req => ({ load: () => Promise.resolve().then(() => req('./client.pdf.js')) }),
-    })
-    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
-    await expect(entry.load()).rejects.toThrow('cannot resolve chunk "client.pdf.js"')
   })
 
   it('a bundle that never registers its id is loud', async () => {

+ 6 - 68
packages/client/modules/tests/node-half.client.spec.ts

@@ -1,6 +1,6 @@
 /** Node-half composition diagnostics for package metadata and built client bundles. */
 
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { SourceMap } from 'node:module'
 import { tmpdir } from 'node:os'
@@ -20,7 +20,6 @@ const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
 const comboUrl = (ids: readonly string[], rev: string): string =>
   `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
 const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
-const chunkUrl = (id: string, fileName: string, rev: string): string => `/plugins/${id}/${fileName}?rev=${rev}`
 const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
 const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
 
@@ -491,28 +490,6 @@ describe('client bundle activation', () => {
     expect(String(thrown)).not.toContain('pnpm run build')
   })
 
-  it('names a missing sibling chunk and preserves its retryable ENOENT code', () => {
-    const packageName = '@fixture/missing-chunk'
-    const clientPath = writePackage(packageName)
-    const chunkPath = join(dirname(clientPath), 'client.pdf.js')
-    mkdirSync(dirname(clientPath), { recursive: true })
-    writeFileSync(clientPath, 'module.exports = [require("./client.pdf.js"), require("./client.pdf.js")]\n')
-    writeFileSync(chunkPath, 'module.exports = {}\n')
-    const service = construct([packageName])
-    unlinkSync(chunkPath)
-
-    let thrown: unknown
-    try {
-      service.rebuilt(packageName)
-    } catch (error) {
-      thrown = error
-    }
-    expect(thrown).toMatchObject({ code: 'ENOENT', clientPath: chunkPath })
-
-    writeFileSync(chunkPath, 'module.exports = { restored: true }\n')
-    expect(service.rebuilt(packageName)).toBeTypeOf('string')
-  })
-
   it('falls back to a generated-file map when an authored map is malformed', async () => {
     const packageName = '@fixture/malformed-source-map'
     const clientPath = writePackage(packageName)
@@ -662,13 +639,13 @@ describe('client bundle activation', () => {
     writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
     const { service, route } = constructWithRoute([packageName])
     const first = service.graph().batches[0]!.url
-    const firstSize = service.artifactBaseline(packageName)!.files[0]!.size
+    const firstSize = service.artifactBaseline(packageName)!.size
 
     writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
     service.rebuilt(packageName)
     const second = service.graph().batches[0]!.url
     expect(second).not.toBe(first)
-    expect(service.artifactBaseline(packageName)!.files[0]!.size).toBeGreaterThan(firstSize)
+    expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(firstSize)
     expect((await routeRequest(route, first)).status).toBe(200)
     expect((await routeRequest(route, second)).status).toBe(200)
 
@@ -695,11 +672,9 @@ describe('client bundle activation', () => {
     const firstPath = service.clientPath(firstName)!
     const firstStat = statSync(firstPath)
     expect(service.artifactBaseline(firstName)).toEqual({
-      files: [{
-        path: firstPath,
-        mtimeMs: firstStat.mtimeMs,
-        size: firstStat.size,
-      }],
+      path: firstPath,
+      mtimeMs: firstStat.mtimeMs,
+      size: firstStat.size,
     })
     expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
   })
@@ -805,43 +780,6 @@ describe('client bundle activation', () => {
     })
   })
 
-  it('snapshots and serves package-local chunks under the entry revision', async () => {
-    const packageName = '@fixture/chunked'
-    const clientPath = writePackage(packageName)
-    const chunkPath = join(dirname(clientPath), 'client.pdf.js')
-    const sharedPath = join(dirname(clientPath), 'client.store.js')
-    mkdirSync(dirname(clientPath), { recursive: true })
-    writeFileSync(clientPath, 'const store = require("./client.store.js"); module.exports = [store, require("./client.pdf.js")]\n')
-    writeFileSync(chunkPath, 'const store = require("./client.store.js"); module.exports = { version: 1, store }\n//# sourceMappingURL=client.pdf.js.map')
-    writeFileSync(sharedPath, 'module.exports = { shared: true }\n')
-    writeFileSync(`${chunkPath}.map`, JSON.stringify({
-      version: 3,
-      names: [],
-      mappings: 'AAAA',
-      sources: ['../../../packages/client/demo/src/pdf.tsx'],
-    }))
-    const { service, route } = constructWithRoute([packageName])
-    const firstRev = service.graph().entries[0]!.rev
-    const firstUrl = chunkUrl(packageName, 'client.pdf.js', firstRev)
-    expect(service.artifactBaseline(packageName)?.files.map(file => file.path)).toEqual([clientPath, sharedPath, chunkPath])
-
-    const entry = await routeRequest(route, service.graph().entries[0]!.url)
-    expect(entry.body.indexOf(Buffer.from('shared: true'))).toBeLessThan(entry.body.indexOf(Buffer.from('module.exports = [store')))
-
-    const script = await routeRequest(route, firstUrl)
-    expect(script.status).toBe(200)
-    expect(script.body.toString('utf8')).toContain('version: 1')
-    expect(script.body.indexOf(Buffer.from('shared: true'))).toBeLessThan(script.body.indexOf(Buffer.from('version: 1')))
-    expect(script.body.toString('utf8')).toContain(`sourceMappingURL=${firstUrl.replace('.js?', '.js.map?')}`)
-    expect((await routeRequest(route, firstUrl.replace('.js?', '.js.map?'))).status).toBe(200)
-
-    writeFileSync(chunkPath, 'module.exports = { version: 2 }\n')
-    const secondRev = service.rebuilt(packageName)!
-    expect(secondRev).not.toBe(firstRev)
-    const second = await routeRequest(route, chunkUrl(packageName, 'client.pdf.js', secondRev))
-    expect(second.body.toString('utf8')).toContain('module.exports = { version: 2 }')
-  })
-
   it('applies sourceRoot before relocating absolute-looking section sources', async () => {
     const packageName = '@fixture/source-root'
     const clientPath = writePackage(packageName)