feat(client): use bounded plugin combo URLs

This commit is contained in:
imccyu committed 2026-08-25 06:43:24 +08:00
1 parent 08ed5a54a8
commit 83463aa896
25 files changed
+388 -243

No files matched your search

@@ -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: dfa9f34276f20ffa99541db1544539d693313a2f
2026-07-23-client-plugin-loading-model.zh.md: 68fe9b912c60aceb2ecea315ed0121f9f96c1ecf
2026-07-23-client-plugin-loading-model.md: 027fd07ca9a46f13807802912c6cbd9e76cd8e6e
2026-07-23-client-plugin-loading-model.zh.md: e4a6bdf3050987761475fad03b8999b0005568d4
@@ -28,7 +28,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers
The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster.
The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row arrives through the application batch; static React, Cordis, and UI library identities come from the shell seed.
The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row belongs to an application combo script; static React, Cordis, and UI library identities come from the shell seed.
### One module system, one plugin governor
@@ -38,13 +38,13 @@ The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientM
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`.
### Batched external-script arrival and source maps
### Combo external-script arrival and source maps
The Host snapshots every built plugin artifact and concatenates its factory registration into one of two same-origin classic scripts. The parser-blocking `bootstrap` batch contains the modules row; the HTML preloads the `application` batch containing every other graph row while bootstrap executes. The module system keys in-flight transport by batch URL, so concurrent row arrivals execute one application 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 Host snapshots every built plugin artifact 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 shape `/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. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX.
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 `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits an Indexed Source Map v3 whose sections embed the available plugin maps. A missing component map leaves that script range unmapped without suppressing the combined map. 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 individual URL for HMR and adds content-addressed descriptors for the two startup batches. Initial row revisions are opaque process nonces rather than content hashes; they keep an exceptional initial individual request immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer 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 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 one-resource response immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Startup combo revisions cover the combined script inputs and indexed map. 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
@@ -54,11 +54,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; batch revisions hash the generated script plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the bundle route and contributes structured index-injection rows.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading the application batch, executes the bootstrap batch as one blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Their shared application URL executes once and registers every remaining factory without materializing it. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
**Phase two — the plugin face.**
@@ -76,8 +76,8 @@ How does a rebuilt bundle become a reload signal? The hmr node half observes it
On the browser side, the driver reloads one plugin per frame, serialized:
1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's individual URL. A live factory would make the next step a no-op.
2. `prefetch` — load the individual external script and register the fresh factory, while the old fiber still serves. The initial batch never executes again.
1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's one-resource combo URL. A live factory would make the next step a no-op.
2. `prefetch` — load that one-resource external script and register the fresh factory while the old fiber still serves. The initial multi-resource script never executes again.
3. `registry.delete` — before touching the fiber. A bare fiber dispose trips the vendored Loader's self-dispose branch, which would disable the entry permanently.
4. Drain the old fiber's disposers.
5. Remove owned `<style data-plugin>` tags.
@@ -96,7 +96,7 @@ The current package inventory and build forms live in the [client shell layering
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, revision-stamped individual responses, current batches, and one previous batch generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, generated one-resource responses, current startup combo responses, and one previous startup generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half.
@@ -28,7 +28,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
[Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.zh.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row,且每个包只有一个普通 `lib/client.js` factory bundle。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他动态图 row 全部经 application 批次到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他每个动态图 row 都归属一个 application combo 脚本;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
### 一套模块系统,一个插件治理器
@@ -38,13 +38,13 @@ Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules
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 会快照每个已构建插件产物,并把其 factory registration 拼入两个同源 classic script 之一。阻塞 parser 的 `bootstrap` 批次包含 modules row;HTML 在 bootstrap 执行期间预加载包含其余全部 graph row 的 `application` 批次。模块系统按批次 URL 复用进行中的传输,因此并发 row 到达只执行一次 application 脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
Host 会快照每个已构建插件产物,并把每个调度阶段的有序 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/` 归属,依赖包路径保持原样。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 Indexed Source Map v3。缺少组件 map 时,对应脚本范围保持未映射,不会抑制整份组合 map。绝对 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 的独立 URL,并为两个启动批次增加按内容寻址的描述。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证异常情况下的初始独立请求不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。启动 combo revision 覆盖合并脚本输入与 indexed map。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
### 装载流程,端到端
@@ -54,11 +54,11 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;批次 revision 对生成的脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 bundle 路由并贡献结构化 index 注入行。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载 application 批次,以一个阻塞式 classic script 执行 bootstrap 批次,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;它们共享的 application URL 只执行一次,并登记其余全部 factory 而不物化。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
**第二阶段——插件面。**
@@ -76,8 +76,8 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
浏览器侧,驱动插件每帧重载一个插件,串行执行:
1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的独立 URL。Factory 还活着会让下一步变成 no-op。
2. `prefetch`——加载独立外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始批次不会再次执行。
1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的单资源 combo URL。Factory 还活着会让下一步变成 no-op。
2. `prefetch`——加载该单资源外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始多资源脚本不会再次执行。
3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
4. 排空旧 fiber 的各 disposer。
5. 移除名下的 `<style data-plugin>` 标签。
@@ -96,7 +96,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、带 revision 的独立响应、当前批次及上一代批次,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
2026-08-15-client-shells-and-dynamic-packages.md: 8713234735ddb53db27e17a2ea9087de665d86f7
2026-08-15-client-shells-and-dynamic-packages.zh.md: ec8a2fb358e86f4fe74db13d555b8be8432ad62e
2026-08-15-client-shells-and-dynamic-packages.md: 016314d10f55e0b590e98944ca417bae658ab56a
2026-08-15-client-shells-and-dynamic-packages.zh.md: 4e0277d1becab8467521dc21d0e5b7509d1ee993
@@ -46,12 +46,12 @@ There is no general `dsh.client.provide` alias mechanism. Dynamic rows and stati
The modules Node half injects the startup protocol into the served HTML in this order:
1. Install `window.__ModuleLoader__` in queue mode with `pendingQueue`, `load()`, and `create()`.
2. Start preloading the content-addressed application batch containing every row except modules.
3. Execute one blocking bootstrap batch containing the ordinary modules factory registration.
4. Assign `window.__DSH_BOOT__`, including both batch descriptors and every row's individual HMR URL.
2. Start preloading every content-addressed application combo URL containing the rows other than modules.
3. Execute every blocking bootstrap combo URL; these currently contain the ordinary modules factory registration.
4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
5. Execute the Vite main module.
The bootstrap batch only registers the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
@@ -79,7 +79,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
Bundle contents stay stable when an npm dependency moves between peer and development sections, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance.
The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Batch generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR still uses each row's revisioned individual artifact. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
The shell consumes built `lib/` products, so source and browser artifacts can drift until the relevant build or watcher runs. Typechecking source alone does not prove the served application uses the same code.
@@ -46,12 +46,12 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
1. 以 queue 模式安装 `window.__ModuleLoader__`,包含 `pendingQueue`、`load()` 与 `create()`。
2. 开始预加载按内容寻址的 application 批次,其中包含 modules 之外的全部 row。
3. 执行一个阻塞式 bootstrap 批次,其中包含普通的 modules factory registration。
4. 赋值 `window.__DSH_BOOT__`,其中包含两个批次描述及每个 row 的独立 HMR URL。
2. 开始预加载所有按内容寻址的 application combo URL,其中包含 modules 之外的 row。
3. 执行所有阻塞式 bootstrap combo URL;当前其中包含普通的 modules factory registration。
4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
5. 执行 Vite 主模块。
Bootstrap 批次只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
`immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
@@ -79,7 +79,7 @@ Bootstrap 批次只登记 modules factory。启动内核把原始图与外壳 se
Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。批次生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 仍使用每个 row 带 revision 的独立产物。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
+7 -6
View File
@@ -85,6 +85,9 @@ function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): s
return resolve(dirname(packagePath), relative)
}
const comboUrl = (ids: readonly string[], rev: string): string =>
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
/** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
function loadAssembledPlugins(): readonly AssembledPlugin[] {
const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer =>
@@ -103,7 +106,7 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
plugins.set(entry.name, {
id: entry.name,
bundlePath: resolveClientExport(packagePath, pkg),
url: `/plugins/${entry.name}/client.js?rev=fx`,
url: comboUrl([entry.name], 'fx'),
rev: 'fx',
...(declaration.inject === undefined ? {} : { inject: declaration.inject }),
...(declaration.external === undefined ? {} : { external: declaration.external }),
@@ -121,8 +124,6 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
const PLUGINS = loadAssembledPlugins()
const BOOTSTRAP_IDS = ['@deepseek-ai/dsh-client-modules'] as const
const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/fx/client.js'
const APPLICATION_URL = '/plugins/_batch/application/fx/client.js'
/** Build the fixture graph after applying per-scenario package exclusions. */
function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
@@ -138,13 +139,13 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
batches: [
...(bootstrapEntries.length === 0 ? [] : [{
phase: 'bootstrap' as const,
url: BOOTSTRAP_URL,
url: comboUrl(bootstrapEntries, 'fx'),
rev: 'fx',
entries: bootstrapEntries,
}]),
...(applicationEntries.length === 0 ? [] : [{
phase: 'application' as const,
url: APPLICATION_URL,
url: comboUrl(applicationEntries, 'fx'),
rev: 'fx',
entries: applicationEntries,
}]),
@@ -152,7 +153,7 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
}
}
/** Build individual and batch script 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(plugins.map(plugin => [
plugin.url,
+1 -1
View File
@@ -193,7 +193,7 @@ describe('web e2e: settings modal and General preferences', () => {
await page.keyboard.press('Escape')
// Hold the real application batch so the shell-owned loading page remains observable.
const pluginPattern = /\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/
const pluginPattern = /\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/
let releaseBundles = (): void => {}
const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
await page.route(pluginPattern, async (route) => {
+17 -10
View File
@@ -30,6 +30,8 @@ import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requir
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
const comboMapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
let out = ''
@@ -286,23 +288,28 @@ describe('dsh web keyless CLI smoke', () => {
// request when the matching script node executes; this count pins both.
page.on('request', (request) => {
const url = new URL(request.url())
if (request.resourceType() === 'script' && url.pathname.startsWith('/plugins/')) {
pluginScripts.push(url.pathname)
const resource = `${url.pathname}${url.search}`
if (request.resourceType() === 'script' && resource.startsWith('/plugins/??')) {
pluginScripts.push(resource)
}
})
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (path.startsWith('/plugins/_batch/')) {
cacheHeaders.set(path, response.headers()['cache-control'])
const url = new URL(response.url())
const resource = `${url.pathname}${url.search}`
if (resource.startsWith('/plugins/??')) {
cacheHeaders.set(resource, response.headers()['cache-control'])
}
})
await page.goto(readyUrl)
await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 })
const batchPaths = [...new Set(pluginScripts)].sort()
expect(batchPaths).toEqual([
expect.stringMatching(/^\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/),
expect.stringMatching(/^\/plugins\/_batch\/bootstrap\/[a-f\d]{12}\/client\.js$/),
])
expect(batchPaths).toHaveLength(2)
expect(batchPaths).toContainEqual(expect.stringMatching(
/^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/,
))
expect(batchPaths).toContainEqual(expect.stringMatching(
/^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/,
))
expect([...cacheHeaders.values()]).toEqual([
'public, max-age=31536000, immutable',
'public, max-age=31536000, immutable',
@@ -310,7 +317,7 @@ describe('dsh web keyless CLI smoke', () => {
for (const path of batchPaths) {
const [scriptResponse, mapResponse] = await Promise.all([
fetch(`${readyUrl}${path}`),
fetch(`${readyUrl}${path}.map`),
fetch(`${readyUrl}${comboMapUrl(path)}`),
])
expect(scriptResponse.status).toBe(200)
expect(mapResponse.status).toBe(200)
+2 -2
View File
@@ -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: 8dae05be292d8d5628c59b768d84dfd97dec1ef2
client-modules.zh.md: 3668a4a3958b3a5957f094c0f925edb120d9b8c6
client-modules.md: 770045112e1181f602da987b2c084c17c1b9fada
client-modules.zh.md: 40c6af6d2bab6156a0986324e89f46203e260af9
+12 -12
View File
@@ -2,13 +2,13 @@
English | [中文](client-modules.zh.md)
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves content-addressed startup batches and individual HMR scripts under `/plugins`, and answers every index-injection collection with the boot protocol rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves versioned one-or-more-resource combo scripts under `/plugins`, and answers every index-injection collection with the boot protocol rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## The wire
The graph is the wire single source between the Node and browser halves. The host composes `WebBootEntry` rows and `WebBootBatch` descriptors from scanned packages, then contributes the registration facade, application preload, bootstrap script, and graph global to the structured index-injection table before the Vite entry. The `global` row renders as `globalThis["__DSH_BOOT__"]` with `<` escaped so plugin-controlled strings cannot break out of the script element. A page without a valid manifest cannot boot: the browser parser rejects malformed rows or batches, duplicate phase names, unknown members, and entries without exactly one initial batch.
The graph is the wire single source between the Node and browser halves. The host composes `WebBootEntry` rows and `WebBootBatch` descriptors from scanned packages, then contributes the registration facade, application preloads, bootstrap scripts, and graph global to the structured index-injection table before the Vite entry. The `global` row renders as `globalThis["__DSH_BOOT__"]` with `<` escaped so plugin-controlled strings cannot break out of the script element. A page without a valid manifest cannot boot: the browser parser rejects malformed rows or batches, unknown members, and entries without exactly one initial combo descriptor.
```ts type-equiv
/**
@@ -22,9 +22,9 @@ The graph is the wire single source between the Node and browser halves. The hos
interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Revisioned individual endpoint used by HMR. */
/** Revisioned single-resource combo endpoint used by HMR. */
url: string
/** Opaque individual-artifact revision used for HMR cache busting. */
/** Opaque plugin-artifact revision used for HMR cache busting. */
rev: string
/** Package-name dependency edges used for factory arrival and plugin composition. */
inject?: string[]
@@ -36,18 +36,18 @@ interface WebBootEntry {
```
```ts type-equiv
/** Initial script-delivery phase for one content-addressed bundle batch. */
/** Initial scheduling phase for one content-addressed combo script. */
type WebBootBatchPhase = 'bootstrap' | 'application'
```
```ts type-equiv
/** One initial-load script containing the factory registrations for several graph rows. */
/** One initial combo script; a scheduling phase may span several descriptors. */
interface WebBootBatch {
/** Parser-blocking bootstrap or preloaded application delivery. */
/** Parser-blocking bootstrap or preloaded application scheduling. */
phase: WebBootBatchPhase
/** Content-addressed batch script endpoint. */
/** Content-addressed combo script endpoint. */
url: string
/** Hash over the batch script and indexed source map. */
/** Revision over the combined plugin script bytes and indexed source map. */
rev: string
/** Graph entry ids whose factories the script registers, in execution order. */
entries: string[]
@@ -65,12 +65,12 @@ interface WebBootGraph {
* unrelated and remains owned by fiber service waiting.
*/
entries: WebBootEntry[]
/** Initial-load batches; every entry belongs to exactly one batch. */
/** Initial combo descriptors; every entry belongs to exactly one descriptor. */
batches: WebBootBatch[]
}
```
Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every individual artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark.
Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every plugin artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and revision; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB. Startup combo revisions hash the combined plugin script bytes and indexed source map, and the graph revision hashes both rows and descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
## The scan
@@ -82,7 +82,7 @@ Package metadata — including the negative "not a client package" verdict — i
## The bundle route and index injection
`GET`/`HEAD /plugins/_batch/<phase>/<rev>/client.js` serves the generated startup scripts, with indexed maps beside them. `GET`/`HEAD /plugins/<id>/client.js?rev=<rev>` serves the snapshotted individual artifact for HMR and stamps the same revision onto its map request. All versioned responses use long-lived immutable caching. Unknown paths, absent maps, missing revisions, and stale revisions answer 404 rather than serving current bytes under an old URL 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>` serves an exact generated combo script; a one-resource request uses the same form and is the HMR path. Its absolute `sourceMappingURL` changes every resource suffix in parallel, yielding `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. The map is Indexed Source Map v3 even for one resource. It contains sections for available component maps and leaves components without maps unmapped. Every generated request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form even when a group has no map. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. All advertised responses use long-lived immutable caching. Unknown or altered resource lists, absent maps, 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
+12 -12
View File
@@ -2,13 +2,13 @@
[English](client-modules.md) | 中文
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules`(`ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins` 下提供按内容寻址的启动批次与 HMR 独立脚本,并以启动协议行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.zh.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.zh.md)中,不在本页。
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules`(`ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins` 下提供带版本的单资源或多资源 combo 脚本,并以启动协议行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.zh.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.zh.md)中,不在本页。
源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## wire
图是 Node 半与浏览器半之间协议层的唯一真源。宿主从扫描到的包组合出 `WebBootEntry` 行与 `WebBootBatch` 描述,随后在 Vite entry 之前向结构化 index 注入表贡献 registration facade、application preload、bootstrap 脚本与图全局量。`global` 行渲染为 `globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素。没有有效 manifest 的页面无法启动:浏览器解析器会拒绝畸形 row 或批次、重复 phase 名、未知成员,以及未恰好归属一个初始批次的 entry。
图是 Node 半与浏览器半之间协议层的唯一真源。宿主从扫描到的包组合出 `WebBootEntry` 行与 `WebBootBatch` 描述,随后在 Vite entry 之前向结构化 index 注入表贡献 registration facade、application preload、bootstrap 脚本与图全局量。`global` 行渲染为 `globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素。没有有效 manifest 的页面无法启动:浏览器解析器会拒绝畸形 row 或批次、未知成员,以及未恰好归属一个初始 combo 描述的 entry。
```ts type-equiv
/**
@@ -22,9 +22,9 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client
interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Revisioned individual endpoint used by HMR. */
/** Revisioned single-resource combo endpoint used by HMR. */
url: string
/** Opaque individual-artifact revision used for HMR cache busting. */
/** Opaque plugin-artifact revision used for HMR cache busting. */
rev: string
/** Package-name dependency edges used for factory arrival and plugin composition. */
inject?: string[]
@@ -36,18 +36,18 @@ interface WebBootEntry {
```
```ts type-equiv
/** Initial script-delivery phase for one content-addressed bundle batch. */
/** Initial scheduling phase for one content-addressed combo script. */
type WebBootBatchPhase = 'bootstrap' | 'application'
```
```ts type-equiv
/** One initial-load script containing the factory registrations for several graph rows. */
/** One initial combo script; a scheduling phase may span several descriptors. */
interface WebBootBatch {
/** Parser-blocking bootstrap or preloaded application delivery. */
/** Parser-blocking bootstrap or preloaded application scheduling. */
phase: WebBootBatchPhase
/** Content-addressed batch script endpoint. */
/** Content-addressed combo script endpoint. */
url: string
/** Hash over the batch script and indexed source map. */
/** Revision over the combined plugin script bytes and indexed source map. */
rev: string
/** Graph entry ids whose factories the script registers, in execution order. */
entries: string[]
@@ -65,12 +65,12 @@ interface WebBootGraph {
* unrelated and remains owned by fiber service waiting.
*/
entries: WebBootEntry[]
/** Initial-load batches; every entry belongs to exactly one batch. */
/** Initial combo descriptors; every entry belongs to exactly one descriptor. */
batches: WebBootBatch[]
}
```
每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个独立产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输。
每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个插件产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。初始描述把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条描述。URL 只含有序 package 资源列表与 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分。启动 combo revision 对合并后的插件脚本字节与 indexed sourcemap 求哈希,图 revision 则对 row 与描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
## 扫描
@@ -82,7 +82,7 @@ interface WebBootGraph {
## bundle 路由与 index 注入
`GET`/`HEAD /plugins/_batch/<phase>/<rev>/client.js` 提供生成的启动脚本,并在相邻路径提供 indexed map。`GET`/`HEAD /plugins/<id>/client.js?rev=<rev>` 为 HMR 提供已快照的独立产物,并把同一 revision 写入其 map 请求。所有版本化响应都使用长期 immutable 缓存。未知路径、缺失 map、缺少 revision 及陈旧 revision 都返回 404,绝不在旧 URL 下提供当前字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 提供精确生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。其绝对 `sourceMappingURL` 平行改写每个资源后缀,得到 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。即使只有一个资源,map 仍采用 Indexed Source Map v3;有组件 map 的资源形成 section,没有 map 的组件区间保持未映射。每条生成的请求 URL 按 UTF-8 字节计算都不超过 3 KiB;即使某组没有 map,切分仍按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。所有已发布响应都使用长期 immutable 缓存。未知或被修改的资源列表、缺失 map、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
## 服务
+2 -2
View File
@@ -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: 089b3ba35780ccb7a24bc8fed10cc0a5353c9eb9
README.zh.md: e100dde3ced0f7272e9a75bc4d0a69f6beb4d4ee
README.md: 82d203fd9e19590b93ab1f9bf8e2c0673ce1204b
README.zh.md: 8b62b4948400c0a91232f897e306792cdf3260e7
+2 -2
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The frame revision makes `invalidate` select that plugin's immutable individual URL instead of its initial batch; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, then `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row begins watching without a content read or hash; a changed row, or a dirty row after its artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. Any tsdown watch process producing the artifacts therefore triggers HMR with no builder→host channel.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The frame revision makes `invalidate` select that plugin's immutable one-resource combo URL instead of its initial multi-resource URL; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, then `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row begins watching without a content read or hash; a changed row, or a dirty row after its artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. Any tsdown watch process producing the artifacts therefore triggers HMR with no builder→host channel.
## Model Experience
@@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request.
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
- **The boot graph is not replaced by rebuilt frames** — each frame carries the individual-script revision needed for that reload; a page reload receives the recomposed batch graph.
- **The boot graph is not replaced by rebuilt frames** — each frame carries the plugin-artifact revision needed for its one-resource combo reload; a page reload receives the recomposed startup graph.
+2 -2
View File
@@ -4,7 +4,7 @@
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。帧内 revision 会让 `invalidate` 选择该插件的不可变独立 URL,而不是初始批次;`prefetch` 在旧 fiber 仍在服务时加载并登记新 factory。其余顺序是:`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载,最后通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建,以 module host 读文件前记录的基线 stat-poll 每个图 bundle 及其可选 sourcemap。未变化的启动 row 无需读取内容或求哈希即可开始监视;只有发生变化的 row,或产物恢复后的 dirty row,才会进入 `rebuilt()`,并且只广播真实 revision 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。帧内 revision 会让 `invalidate` 选择该插件不可变的单资源 combo URL,而不是初始多资源 URL;`prefetch` 在旧 fiber 仍在服务时加载并登记新 factory。其余顺序是:`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载,最后通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建,以 module host 读文件前记录的基线 stat-poll 每个图 bundle 及其可选 sourcemap。未变化的启动 row 无需读取内容或求哈希即可开始监视;只有发生变化的 row,或产物恢复后的 dirty row,才会进入 `rebuilt()`,并且只广播真实 revision 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
## 模型体验
@@ -18,4 +18,4 @@
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
- **重建帧不会替换启动图**:每个帧都会携带本次重载所需的独立脚本 revision;页面重载时才接收重新组合的批次图。
- **重建帧不会替换启动图**:每个帧都会携带单资源 combo 重载所需的插件产物 revision;页面重载时才接收重新组合的启动图。
+1 -1
View File
@@ -152,7 +152,7 @@ export function apply(ctx: Context): void {
break
case 'graph':
// Connect-time snapshot, unused. Each rebuilt frame carries the
// revision that selects the immutable individual script; the boot
// revision that selects the immutable single-resource combo script; the boot
// graph remains the initial-load record until a page reload.
break
default:
@@ -57,7 +57,7 @@ function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOption
options.beforeGraphRead?.()
return {
rev: 'r',
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/??${id}/client.js&rev=r`, rev: 'r' })),
batches: [],
}
},
+2 -2
View File
@@ -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: 2e8ed59614a30a1c737e57e5e5869bccf6bfb6db
README.zh.md: 982d4a444f691cb575b77d35283e39940b66b42a
README.md: daecf2f73e6c4d43d068e14053374d4ab0c674d9
README.zh.md: 291cea6f570fe4c73635bdbe8a6793252962863d
+4 -4
View File
@@ -6,11 +6,11 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively; graph composition places declared dynamic requests before their consumers, and require cycles throw because factory-form CJS cannot deliver partial exports. `<id>/client` and the bare id resolve to the same exports (a plugin bundle IS its package's client half).
The Host installs `window.__ModuleLoader__`, preloads the application batch, then executes the parser-blocking bootstrap batch. Queue-mode `load()` retains the modules registration; `create()` materializes this package's factory with an external-rejecting bootstrap require and calls its `createClientModuleSystem` export. Construction caches those same exports as the modules row and switches the same facade to live registration. The bundle retains the resulting system in a module closure, so its later Cordis `apply()` provides the identical instance as `ctx.modules` without another page global.
The Host installs `window.__ModuleLoader__`, preloads every application combo script, then executes the parser-blocking bootstrap combo scripts. `bootstrap` and `application` are scheduling phases, not URL path names, and either phase may span several requests. Queue-mode `load()` retains the modules registration; `create()` materializes this package's factory with an external-rejecting bootstrap require and calls its `createClientModuleSystem` export. Construction caches those same exports as the modules row and switches the same facade to live registration. The bundle retains the resulting system in a module closure, so its later Cordis `apply()` provides the identical instance as `ctx.modules` without another page global.
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its initial-batch factory; registered factory → materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous graph-row load and records observed edges into the module record. `prefetch` is the stage-one arrival hook; rows sharing a batch URL share one in-flight script task. `invalidate(id, rev)` drops a non-bootstrap factory and materialized record and switches that row to its revisioned individual script, so HMR reloads one plugin without executing the batch again.
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its initial-combo factory; registered factory → materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous graph-row load and records observed edges into the module record. `prefetch` is the stage-one arrival hook; rows sharing an initial combo URL share one in-flight script task. `invalidate(id, rev)` drops a non-bootstrap factory and materialized record and switches that row to its revisioned one-resource combo URL, so HMR reloads one plugin without executing the initial multi-resource script again.
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It emits a bootstrap batch for the modules row and an application batch for every other row. Each batch has a content-addressed script and an indexed Source Map v3 file assembled from the available plugin maps. Initial individual revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Individual revisioned scripts and maps remain available for HMR; every versioned response is immutable, and a revision mismatch returns 404 instead of serving newer bytes under an older URL. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It schedules the modules row as bootstrap and every other row as application. Both initial delivery and HMR use the same combo route: `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` for any non-empty resource list, including a one-resource HMR reload. Within each phase, graph composition greedily partitions the ordered resources before the longer map-form URL would exceed 3 KiB, so a large application uses several requests without changing the path model. A script's absolute `sourceMappingURL` mirrors its list as `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. Every generated map is Indexed Source Map v3, with sections for the available plugin maps and unmapped ranges where a component map is absent. Initial per-plugin revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Every advertised response is immutable, and an unadvertised combination or revision returns 404 instead of serving different bytes. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
`dsh.client.external` is an optional exact-specifier request list beyond the implicit baseline of shell-seeded React, Cordis, and static UI libraries. A request is answered by the dynamic package row it names or an exact static-table key; only a trailing `/client` aliases a package row, and there is no provider-alias declaration. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles; import and prefetch recursively register dynamic suppliers before their consumers materialize. See [shared modules and the module graph](../AGENTS.md#shared-modules-and-the-module-graph).
@@ -26,4 +26,4 @@ None; this package neither assembles nor sends a provider request.
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, revision-stamped individual response, and generated batch in memory; HMR additionally retains one prior batch generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, generated one-resource response, and current startup combo responses in memory; HMR additionally retains one prior startup generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
+4 -4
View File
@@ -6,11 +6,11 @@
惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它;图组合会把声明的动态请求提供方放在消费者之前,而 require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
Host 会先安装 `window.__ModuleLoader__`、预加载 application 批次,再执行阻塞 parser 的 bootstrap 批次。Queue 模式的 `load()` 保存 modules registration;`create()` 使用拒绝 external 的 bootstrap require 物化本包 factory,并调用其 `createClientModuleSystem` 导出。构造过程把同一组导出缓存为 modules row,并把同一个 facade 切换到 live registration。Bundle 通过模块闭包保留生成的系统,因此随后 Cordis `apply()` 能把同一实例提供为 `ctx.modules`,无需另一个页面全局变量。
Host 会先安装 `window.__ModuleLoader__`、预加载所有 application combo 脚本,再执行阻塞 parser 的 bootstrap combo 脚本。`bootstrap` 与 `application` 只是调度阶段,不会成为 URL 路径名,并且每个阶段都可以包含多次请求。Queue 模式的 `load()` 保存 modules registration;`create()` 使用拒绝 external 的 bootstrap require 物化本包 factory,并调用其 `createClientModuleSystem` 导出。构造过程把同一组导出缓存为 modules row,并把同一个 facade 切换到 live registration。Bundle 通过模块闭包保留生成的系统,因此随后 Cordis `apply()` 能把同一实例提供为 `ctx.modules`,无需另一个页面全局变量。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其初始批次 factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子;共享同一批次 URL 的 row 会共享一个进行中的脚本任务。`invalidate(id, rev)` 会丢弃非 bootstrap factory 与物化记录,并让该 row 改用带 revision 的独立脚本,因此 HMR(热模块替换)只重载一个插件,不会再次执行整批脚本。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其初始 combo 中的 factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子;共享同一初始 combo URL 的 row 会共享一个进行中的脚本任务。`invalidate(id, rev)` 会丢弃非 bootstrap factory 与物化记录,并让该 row 改用带 revision 的单资源 combo URL,因此 HMR(热模块替换)只重载一个插件,不会再次执行初始多资源脚本。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它为 modules row 生成 bootstrap 批次,为其余 row 生成 application 批次;每个批次都有按内容寻址的脚本,以及由现有插件 map 组合而成的 indexed Source Map v3 文件。初始独立 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。HMR 仍可访问带 revision 的独立脚本与 map;所有版本化响应都不可变,revision 不匹配时返回 404,绝不在旧 URL 下提供新字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它把 modules row 调度为 bootstrap,把其余 row 调度为 application。初始传输与 HMR 使用同一条 combo 路由:任意非空资源列表都写成 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`,HMR 单资源重载也不例外。图组合会在更长的 map 形式 URL 超过 3 KiB 前,按顺序贪心切分每个阶段的资源,因此大型 application 会产生多次请求,但路径模型不变。脚本中的绝对 `sourceMappingURL` 把各自的资源列表平行改写成 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。每个生成的 map 都是 Indexed Source Map v3:有 map 的插件形成 section,缺失组件 map 的范围保持未映射。初始逐插件 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。每个已发布响应都不可变,未发布的组合或 revision 返回 404,绝不提供其他字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
`dsh.client.external` 是外壳播种的 React、Cordis 和静态 UI 库这一统一基座之外的可选精确 specifier 请求列表。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
@@ -26,4 +26,4 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,
- **有意采用扁平模块图**:每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
- **自身不维护卸载记录**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
- **快照式提供会常驻产物字节**:Host 会在内存中保留每个 bundle、可选 sourcemap、带 revision 的独立响应及生成的批次;HMR 还会保留上一代批次。内存会随组合出的客户端产物增长为数份副本,以换取 immutable 响应和一代竞态容忍。
- **快照式提供会常驻产物字节**:Host 会在内存中保留每个 bundle、可选 sourcemap、生成的单资源响应及当前启动 combo 响应;HMR 还会保留上一代启动响应。内存会随组合出的客户端产物增长为数份副本,以换取 immutable 响应和一代竞态容忍。
+19 -19
View File
@@ -50,9 +50,9 @@ declare module '@deepseek-ai/cordis' {
export interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Revisioned individual endpoint used by HMR. */
/** Revisioned single-resource combo endpoint used by HMR. */
url: string
/** Opaque individual-artifact revision used for HMR cache busting. */
/** Opaque plugin-artifact revision used for HMR cache busting. */
rev: string
/** Package-name dependency edges used for factory arrival and plugin composition. */
inject?: string[]
@@ -62,16 +62,16 @@ export interface WebBootEntry {
external?: string[]
}
/** Initial script-delivery phase for one content-addressed bundle batch. */
/** Initial scheduling phase for one content-addressed combo script. */
export type WebBootBatchPhase = 'bootstrap' | 'application'
/** One initial-load script containing the factory registrations for several graph rows. */
/** One initial combo script; a scheduling phase may span several descriptors. */
export interface WebBootBatch {
/** Parser-blocking bootstrap or preloaded application delivery. */
/** Parser-blocking bootstrap or preloaded application scheduling. */
phase: WebBootBatchPhase
/** Content-addressed batch script endpoint. */
/** Content-addressed combo script endpoint. */
url: string
/** Hash over the batch script and indexed source map. */
/** Revision over the combined plugin script bytes and indexed source map. */
rev: string
/** Graph entry ids whose factories the script registers, in execution order. */
entries: string[]
@@ -87,7 +87,7 @@ export interface WebBootGraph {
* unrelated and remains owned by fiber service waiting.
*/
entries: WebBootEntry[]
/** Initial-load batches; every entry belongs to exactly one batch. */
/** Initial combo descriptors; every entry belongs to exactly one descriptor. */
batches: WebBootBatch[]
}
@@ -95,11 +95,11 @@ export interface WebBootGraph {
export interface BootModuleRow {
/** Entry name == package name (module-table key). */
id: string
/** Revisioned individual endpoint used after HMR invalidation. */
/** Revisioned single-resource combo endpoint used after HMR invalidation. */
url: string
/** Content-addressed batch endpoint used before the first HMR invalidation. */
/** Content-addressed combo endpoint used before the first HMR invalidation. */
initialUrl: string
/** Opaque individual-artifact revision used after HMR invalidation. */
/** Opaque plugin-artifact revision used after HMR invalidation. */
rev: string
/** Injected package rows whose factories arrive before this row materializes. */
inject: string[]
@@ -214,7 +214,7 @@ export function parseBootManifest(wire: unknown): BootManifest {
const entryIds = new Set(moduleFields.map(row => row.id))
const initialUrls = new Map<string, string>()
const phases = new Set<WebBootBatchPhase>()
const batchUrls = new Set<string>()
for (const value of graph.batches as unknown[]) {
if (typeof value !== 'object' || value === null) {
throw new Error('client-modules: boot manifest batch is not an object')
@@ -224,13 +224,13 @@ export function parseBootManifest(wire: unknown): BootManifest {
if (phase !== 'bootstrap' && phase !== 'application') {
throw new Error(`client-modules: boot manifest batch phase must be "bootstrap" or "application", received ${JSON.stringify(phase)}`)
}
if (phases.has(phase)) {
throw new Error(`client-modules: boot manifest carries duplicate "${phase}" batches`)
}
phases.add(phase)
if (typeof batch.url !== 'string' || typeof batch.rev !== 'string') {
throw new Error(`client-modules: boot manifest ${phase} batch must carry string url/rev`)
}
if (batchUrls.has(batch.url)) {
throw new Error(`client-modules: boot manifest carries duplicate batch URL ${JSON.stringify(batch.url)}`)
}
batchUrls.add(batch.url)
const entries = optionalStringArray(`boot manifest ${phase} batch`, 'entries', batch.entries)
if (entries === undefined || entries.length === 0) {
throw new Error(`client-modules: boot manifest ${phase} batch entries must be a non-empty string array`)
@@ -352,9 +352,9 @@ export interface ClientModuleLoader {
prefetch(id: string): Promise<void>
/**
* Full reset of one non-bootstrap module: drop its registered factory and
* materialized record so the next prefetch/import reloads its individual
* script rather than the initial batch. The bootstrap module remains
* materialized.
* 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.
* @param rev - New content revision from the HMR frame; omitted to reuse
* the graph revision or for page-local modules that register directly.
+5 -8
View File
@@ -28,13 +28,10 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
/** Replace the rev query while preserving absolute, protocol-relative, or path-relative form. */
function atRevision(url: string, rev: string): string {
const absolute = /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)
const protocolRelative = url.startsWith('//')
const parsed = new URL(url, 'http://dsh.invalid')
parsed.searchParams.set('rev', rev)
if (absolute) return parsed.href
if (protocolRelative) return `//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`
return `${parsed.pathname}${parsed.search}${parsed.hash}`
if (!/[?&]rev=[^&#]*/.test(url)) {
throw new Error(`client-modules: bundle URL ${url} has no revision`)
}
return url.replace(/([?&]rev=)[^&#]*/, `$1${encodeURIComponent(rev)}`)
}
/**
@@ -71,7 +68,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
private readonly bootstrapIds = new Set<string>()
/** In-flight script transport per URL; every row in one batch shares it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Individual revisioned URL selected by HMR after invalidating one row. */
/** Single-resource combo URL selected by HMR after invalidating one row. */
private readonly reloadUrls = new Map<string, string>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
+130 -78
View File
@@ -2,9 +2,9 @@
* Node half of the client module system (`dsh.client` dual-face package): scans
* the host Loader's entries for packages declaring `dsh.client`, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`) in module-graph order, serves two initial-load
* batches plus revisioned per-plugin HMR scripts and their source maps,
* contributes the registration facade, application preload, bootstrap script,
* in `./client/manifest.ts`) in module-graph order, serves one-or-more-plugin
* combo scripts plus their source maps,
* contributes the registration facade, application preloads, bootstrap scripts,
* and graph to the webserver's index injection table, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
@@ -138,21 +138,33 @@ interface WebPluginRecord {
bundle: Buffer
/** Pre-read filesystem baseline handed to the HMR watcher. */
baseline: ClientArtifactBaseline
/** Revision-stamped individual response used after HMR invalidation. */
individualBundle: Buffer
/** Optional parsed and original source map snapshot for immutable delivery. */
sourceMap?: { body: Buffer; parsed: Record<string, unknown> }
}
/** One generated initial-load response and its wire descriptor. */
interface BatchArtifact {
descriptor: WebBootBatch
/** Fields shared by every generated combo response. */
interface ComboArtifactBase {
url: string
rev: string
entries: string[]
script: Buffer
sourceMap?: Buffer
}
/** One generated combo response over an ordered list of plugin resources. */
type ComboArtifact = ComboArtifactBase & (
| { sourceMap: Buffer; sourceMapUrl: string }
| { sourceMap?: undefined; sourceMapUrl?: undefined }
)
/** One generated initial-load response and its wire descriptor. */
type BatchArtifact = ComboArtifact & { descriptor: WebBootBatch }
/** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
/** Generated request URLs stay below conservative browser and intermediary request-target limits. */
const MAX_COMBO_URL_BYTES = 3 * 1024
const HASH_REVISION_LENGTH = 12
const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
/** Source-map trailer emitted by tsdown at the end of every client bundle. */
const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
@@ -193,21 +205,63 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
}
/** sha1 content hash shortened to 12 hex chars (batch / graph / rebuilt-artifact rev). */
/** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
return createHash('sha1').update(input).digest('hex').slice(0, HASH_REVISION_LENGTH)
}
/** Hash several response fields without allowing bytes to move across field boundaries. */
function framedHash(domain: string, parts: readonly Buffer[]): string {
const hash = createHash('sha1').update(domain).update('\0')
for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part)
return hash.digest('hex').slice(0, 12)
return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
}
/** Hash every byte served after HMR observes one artifact change. */
/** Hash every artifact input served after HMR observes one plugin change. */
function artifactRevision(bundle: Buffer, sourceMap: WebPluginRecord['sourceMap']): string {
return framedHash('individual', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
return framedHash('plugin-artifact', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
}
/** Address one ordered plugin-file list through the shared combo route. */
function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
return `/plugins/??${resources}&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(
records.map(record => record.entry.id),
COMBO_REVISION_PLACEHOLDER,
true,
))
}
/** Partition one phase in graph order without allowing a generated URL above the protocol limit. */
function partitionComboRecords(records: readonly WebPluginRecord[]): WebPluginRecord[][] {
const chunks: WebPluginRecord[][] = []
let current: WebPluginRecord[] = []
for (const record of records) {
const candidate = [...current, record]
if (projectedComboUrlBytes(candidate) <= MAX_COMBO_URL_BYTES) {
current = candidate
continue
}
if (current.length === 0) {
throw new Error(
`client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
)
}
chunks.push(current)
current = [record]
if (projectedComboUrlBytes(current) > MAX_COMBO_URL_BYTES) {
throw new Error(
`client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
)
}
}
if (current.length > 0) chunks.push(current)
return chunks
}
/** Remove a bundle-local source-map trailer and retain one final newline. */
@@ -216,10 +270,9 @@ function withoutSourceMapTrailer(input: Buffer): string {
return stripped.endsWith('\n') ? stripped : `${stripped}\n`
}
/** Stamp an individual bundle's map request with the same immutable revision. */
function individualBundle(input: Buffer, rev: string, hasSourceMap: boolean): Buffer {
const source = withoutSourceMapTrailer(input)
return Buffer.from(hasSourceMap ? `${source}//# sourceMappingURL=client.js.map?rev=${rev}\n` : source)
/** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
function comboScript(input: string, sourceMapUrl?: string): Buffer {
return Buffer.from(sourceMapUrl === undefined ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`)
}
/** Parse an optional source-map artifact; missing maps do not prevent plugin execution. */
@@ -254,8 +307,8 @@ function newlineCount(value: string): number {
return count
}
/** Resolve section sources against their original per-plugin map URL before relocation into a batch. */
function batchSectionMap(record: WebPluginRecord): Record<string, unknown> {
/** Resolve section sources against their original per-plugin map URL before combo relocation. */
function comboSectionMap(record: WebPluginRecord): Record<string, unknown> {
const original = record.sourceMap?.parsed
/* v8 ignore next -- callers add sections only for records with a source map. */
if (original === undefined) throw new Error(`client-modules: source map missing for ${record.entry.id}`)
@@ -274,14 +327,14 @@ function batchSectionMap(record: WebPluginRecord): Record<string, unknown> {
return section
}
/** Concatenate factory registrations and compose their maps as indexed sections. */
function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
/** Concatenate one or more factory registrations and compose their maps as indexed sections. */
function buildCombo(records: readonly WebPluginRecord[], revision?: string): ComboArtifact {
let source = ''
const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
let line = 0
for (const record of records) {
if (record.sourceMap !== undefined) {
sections.push({ offset: { line, column: 0 }, map: batchSectionMap(record) })
sections.push({ offset: { line, column: 0 }, map: comboSectionMap(record) })
}
const bundle = `${withoutSourceMapTrailer(record.bundle)};\n`
source += bundle
@@ -290,18 +343,21 @@ function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[
const sourceMap = sections.length === 0
? undefined
: Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
if (sourceMap !== undefined) source += '//# sourceMappingURL=client.js.map\n'
const script = Buffer.from(source)
const rev = framedHash('batch', sourceMap === undefined ? [script] : [script, sourceMap])
const sourceBytes = Buffer.from(source)
const rev = revision ?? framedHash('combo', sourceMap === undefined ? [sourceBytes] : [sourceBytes, sourceMap])
const entries = records.map(record => record.entry.id)
const url = comboUrl(entries, rev)
if (sourceMap === undefined) return { url, rev, entries, script: comboScript(source) }
const sourceMapUrl = comboUrl(entries, rev, true)
return { url, rev, entries, script: comboScript(source, sourceMapUrl), sourceMap, sourceMapUrl }
}
/** Add initial-load scheduling metadata to a combo artifact. */
function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
const artifact = buildCombo(records)
return {
descriptor: {
phase,
url: `/plugins/_batch/${phase}/${rev}/client.js`,
rev,
entries: records.map(record => record.entry.id),
},
script,
...(sourceMap === undefined ? {} : { sourceMap }),
...artifact,
descriptor: { phase, url: artifact.url, rev: artifact.rev, entries: artifact.entries },
}
}
@@ -309,7 +365,7 @@ function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[
function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
return {
id,
url: `/plugins/${id}/client.js?rev=${rev}`,
url: comboUrl([id], rev),
rev,
...(fields.inject !== undefined ? { inject: fields.inject } : {}),
...(fields.immediately ? { immediately: true } : {}),
@@ -375,7 +431,8 @@ const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
* in live-registration mode. The graph global follows before the shell reads
* it.
* @param graph - the composed entry graph.
* @returns head rows in execution order: queue script, preload scripts, graph global.
* @returns head rows in execution order: queue script, application preloads,
* blocking bootstrap scripts, graph global.
*/
export function bootInjections(graph: WebBootGraph): IndexInjection[] {
const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
@@ -401,14 +458,14 @@ window.__ModuleLoader__={
}
}
})()`
const bootstrap = graph.batches.find(batch => batch.phase === 'bootstrap')
const application = graph.batches.find(batch => batch.phase === 'application')
const bootstrap = graph.batches.filter(batch => batch.phase === 'bootstrap')
const application = graph.batches.filter(batch => batch.phase === 'application')
const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
if (application !== undefined) {
rows.push({ kind: 'script-preload', src: application.url })
for (const batch of application) {
rows.push({ kind: 'script-preload', src: batch.url })
}
if (bootstrap !== undefined) {
rows.push({ kind: 'script-src', placement: 'head', src: bootstrap.url })
for (const batch of bootstrap) {
rows.push({ kind: 'script-src', placement: 'head', src: batch.url })
}
rows.push({ kind: 'global', name: '__DSH_BOOT__', value: graph })
return rows
@@ -435,6 +492,7 @@ export class ClientModuleRegistry extends Service {
private readonly resolvePkgJson: (spec: string) => string
private readonly initialRevisionNonce = randomBytes(8).toString('hex')
private nextInitialRevision = 0
private responses = new Map<string, { body: Buffer; contentType: string }>()
private batchResponses = new Map<string, { body: Buffer; contentType: string }>()
/** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
private previousBatchResponses = new Map<string, { body: Buffer; contentType: string }>()
@@ -539,7 +597,6 @@ export class ClientModuleRegistry extends Service {
if (rev === record.entry.rev) return rev
record.entry = graphRow(id, rev, record.meta)
record.bundle = bundle
record.individualBundle = individualBundle(bundle, rev, sourceMap !== undefined)
if (sourceMap === undefined) delete record.sourceMap
else record.sourceMap = sourceMap
this.composed = this.compose()
@@ -588,8 +645,12 @@ export class ClientModuleRegistry extends Service {
.map(entry => this.table.get(entry.id))
.filter((record): record is WebPluginRecord => record !== undefined)
const artifacts: BatchArtifact[] = []
if (bootstrap.length > 0) artifacts.push(buildBatch('bootstrap', bootstrap))
if (application.length > 0) artifacts.push(buildBatch('application', application))
for (const records of partitionComboRecords(bootstrap)) {
artifacts.push(buildBatch('bootstrap', records))
}
for (const records of partitionComboRecords(application)) {
artifacts.push(buildBatch('application', records))
}
const batchResponses = new Map<string, { body: Buffer; contentType: string }>()
for (const artifact of artifacts) {
@@ -598,7 +659,21 @@ export class ClientModuleRegistry extends Service {
contentType: 'text/javascript; charset=utf-8',
})
if (artifact.sourceMap !== undefined) {
batchResponses.set(`${artifact.descriptor.url}.map`, {
batchResponses.set(artifact.sourceMapUrl, {
body: artifact.sourceMap,
contentType: 'application/json; charset=utf-8',
})
}
}
const responses = new Map(batchResponses)
for (const record of this.table.values()) {
const artifact = buildCombo([record], record.entry.rev)
responses.set(artifact.url, {
body: artifact.script,
contentType: 'text/javascript; charset=utf-8',
})
if (artifact.sourceMap !== undefined) {
responses.set(artifact.sourceMapUrl, {
body: artifact.sourceMap,
contentType: 'application/json; charset=utf-8',
})
@@ -606,6 +681,7 @@ export class ClientModuleRegistry extends Service {
}
this.previousBatchResponses = this.batchResponses
this.batchResponses = batchResponses
this.responses = responses
const batches = artifacts.map(artifact => artifact.descriptor)
return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
}
@@ -737,7 +813,6 @@ export class ClientModuleRegistry extends Service {
meta,
bundle: snapshot.bundle,
baseline: snapshot.baseline,
individualBundle: individualBundle(snapshot.bundle, rev, snapshot.sourceMap !== undefined),
...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
})
return true
@@ -779,43 +854,20 @@ export class ClientModuleRegistry extends Service {
}
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
const requestUrl = new URL(req.url ?? '/', 'http://x')
const pathname = decodeURIComponent(requestUrl.pathname)
const batch = this.batchResponses.get(pathname) ?? this.previousBatchResponses.get(pathname)
if (batch !== undefined) {
const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl)
if (response !== undefined) {
res.writeHead(200, {
'content-type': batch.contentType,
'content-type': response.contentType,
'cache-control': IMMUTABLE_CACHE,
})
res.end(req.method === 'HEAD' ? undefined : batch.body)
res.end(req.method === 'HEAD' ? undefined : response.body)
return
}
// The id may contain a scope slash. Anything else under /plugins (including
// Anything else under /plugins (including unadvertised combinations and
// /plugins/events when the HMR row is absent) is an unknown resource.
const prefix = '/plugins/'
const mapSuffix = '/client.js.map'
const bundleSuffix = '/client.js'
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
const suffix = isSourceMap ? mapSuffix : bundleSuffix
const id = pathname.startsWith(prefix) && pathname.endsWith(suffix)
? pathname.slice(prefix.length, -suffix.length)
: undefined
const record = id === undefined ? undefined : this.table.get(id)
if (record === undefined || requestUrl.searchParams.get('rev') !== record.entry.rev) {
res.writeHead(404)
res.end()
return
}
const body = isSourceMap ? record.sourceMap?.body : record.individualBundle
if (body === undefined) {
res.writeHead(404)
res.end()
return
}
res.writeHead(200, {
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
'cache-control': IMMUTABLE_CACHE,
})
res.end(req.method === 'HEAD' ? undefined : body)
res.writeHead(404)
res.end()
}
}
@@ -8,8 +8,11 @@ import {
} from '../src/client/index.ts'
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/graph/client.js'
const APPLICATION_URL = '/plugins/_batch/application/graph/client.js'
const comboUrl = (ids: readonly string[], rev: string): string =>
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'bootstrap')
const APPLICATION_URL = comboUrl(['a', 'b'], 'application')
const win = globalThis as DshWindow
const bootstrapExports = { apply, createClientModuleSystem }
@@ -24,7 +27,7 @@ afterEach(() => {
const row = (id: string, fields: Partial<BootModuleRow> = {}): BootModuleRow =>
({
id,
url: `/plugins/${id}/client.js?rev=0`,
url: comboUrl([id], '0'),
initialUrl: id === MODULES_ID ? BOOTSTRAP_URL : APPLICATION_URL,
rev: '0',
inject: [],
@@ -83,8 +86,12 @@ function bench(
: url === APPLICATION_URL
? entries.filter(entry => entry.initialUrl === APPLICATION_URL).map(entry => entry.id)
: undefined
const individualId = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
for (const id of batchIds ?? (individualId === undefined ? [] : [individualId])) {
const parsed = new URL(url, 'http://dsh.invalid')
const combo = parsed.search.startsWith('??') ? parsed.search.slice(2).split('&', 1)[0] : undefined
const singleId = combo?.split(',').length === 1 && combo.endsWith('/client.js')
? combo.slice(0, -'/client.js'.length)
: undefined
for (const id of batchIds ?? (singleId === undefined ? [] : [singleId])) {
const factory = bundles[id]
if (factory != null) win.__ModuleLoader__?.load({ id, factory })
}
@@ -374,21 +381,42 @@ describe('boot manifest wire', () => {
.toThrow('client-modules: boot manifest batches must be an array')
})
it('rejects malformed and duplicate batch phases', () => {
it('rejects malformed batch phases', () => {
const entry = { id: 'a', url: '/a.js', rev: '1' }
expect(() => parseBootManifest({ rev: 'graph', entries: [entry], batches: [null] }))
.toThrow('client-modules: boot manifest batch is not an object')
expect(() => parseBootManifest({
rev: 'graph', entries: [entry], batches: [{ phase: 'idle', url: '/b.js', rev: 'b', entries: ['a'] }],
})).toThrow('boot manifest batch phase must be "bootstrap" or "application"')
})
it('rejects duplicate batch URLs', () => {
expect(() => parseBootManifest({
rev: 'graph',
entries: [entry],
entries: [
{ id: 'a', url: '/a.js', rev: '1' },
{ id: 'b', url: '/b.js', rev: '2' },
],
batches: [
{ phase: 'application', url: '/combo.js', rev: '1', entries: ['a'] },
{ phase: 'application', url: '/combo.js', rev: '2', entries: ['b'] },
],
})).toThrow('boot manifest carries duplicate batch URL "/combo.js"')
})
it('allows several batches in one scheduling phase', () => {
const manifest = parseBootManifest({
rev: 'graph',
entries: [
{ id: 'a', url: '/a.js', rev: '1' },
{ id: 'b', url: '/b.js', rev: '2' },
],
batches: [
{ phase: 'application', url: '/b.js', rev: '1', entries: ['a'] },
{ phase: 'application', url: '/c.js', rev: '2', entries: ['a'] },
{ phase: 'application', url: '/c.js', rev: '2', entries: ['b'] },
],
})).toThrow('boot manifest carries duplicate "application" batches')
})
expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/b.js', '/c.js'])
})
it('requires complete batch fields and non-empty entries', () => {
@@ -436,37 +464,37 @@ describe('HMR reset', () => {
expect(b.loader.loadCache.has('a')).toBe(false)
await b.loader.prefetch('a')
const second = await b.loader.import('a', '', {})
expect(b.fetched).toEqual([APPLICATION_URL, '/plugins/a/client.js?rev=1'])
expect(b.fetched).toEqual([APPLICATION_URL, comboUrl(['a'], '1')])
expect((first as { generation: number }).generation).toBe(1)
expect((second as { generation: number }).generation).toBe(2)
})
it('preserves an absolute individual endpoint when applying the rebuilt revision', async () => {
it('preserves an absolute combo endpoint when applying the rebuilt revision', async () => {
const b = bench([
row('a', { url: 'https://plugins.example.test/plugins/a/client.js?rev=0' }),
row('a', { url: 'https://plugins.example.test/plugins/??a/client.js&rev=0' }),
], { a: () => ({}) })
await b.loader.import('a', '', {})
b.loader.invalidate('a', 'next')
await b.loader.prefetch('a')
expect(b.fetched.at(-1)).toBe('https://plugins.example.test/plugins/a/client.js?rev=next')
expect(b.fetched.at(-1)).toBe('https://plugins.example.test/plugins/??a/client.js&rev=next')
})
it('preserves a protocol-relative individual endpoint when applying the rebuilt revision', async () => {
it('preserves a protocol-relative combo endpoint when applying the rebuilt revision', async () => {
const b = bench([
row('a', { url: '//plugins.example.test/plugins/a/client.js?rev=0' }),
row('a', { url: '//plugins.example.test/plugins/??a/client.js&rev=0' }),
], { a: () => ({}) })
await b.loader.import('a', '', {})
b.loader.invalidate('a', 'next')
await b.loader.prefetch('a')
expect(b.fetched.at(-1)).toBe('//plugins.example.test/plugins/a/client.js?rev=next')
expect(b.fetched.at(-1)).toBe('//plugins.example.test/plugins/??a/client.js&rev=next')
})
it('uses the current individual revision when a graph-row invalidation omits an override', async () => {
it('uses the current plugin revision when a graph-row invalidation omits an override', async () => {
const b = bench([row('a')], { a: () => ({}) })
await b.loader.import('a', '', {})
b.loader.invalidate('a')
await b.loader.prefetch('a')
expect(b.fetched).toEqual([APPLICATION_URL, '/plugins/a/client.js?rev=0'])
expect(b.fetched).toEqual([APPLICATION_URL, comboUrl(['a'], '0')])
})
})
@@ -16,8 +16,12 @@ import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../sr
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/boot/client.js'
const APPLICATION_URL = '/plugins/_batch/application/app/client.js'
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 BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
let root: string | undefined
@@ -125,8 +129,8 @@ function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModu
const bootGraph = (): WebBootGraph => ({
rev: 'graph',
entries: [
{ id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' },
{ id: UI_RENDERER_ID, url: '/plugins/ui-renderer.js?rev=r', rev: 'r' },
{ id: MODULES_ID, url: comboUrl([MODULES_ID], 'm'), rev: 'm' },
{ id: UI_RENDERER_ID, url: comboUrl([UI_RENDERER_ID], 'r'), rev: 'r' },
],
batches: [
{
@@ -150,9 +154,9 @@ describe('HTML bootstrap facade', () => {
const { html, target } = injectedFacade(graph)
const facadeAt = html.indexOf('window.__ModuleLoader__=')
const applicationAt = html.indexOf(
`<link rel="preload" as="script" href="${APPLICATION_URL}">`,
`<link rel="preload" as="script" href="${APPLICATION_URL.replaceAll('&', '&amp;')}">`,
)
const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL}"></script>`)
const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL.replaceAll('&', '&amp;')}"></script>`)
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
@@ -178,6 +182,16 @@ describe('HTML bootstrap facade', () => {
.toThrow('create called after module-system boot')
})
it('preloads every application combo', () => {
const graph = bootGraph()
const secondId = '@fixture/second-application-combo'
const secondUrl = comboUrl([secondId], 'app-2')
graph.entries.push({ id: secondId, url: comboUrl([secondId], 'row-2'), rev: 'row-2' })
graph.batches.push({ phase: 'application', url: secondUrl, rev: 'app-2', entries: [secondId] })
expect(bootInjections(graph).flatMap(row => row.kind === 'script-preload' ? [row.src] : []))
.toEqual([APPLICATION_URL, secondUrl])
})
it('rejects a page that did not preload the modules bundle', () => {
const graph = bootGraph()
const { target } = injectedFacade(graph)
@@ -268,7 +282,7 @@ describe('client bundle activation', () => {
const tornRow = torn.service.graph().entries[0]!
expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
.not.toContain('sourceMappingURL')
expect((await routeRequest(torn.route, `${torn.service.graph().batches[0]!.url}.map`)).status).toBe(404)
expect((await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))).status).toBe(404)
writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
expect(() => construct([packageName])).not.toThrow()
@@ -323,6 +337,39 @@ describe('client bundle activation', () => {
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
})
it('splits startup combos before the map-form URL exceeds 3 KiB', async () => {
const packageNames = Array.from({ length: 48 }, (_, index) => (
`@fixture/combo-url-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`
))
const sourceMap = JSON.stringify({
version: 3,
names: [],
mappings: 'AAAA',
sources: ['src/index.ts'],
})
for (const packageName of packageNames) {
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
writeFileSync(`${clientPath}.map`, sourceMap)
}
const { service, route } = constructWithRoute(packageNames)
const batches = service.graph().batches.filter(batch => batch.phase === 'application')
expect(batches.length).toBeGreaterThan(1)
expect(batches.flatMap(batch => batch.entries)).toEqual(packageNames)
for (const batch of batches) {
expect(Buffer.byteLength(batch.url)).toBeLessThanOrEqual(3 * 1024)
expect(Buffer.byteLength(mapUrl(batch.url))).toBeLessThanOrEqual(3 * 1024)
expect((await routeRequest(route, batch.url)).status).toBe(200)
expect((await routeRequest(route, mapUrl(batch.url))).status).toBe(200)
}
for (let index = 0; index < batches.length - 1; index += 1) {
const entries = [...batches[index]!.entries, batches[index + 1]!.entries[0]!]
expect(Buffer.byteLength(mapUrl(comboUrl(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
}
})
it('serves the source map beside a registered client bundle', async () => {
const packageName = '@fixture/source-map'
const clientPath = writePackage(packageName)
@@ -332,35 +379,44 @@ describe('client bundle activation', () => {
writeFileSync(`${clientPath}.map`, map)
const { service, route } = constructWithRoute([packageName])
const row = service.graph().entries[0]!
const individualScript = await routeRequest(route, row.url)
expect(individualScript.body.toString('utf8')).toContain(`sourceMappingURL=client.js.map?rev=${row.rev}`)
const individual = await routeRequest(route, row.url.replace('/client.js?', '/client.js.map?'))
expect(individual.status).toBe(200)
expect(individual.headers).toEqual({
const singleScript = await routeRequest(route, row.url)
expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapUrl(row.url)}`)
const singleMap = await routeRequest(route, mapUrl(row.url))
expect(singleMap.status).toBe(200)
expect(singleMap.headers).toEqual({
'content-type': 'application/json; charset=utf-8',
'cache-control': 'public, max-age=31536000, immutable',
})
expect(individual.body.toString('utf8')).toBe(map)
expect(JSON.parse(singleMap.body.toString('utf8'))).toMatchObject({
version: 3,
file: 'client.js',
sections: [{
offset: { line: 0, column: 0 },
map: {
...(JSON.parse(map) as Record<string, unknown>),
sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
},
}],
})
const batch = service.graph().batches[0]!
expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
const batchScript = await routeRequest(route, batch.url)
expect(batchScript.status).toBe(200)
expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
expect(batchScript.body.toString('utf8')).toContain('//# sourceMappingURL=client.js.map')
expect(batchScript.body.toString('utf8')).not.toContain('sourceMappingURL=client.js.map?rev=')
expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0)
expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405)
const batchMap = await routeRequest(route, `${batch.url}.map`)
const batchMap = await routeRequest(route, mapUrl(batch.url))
const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown
const parsedIndividualMap = JSON.parse(map) as Record<string, unknown>
const parsedPluginMap = JSON.parse(map) as Record<string, unknown>
expect(parsedBatchMap).toMatchObject({
version: 3,
file: 'client.js',
sections: [{
offset: { line: 0, column: 0 },
map: {
...parsedIndividualMap,
...parsedPluginMap,
sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
},
}],
@@ -370,8 +426,12 @@ describe('client bundle activation', () => {
writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
const nextRev = service.rebuilt(packageName)
expect(nextRev).not.toBe(row.rev)
const nextMap = await routeRequest(route, `/plugins/${packageName}/client.js.map?rev=${String(nextRev)}`)
expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({ sources: ['src/changed.tsx'] })
const nextRow = service.graph().entries[0]!
expect(nextRow.rev).toBe(nextRev)
const nextMap = await routeRequest(route, mapUrl(nextRow.url))
expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({
sections: [{ map: { sources: ['/plugins/@fixture/source-map/src/changed.tsx'] } }],
})
})
it('applies sourceRoot before relocating absolute-looking section sources', async () => {
@@ -387,7 +447,7 @@ describe('client bundle activation', () => {
sources: ['/absolute.ts'],
}))
const { service, route } = constructWithRoute([packageName])
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
const map = JSON.parse(response.body.toString('utf8')) as {
sections: { map: { sourceRoot?: string; sources: string[] } }[]
}
@@ -417,7 +477,7 @@ describe('client bundle activation', () => {
}))
}
const { service, route } = constructWithRoute([firstName, secondName])
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
const sections = (payload as unknown as {
sections: { offset: { line: number; column: number } }[]
@@ -449,7 +509,7 @@ describe('client bundle activation', () => {
}))
const { service, route } = constructWithRoute([unmappedName, mappedName])
const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
const consumer = new SourceMap(payload)
expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
@@ -462,7 +522,7 @@ describe('shared module declarations', () => {
writeBuiltPackage(packageName, { external: ['react'] })
expect(construct([packageName]).graph().entries).toEqual([{
id: packageName,
url: expect.stringContaining(`/plugins/${packageName}/client.js?rev=`) as unknown as string,
url: expect.stringContaining(`/plugins/??${packageName}/client.js&rev=`) as unknown as string,
rev: expect.any(String) as unknown as string,
external: ['react'],
}])
@@ -485,7 +545,7 @@ describe('shared module declarations', () => {
describe('module graph order', () => {
const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', ...fields })
({ id, url: comboUrl([id], '0'), rev: '0', ...fields })
const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
it('places every requested package row before its consumers along a chain', () => {