@deepseek-ai/dsh-client-modules 0.1.6-alpha.2 → 0.1.7-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/modules/README.md
5
- README.md: 5b751268430b716e7a0af24f8e25aaf2f865d079
6
- README.zh.md: 4152d64ca9dde60ab1aeb76c98508d8d86fcf54e
5
+ README.md: 3388eb2ae421b303df6709f0bfc656fe67e3a866
6
+ README.zh.md: 3cbc600ee5ed211640070773efb72f58b91d4669
package/README.md CHANGED
@@ -35,7 +35,7 @@ A browser plugin package declares `dsh.client` in its `package.json` with `platf
35
35
 
36
36
  ### What the browser loads
37
37
 
38
- The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client.<name>.js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
38
+ The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client.<name>.js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
39
39
 
40
40
  ### Live plugin composition
41
41
 
@@ -71,7 +71,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
71
71
 
72
72
  The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
73
73
 
74
- The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. A script body is combined once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads and caches that script, and its map remains uncomputed until the map URL is requested. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces. During development, the shared preset stamps `client.js` after every package output is written; HMR derives the next revision from the entry bytes and that completed-build stamp, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
74
+ The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads and caches that script, and its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
75
75
 
76
76
  ### Boot manifest injection
77
77
 
@@ -127,6 +127,7 @@ None; this package neither assembles nor sends a provider request.
127
127
 
128
128
  These limits define what the module system does not do. They are current package constraints, not a task backlog.
129
129
 
130
+ - **Metadata-based revisions** — revisions identify filesystem generations, not content equality. Metadata-only changes can reload a plugin; changes invisible in mtime, ctime, and size cannot be distinguished.
130
131
  - **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.
131
132
  - **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
132
133
  - **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
package/README.zh.md CHANGED
@@ -35,7 +35,7 @@ kind: "package-reference"
35
35
 
36
36
  ### 浏览器加载什么
37
37
 
38
- application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
38
+ application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。`<script>` 加载失败的 combo 会再请求一次;加载成功但没有注册某条 row 的 combo 绝不会重新执行,因为批量脚本按顺序注册各个包,重放会在第一个重复注册处停止。两种情况下,每条仍缺失的 row 随后加载自己的单资源 combo URL,因此一个失败的 batch 对每条缺失 row 最多花费三次请求,且不影响它已经注册的 row。模块系统按 row 记录最后一次 import 失败(传输、注册、依赖级联或 factory 执行);Web 启动审计按条目报告该文本。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
39
39
 
40
40
  ### 插件动态组合
41
41
 
@@ -71,7 +71,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
71
71
 
72
72
  Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
73
73
 
74
- Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只组合一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会读取并缓存该脚本,其 map 仍会等到 map URL 被请求后才计算。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce。开发期间,共享预设会在该包所有输出写完后标记 `client.js`;HMR 从入口字节和该构建完成标记派生下一 revision,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
74
+ Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会读取并缓存该脚本,其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。首次发布与 HMR 都从入口的 `mtimeMs`、`ctimeMs` 和大小派生逐插件 revision,不对产物内容求哈希。状态变更时间用于区分保留 mtime 和大小的重写。未变化的产物因此会跨 Host 重启保持 revision,SSE 重连不会替换其浏览器插件。共享预设会在该包所有输出写完后标记 `client.js`,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
75
75
 
76
76
  ### 启动 manifest 注入
77
77
 
@@ -127,6 +127,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
127
127
 
128
128
  这些限制说明模块系统不做什么。它们是当前包约束,不是任务积压。
129
129
 
130
+ - **基于元数据的 revision**——revision 标识文件系统代际,而非内容相等性。仅元数据变化也可能重载插件;mtime、ctime 和大小都无法反映的变化无法区分。
130
131
  - **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
131
132
  - **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步。
132
133
  - **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
package/lib/client.js CHANGED
@@ -468,6 +468,10 @@ window.__ModuleLoader__.load({
468
468
  return url.replace(/([?&]rev=)[^&#]*/, `$1${encodeURIComponent(rev)}`);
469
469
  }
470
470
  const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/;
471
+ /** The message of a thrown value: an Error's message, anything else stringified. */
472
+ function describeError(error) {
473
+ return error instanceof Error ? error.message : String(error);
474
+ }
471
475
  /** Internal module-table key for one package-local chunk. */
472
476
  function chunkId(ownerId, fileName) {
473
477
  return `${ownerId}/${fileName}`;
@@ -517,6 +521,12 @@ window.__ModuleLoader__.load({
517
521
  materializing = /* @__PURE__ */ new Set();
518
522
  graphRows = /* @__PURE__ */ new Map();
519
523
  loadBundle;
524
+ /** Last import or prefetch failure per graph row, cleared by a later success or invalidation. */
525
+ importErrors = /* @__PURE__ */ new Map();
526
+ /** Batch URLs whose transport or execution already failed; rows still missing from them go straight to their one-resource URL. */
527
+ failedBundleUrls = /* @__PURE__ */ new Set();
528
+ /** Every URL whose script has executed once; a batch among them is never requested again. */
529
+ executedBundleUrls = /* @__PURE__ */ new Set();
520
530
  /**
521
531
  * Build the module system over the parsed boot rows.
522
532
  * @param options - Parsed graph, platform seed, bootstrap module, registration facade, and transport.
@@ -569,23 +579,66 @@ window.__ModuleLoader__.load({
569
579
  rev: this.reloadTargets.get(ownerId)?.rev ?? this.graphRows.get(ownerId)?.rev
570
580
  });
571
581
  }
572
- /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
573
- arrive(row) {
574
- const { id } = row;
575
- if (this.loadCache.has(id) || this.factories.has(id)) return Promise.resolve();
576
- const reload = this.reloadTargets.get(id);
577
- const url = reload?.url ?? row.initialUrl;
582
+ /** Run one bundle transport per URL; every row waiting on the same URL shares the in-flight request. */
583
+ loadShared(url) {
578
584
  let transport = this.pendingArrival.get(url);
579
585
  if (transport === void 0) {
580
- transport = this.loadBundle(url).finally(() => {
586
+ transport = this.loadBundle(url).then(() => {
587
+ this.executedBundleUrls.add(url);
588
+ }).finally(() => {
581
589
  this.pendingArrival.delete(url);
582
590
  });
583
591
  this.pendingArrival.set(url, transport);
584
592
  }
585
- return transport.then(() => {
586
- if (!this.factories.has(id)) throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`);
587
- if (reload !== void 0 && this.reloadTargets.get(id) === reload) this.reloadTargets.delete(id);
588
- });
593
+ return transport;
594
+ }
595
+ /**
596
+ * Load one graph row so its factory is registered (idempotent per in-flight
597
+ * arrival). A batch script is one classic script that registers every
598
+ * package in sequence, and {@link register} rejects a second registration,
599
+ * so the two failure kinds differ: a transport failure (`error` event, nothing
600
+ * executed) is retried once on the same URL; a script that loaded without
601
+ * registering this row (a parse error registered nothing, or a runtime throw
602
+ * stopped it after registering others) is never re-executed, because a replay
603
+ * would stop again at the first duplicate registration; that holds even when
604
+ * the row that first imports from the batch is one it did register, because
605
+ * every executed batch URL is remembered. Either way the row then falls back
606
+ * to its own one-resource URL, which the Host serves for every package, so one
607
+ * failed batch costs at most three requests per missing row and never fails
608
+ * the rows that were registered.
609
+ */
610
+ async arrive(row) {
611
+ const { id } = row;
612
+ if (this.loadCache.has(id) || this.factories.has(id)) return;
613
+ const reload = this.reloadTargets.get(id);
614
+ const preferred = reload?.url ?? row.initialUrl;
615
+ const fallback = reload === void 0 && row.url !== preferred ? row.url : void 0;
616
+ const failures = [];
617
+ const attempt = async (url) => {
618
+ try {
619
+ await this.loadShared(url);
620
+ } catch (error) {
621
+ failures.push(`${url}: ${describeError(error)}`);
622
+ return "transport-failed";
623
+ }
624
+ if (this.factories.has(id)) return "registered";
625
+ failures.push(`${url}: loaded without registering "${id}" via __ModuleLoader__.load`);
626
+ return "not-registered";
627
+ };
628
+ let outcome = "transport-failed";
629
+ if (this.failedBundleUrls.has(preferred)) failures.push(`${preferred}: skipped after an earlier failure of this bundle`);
630
+ else if (fallback !== void 0 && this.executedBundleUrls.has(preferred)) {
631
+ failures.push(`${preferred}: already executed without registering "${id}"`);
632
+ outcome = "not-registered";
633
+ this.failedBundleUrls.add(preferred);
634
+ } else {
635
+ outcome = await attempt(preferred);
636
+ if (outcome === "transport-failed") outcome = await attempt(preferred);
637
+ if (outcome !== "registered" && fallback !== void 0) this.failedBundleUrls.add(preferred);
638
+ }
639
+ if (outcome !== "registered" && fallback !== void 0) outcome = await attempt(fallback);
640
+ if (outcome !== "registered") throw new Error(`client-modules: could not load "${id}": ${failures.join("; ")}`);
641
+ if (reload !== void 0 && this.reloadTargets.get(id) === reload) this.reloadTargets.delete(id);
589
642
  }
590
643
  /** Register each injected package and unresolved dynamic request before its consumer. */
591
644
  async arriveGraphRow(row, open = [], visited = /* @__PURE__ */ new Set()) {
@@ -598,14 +651,22 @@ window.__ModuleLoader__.load({
598
651
  const id = stripClientSuffix(request);
599
652
  if (this.seed.has(request) || this.loadCache.has(id)) continue;
600
653
  const dependency = this.graphRows.get(id);
601
- if (dependency !== void 0) await this.arriveGraphRow(dependency, next, visited);
654
+ if (dependency !== void 0) await this.arriveDependency(row.id, dependency, next, visited);
602
655
  }
603
656
  for (const packageName of row.inject) {
604
657
  const dependency = this.graphRows.get(packageName);
605
- if (dependency !== void 0) await this.arriveGraphRow(dependency, [], visited);
658
+ if (dependency !== void 0) await this.arriveDependency(row.id, dependency, [], visited);
606
659
  }
607
660
  await this.arrive(row);
608
661
  }
662
+ /** Arrive one dependency, naming the consumer it failed for so a cascade reads as a chain, not as 44 unrelated failures. */
663
+ async arriveDependency(consumerId, dependency, open, visited) {
664
+ try {
665
+ await this.arriveGraphRow(dependency, open, visited);
666
+ } catch (error) {
667
+ throw new Error(`client-modules: "${consumerId}" not loaded because dependency "${dependency.id}" failed: ${describeError(error)}`, { cause: error });
668
+ }
669
+ }
609
670
  /** Materialize a registered factory (synchronous; memoized in loadCache). */
610
671
  materialize(id, ownerId = id) {
611
672
  const existing = this.loadCache.get(id);
@@ -685,16 +746,40 @@ window.__ModuleLoader__.load({
685
746
  const existing = this.loadCache.get(id);
686
747
  if (existing !== void 0) return existing.exports;
687
748
  const row = this.graphRows.get(id);
688
- if (row !== void 0) await this.arriveGraphRow(row);
689
- else if (!this.factories.has(id)) throw new Error(`client-modules: cannot resolve "${specifier}" — not a seed word, not a materialized module, and not a row in the boot graph (the runtime mirror of the bundle purity gate)`);
690
- return this.materialize(id).exports;
749
+ if (row === void 0) {
750
+ if (this.factories.has(id)) return this.materialize(id).exports;
751
+ throw new Error(`client-modules: cannot resolve "${specifier}" — not a seed word, not a materialized module, and not a row in the boot graph (the runtime mirror of the bundle purity gate)`);
752
+ }
753
+ return this.recordingImportError(id, async () => {
754
+ await this.arriveGraphRow(row);
755
+ return this.materialize(id).exports;
756
+ });
691
757
  }
692
758
  async prefetch(id) {
693
759
  const normalized = stripClientSuffix(id);
694
760
  if (this.loadCache.has(normalized)) return;
695
761
  const row = this.graphRows.get(normalized);
696
762
  if (row === void 0) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`);
697
- await this.arriveGraphRow(row);
763
+ await this.recordingImportError(normalized, () => this.arriveGraphRow(row));
764
+ }
765
+ importError(id) {
766
+ return this.importErrors.get(stripClientSuffix(id));
767
+ }
768
+ /**
769
+ * Run one graph-row operation, recording its failure for the boot audit and
770
+ * clearing the record on success. Arrival and materialization both run in
771
+ * here, so a factory that throws is recorded as well as a bundle that never
772
+ * arrived; the Loader only sees a missing fiber either way.
773
+ */
774
+ async recordingImportError(id, operation) {
775
+ try {
776
+ const result = await operation();
777
+ this.importErrors.delete(id);
778
+ return result;
779
+ } catch (error) {
780
+ this.importErrors.set(id, error instanceof Error ? error : new Error(describeError(error)));
781
+ throw error;
782
+ }
698
783
  }
699
784
  /** Refresh descriptors and unowned factory revisions before any entry imports its dependencies. */
700
785
  updateManifest(manifest, managed) {
@@ -739,6 +824,7 @@ window.__ModuleLoader__.load({
739
824
  invalidate(id, rev) {
740
825
  const normalized = stripClientSuffix(id);
741
826
  if (this.bootstrapIds.has(normalized)) return;
827
+ this.importErrors.delete(normalized);
742
828
  this.generations.set(normalized, (this.generations.get(normalized) ?? 0) + 1);
743
829
  const row = this.graphRows.get(normalized);
744
830
  if (row !== void 0) {
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { createHash, randomBytes } from "node:crypto";
2
+ import { createHash } from "node:crypto";
3
3
  import { existsSync, readFileSync, statSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -179,31 +179,58 @@ function clientExportOf(pkgName, exportsField) {
179
179
  }
180
180
  throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`);
181
181
  }
182
- /** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
182
+ /** sha1 metadata hash shortened to 12 hex chars. */
183
183
  function shortHash(input) {
184
184
  return createHash("sha1").update(input).digest("hex").slice(0, HASH_REVISION_LENGTH);
185
185
  }
186
186
  /** Hash several response fields without allowing bytes to move across field boundaries. */
187
187
  function framedHash(domain, parts) {
188
188
  const hash = createHash("sha1").update(domain).update("\0");
189
- for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part);
189
+ for (const part of parts) hash.update(`${String(Buffer.byteLength(part))}:`).update(part);
190
190
  return hash.digest("hex").slice(0, HASH_REVISION_LENGTH);
191
191
  }
192
- /** Hash one completed build generation observed through its entry artifact. */
193
- function artifactRevision(bundle, baseline) {
194
- return framedHash("plugin-artifact", [bundle, Buffer.from(String(baseline.mtimeMs))]);
192
+ /** Identify an entry's build from filesystem metadata without hashing its contents. */
193
+ function artifactRevision(baseline) {
194
+ return framedHash("plugin-artifact", [
195
+ String(baseline.mtimeMs),
196
+ String(baseline.ctimeMs),
197
+ String(baseline.size)
198
+ ]);
195
199
  }
196
- /** Address one ordered plugin-file list through the shared combo route. */
200
+ /** Absolute route prefix serving every plugin resource. */
201
+ const PLUGIN_ROUTE = "/plugins";
202
+ /** Combo query addressing one ordered plugin-file list. */
203
+ function comboSearch(ids, rev, sourceMap = false) {
204
+ return `??${ids.map((id) => `${id}/client.js${sourceMap ? ".map" : ""}`).join(",")}&rev=${rev}`;
205
+ }
206
+ /** Absolute route URL for one combo resource. */
197
207
  function comboUrl(ids, rev, sourceMap = false) {
198
- return `/plugins/??${ids.map((id) => `${id}/client.js${sourceMap ? ".map" : ""}`).join(",")}&rev=${rev}`;
208
+ return `${PLUGIN_ROUTE}/${comboSearch(ids, rev, sourceMap)}`;
199
209
  }
200
- /** Address one package-local chunk through the same revision as its entry. */
210
+ /**
211
+ * Browser reference to one combo resource: app-owned browser routes are
212
+ * document-relative, so the route key's leading slash is stripped here, at the
213
+ * boundary between the two halves. The rule and its reasons are owned by
214
+ * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
215
+ */
216
+ function comboReference(ids, rev, sourceMap = false) {
217
+ return comboUrl(ids, rev, sourceMap).slice(1);
218
+ }
219
+ /** Absolute route URL for one package-local chunk. */
201
220
  function chunkUrl(id, fileName, rev, sourceMap = false) {
202
- return `/plugins/${id}/${fileName}${sourceMap ? ".map" : ""}?rev=${rev}`;
221
+ return `${PLUGIN_ROUTE}/${id}/${fileName}${sourceMap ? ".map" : ""}?rev=${rev}`;
222
+ }
223
+ /**
224
+ * Source-map reference stamped into one chunk script. A script's map reference
225
+ * resolves against that script's own directory rather than the document, so
226
+ * this is the bare map file name, not the document-relative route.
227
+ */
228
+ function chunkMapReference(fileName, rev) {
229
+ return `${fileName}.map?rev=${rev}`;
203
230
  }
204
- /** Measure the longer map-form URL used to partition a startup resource list. */
231
+ /** Measure the longest browser-facing combo URL used to partition a startup resource list. */
205
232
  function projectedComboUrlBytes(records) {
206
- return Buffer.byteLength(comboUrl(records.map((record) => record.entry.id), COMBO_REVISION_PLACEHOLDER, true));
233
+ return Buffer.byteLength(comboReference(records.map((record) => record.entry.id), COMBO_REVISION_PLACEHOLDER, true));
207
234
  }
208
235
  /** Partition one phase in graph order without allowing a generated URL above the protocol limit. */
209
236
  function partitionComboRecords(records) {
@@ -235,7 +262,7 @@ function prepareSource(resource) {
235
262
  fallbackSource
236
263
  };
237
264
  }
238
- /** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
265
+ /** Stamp a combo script's source-map reference onto its executable bytes. */
239
266
  function comboScript(input, sourceMapUrl) {
240
267
  return Buffer.from(sourceMapUrl === void 0 ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`);
241
268
  }
@@ -297,7 +324,7 @@ function lazyBody(produce) {
297
324
  }
298
325
  /** Derive one combo revision from the ordered immutable row revisions. */
299
326
  function comboRevision(resources) {
300
- return framedHash("combo", resources.flatMap((resource) => [Buffer.from(resource.id), Buffer.from(resource.rev)]));
327
+ return framedHash("combo", resources.flatMap((resource) => [resource.id, resource.rev]));
301
328
  }
302
329
  /** Concatenate one or more factory registrations without reading or composing source maps. */
303
330
  function buildComboScript(resources, sourceMapUrl) {
@@ -342,14 +369,12 @@ function buildCombo(records, sourceMapOf, revision) {
342
369
  }));
343
370
  const rev = revision ?? comboRevision(resources);
344
371
  const entries = resources.map((resource) => resource.id);
345
- const url = comboUrl(entries, rev);
346
- const sourceMapUrl = comboUrl(entries, rev, true);
347
372
  return {
348
- url,
373
+ url: comboUrl(entries, rev),
349
374
  rev,
350
375
  entries,
351
- sourceMapUrl,
352
- scriptBody: lazyBody(() => buildComboScript(resources, sourceMapUrl)),
376
+ sourceMapUrl: comboUrl(entries, rev, true),
377
+ scriptBody: lazyBody(() => buildComboScript(resources, comboSearch(entries, rev, true))),
353
378
  sourceMapBody: lazyBody(() => buildComboSourceMap(resources, sourceMapOf))
354
379
  };
355
380
  }
@@ -360,17 +385,17 @@ function buildBatch(phase, records, sourceMapOf) {
360
385
  ...artifact,
361
386
  descriptor: {
362
387
  phase,
363
- url: artifact.url,
388
+ url: artifact.url.slice(1),
364
389
  rev: artifact.rev,
365
390
  entries: artifact.entries
366
391
  }
367
392
  };
368
393
  }
369
- /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
394
+ /** Graph row for one bundle rev (the reference carries the rev as its cache-busting query). */
370
395
  function graphRow(id, rev, fields) {
371
396
  return {
372
397
  id,
373
- url: comboUrl([id], rev),
398
+ url: comboReference([id], rev),
374
399
  rev,
375
400
  ...fields.inject !== void 0 ? { inject: fields.inject } : {},
376
401
  ...fields.immediately ? { immediately: true } : {},
@@ -486,8 +511,6 @@ var ClientModuleRegistry = class extends Service {
486
511
  rebuildListeners = /* @__PURE__ */ new Set();
487
512
  graphListeners = /* @__PURE__ */ new Set();
488
513
  dirty = /* @__PURE__ */ new Set();
489
- initialRevisionNonce = randomBytes(8).toString("hex");
490
- nextInitialRevision = 0;
491
514
  responses = /* @__PURE__ */ new Map();
492
515
  batchResponses = /* @__PURE__ */ new Map();
493
516
  /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
@@ -522,7 +545,7 @@ var ClientModuleRegistry = class extends Service {
522
545
  const registerWebCarrier = (webCtx) => {
523
546
  webCtx.effect(() => webCtx.webServer.register({
524
547
  kind: "prefix",
525
- path: "/plugins",
548
+ path: PLUGIN_ROUTE,
526
549
  handler: this.serveBundle
527
550
  }), "client-modules: bundle route");
528
551
  };
@@ -577,17 +600,18 @@ var ClientModuleRegistry = class extends Service {
577
600
  /**
578
601
  * Publish one completed bundle generation (the HMR watch's registration
579
602
  * hook — the only entry point through which build changes reach the graph).
603
+ * Unchanged mtime, ctime and size preserve the graph without reading the bundle.
580
604
  * @param id - entry id (package name).
581
- * @returns the new rev, or undefined for an unknown id.
605
+ * @returns the current artifact rev, or undefined for an unknown id.
582
606
  */
583
607
  rebuilt(id) {
584
608
  const record = this.table.get(id);
585
609
  if (record === void 0) return void 0;
586
610
  const baseline = this.captureArtifactBaseline(record.meta.clientPath);
611
+ const rev = artifactRevision(baseline);
612
+ if (rev === record.entry.rev) return rev;
587
613
  const bundle = readFileSync(record.meta.clientPath);
588
- const rev = artifactRevision(bundle, baseline);
589
614
  record.baseline = baseline;
590
- if (rev === record.entry.rev) return rev;
591
615
  record.entry = graphRow(id, rev, record.meta);
592
616
  record.bundle = bundle;
593
617
  this.composed = this.compose();
@@ -600,7 +624,7 @@ var ClientModuleRegistry = class extends Service {
600
624
  return rev;
601
625
  }
602
626
  /**
603
- * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
627
+ * Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
604
628
  * @param listener - receives the entry id and its new bundle rev.
605
629
  * @returns the unsubscriber.
606
630
  */
@@ -632,7 +656,7 @@ var ClientModuleRegistry = class extends Service {
632
656
  for (const records of partitionComboRecords(application)) artifacts.push(buildBatch("application", records, this.readSourceMap));
633
657
  const batchResponses = /* @__PURE__ */ new Map();
634
658
  for (const artifact of artifacts) {
635
- batchResponses.set(artifact.descriptor.url, this.responses.get(artifact.descriptor.url) ?? {
659
+ batchResponses.set(artifact.url, this.responses.get(artifact.url) ?? {
636
660
  body: artifact.scriptBody,
637
661
  contentType: "text/javascript; charset=utf-8"
638
662
  });
@@ -773,13 +797,10 @@ var ClientModuleRegistry = class extends Service {
773
797
  return {
774
798
  path: clientPath,
775
799
  mtimeMs: bundle.mtimeMs,
800
+ ctimeMs: bundle.ctimeMs,
776
801
  size: bundle.size
777
802
  };
778
803
  }
779
- /** Allocate an opaque initial row revision without inspecting artifact bytes. */
780
- allocateInitialRevision() {
781
- return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`;
782
- }
783
804
  /**
784
805
  * Read the activation-time bundle snapshot.
785
806
  * @param pkgName - package that declares the client bundle.
@@ -858,7 +879,7 @@ var ClientModuleRegistry = class extends Service {
858
879
  if (source === void 0) return this.table.delete(packageName);
859
880
  if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false;
860
881
  const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath);
861
- const rev = this.allocateInitialRevision();
882
+ const rev = artifactRevision(snapshot.baseline);
862
883
  this.table.set(packageName, {
863
884
  entry: graphRow(packageName, rev, source.meta),
864
885
  loaderName: source.loaderName,
@@ -916,7 +937,7 @@ var ClientModuleRegistry = class extends Service {
916
937
  const { record, fileName, sourceMap, resourceUrl } = request;
917
938
  const clientPath = join(dirname(record.meta.clientPath), fileName);
918
939
  if (!existsSync(clientPath)) return void 0;
919
- const sourceMapUrl = chunkUrl(record.entry.id, fileName, record.entry.rev, true);
940
+ const sourceMapUrl = chunkMapReference(fileName, record.entry.rev);
920
941
  const resource = () => ({
921
942
  id: record.entry.id,
922
943
  rev: record.entry.rev,
package/lib/invariant.js CHANGED
@@ -11,8 +11,8 @@ const inject = ["invariants"];
11
11
  /**
12
12
  * Owned relation: the node half's boot entry graph must stay self-consistent
13
13
  * — every row must resolve a clientPath under the same id (the
14
- * /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
15
- * that just received the graph). Checked on every scan trigger (cordis
14
+ * `plugins/<id>/client.js` reference it advertises would otherwise 404 in the
15
+ * browser that just received the graph). Checked on every scan trigger (cordis
16
16
  * 'internal/plugin'): graph() and clientPath() read the same table object,
17
17
  * so the relation holds at any instant — no need to wait out the node half's
18
18
  * own microtask-debounced flush.
@@ -48,7 +48,10 @@ declare module '@deepseek-ai/cordis' {
48
48
  export interface WebBootEntry {
49
49
  /** Entry name == package name. */
50
50
  id: string;
51
- /** Revisioned single-resource combo endpoint used by HMR. */
51
+ /**
52
+ * Revisioned single-resource combo reference used by HMR. It is relative to
53
+ * the document, so the browser resolves it under whatever mount served the page.
54
+ */
52
55
  url: string;
53
56
  /** Opaque plugin-artifact revision used for HMR cache busting. */
54
57
  rev: string;
@@ -65,7 +68,7 @@ export type WebBootBatchPhase = 'bootstrap' | 'application';
65
68
  export interface WebBootBatch {
66
69
  /** Parser-blocking bootstrap or preloaded application scheduling. */
67
70
  phase: WebBootBatchPhase;
68
- /** Revisioned combo script endpoint. */
71
+ /** Content-addressed combo script reference, document-relative like {@link WebBootEntry.url}. */
69
72
  url: string;
70
73
  /** Revision derived from the ordered entry revisions. */
71
74
  rev: string;
@@ -89,9 +92,9 @@ export interface WebBootGraph {
89
92
  export interface BootModuleRow {
90
93
  /** Entry name == package name (module-table key). */
91
94
  id: string;
92
- /** Revisioned single-resource combo endpoint used after HMR invalidation. */
95
+ /** Revisioned single-resource combo reference: the fallback when the row's batch fails and the reload target after HMR invalidation. */
93
96
  url: string;
94
- /** Revisioned combo endpoint used before the first HMR invalidation. */
97
+ /** Content-addressed combo reference used before the first HMR invalidation. */
95
98
  initialUrl: string;
96
99
  /** Opaque plugin-artifact revision used after HMR invalidation. */
97
100
  rev: string;
@@ -263,6 +266,15 @@ export interface ClientModuleLoader {
263
266
  * @param id - graph entry name.
264
267
  */
265
268
  prefetch(id: string): Promise<void>;
269
+ /**
270
+ * The last failure of {@link import} or {@link prefetch} for one graph row:
271
+ * transport, registration, dependency cascade, or factory execution. Cleared
272
+ * by a later success and by {@link invalidate}. The boot audit reads it to
273
+ * report why a Loader entry has no fiber.
274
+ * @param id - graph entry name.
275
+ * @returns the recorded failure, or `undefined` when the row never failed or succeeded since.
276
+ */
277
+ importError(id: string): Error | undefined;
266
278
  /**
267
279
  * Full reset of one non-bootstrap package: drop its entry and chunk factories
268
280
  * and materialized records so the next prefetch/import loads its one-resource
@@ -25,6 +25,12 @@ export declare class ClientModuleSystem implements ClientModuleLoader {
25
25
  private readonly materializing;
26
26
  private readonly graphRows;
27
27
  private readonly loadBundle;
28
+ /** Last import or prefetch failure per graph row, cleared by a later success or invalidation. */
29
+ private readonly importErrors;
30
+ /** Batch URLs whose transport or execution already failed; rows still missing from them go straight to their one-resource URL. */
31
+ private readonly failedBundleUrls;
32
+ /** Every URL whose script has executed once; a batch among them is never requested again. */
33
+ private readonly executedBundleUrls;
28
34
  /**
29
35
  * Build the module system over the parsed boot rows.
30
36
  * @param options - Parsed graph, platform seed, bootstrap module, registration facade, and transport.
@@ -32,10 +38,28 @@ export declare class ClientModuleSystem implements ClientModuleLoader {
32
38
  constructor(options: ClientModuleSystemOptions);
33
39
  /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
34
40
  private register;
35
- /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
41
+ /** Run one bundle transport per URL; every row waiting on the same URL shares the in-flight request. */
42
+ private loadShared;
43
+ /**
44
+ * Load one graph row so its factory is registered (idempotent per in-flight
45
+ * arrival). A batch script is one classic script that registers every
46
+ * package in sequence, and {@link register} rejects a second registration,
47
+ * so the two failure kinds differ: a transport failure (`error` event, nothing
48
+ * executed) is retried once on the same URL; a script that loaded without
49
+ * registering this row (a parse error registered nothing, or a runtime throw
50
+ * stopped it after registering others) is never re-executed, because a replay
51
+ * would stop again at the first duplicate registration; that holds even when
52
+ * the row that first imports from the batch is one it did register, because
53
+ * every executed batch URL is remembered. Either way the row then falls back
54
+ * to its own one-resource URL, which the Host serves for every package, so one
55
+ * failed batch costs at most three requests per missing row and never fails
56
+ * the rows that were registered.
57
+ */
36
58
  private arrive;
37
59
  /** Register each injected package and unresolved dynamic request before its consumer. */
38
60
  private arriveGraphRow;
61
+ /** Arrive one dependency, naming the consumer it failed for so a cascade reads as a chain, not as 44 unrelated failures. */
62
+ private arriveDependency;
39
63
  /** Materialize a registered factory (synchronous; memoized in loadCache). */
40
64
  private materialize;
41
65
  /** Build the synchronous module-table require and its asynchronous chunk operation. */
@@ -44,6 +68,14 @@ export declare class ClientModuleSystem implements ClientModuleLoader {
44
68
  private importChunk;
45
69
  import(specifier: string): Promise<unknown>;
46
70
  prefetch(id: string): Promise<void>;
71
+ importError(id: string): Error | undefined;
72
+ /**
73
+ * Run one graph-row operation, recording its failure for the boot audit and
74
+ * clearing the record on success. Arrival and materialization both run in
75
+ * here, so a factory that throws is recorded as well as a bundle that never
76
+ * arrived; the Loader only sees a missing fiber either way.
77
+ */
78
+ private recordingImportError;
47
79
  /** Refresh descriptors and unowned factory revisions before any entry imports its dependencies. */
48
80
  private updateManifest;
49
81
  /** Retain live Loader modules and their transitive requests before evicting unreferenced graph records. */
@@ -40,6 +40,8 @@ export interface ClientArtifactBaseline {
40
40
  readonly path: string;
41
41
  /** Bundle modification time in milliseconds. */
42
42
  readonly mtimeMs: number;
43
+ /** Bundle status-change time in milliseconds, including writes that preserve mtime. */
44
+ readonly ctimeMs: number;
43
45
  /** Bundle size in bytes. */
44
46
  readonly size: number;
45
47
  }
@@ -81,8 +83,6 @@ export declare class ClientModuleRegistry extends Service {
81
83
  private readonly rebuildListeners;
82
84
  private readonly graphListeners;
83
85
  private readonly dirty;
84
- private readonly initialRevisionNonce;
85
- private nextInitialRevision;
86
86
  private responses;
87
87
  private batchResponses;
88
88
  /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
@@ -127,12 +127,13 @@ export declare class ClientModuleRegistry extends Service {
127
127
  /**
128
128
  * Publish one completed bundle generation (the HMR watch's registration
129
129
  * hook — the only entry point through which build changes reach the graph).
130
+ * Unchanged mtime, ctime and size preserve the graph without reading the bundle.
130
131
  * @param id - entry id (package name).
131
- * @returns the new rev, or undefined for an unknown id.
132
+ * @returns the current artifact rev, or undefined for an unknown id.
132
133
  */
133
134
  rebuilt(id: string): string | undefined;
134
135
  /**
135
- * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
136
+ * Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
136
137
  * @param listener - receives the entry id and its new bundle rev.
137
138
  * @returns the unsubscriber.
138
139
  */
@@ -163,8 +164,6 @@ export declare class ClientModuleRegistry extends Service {
163
164
  private sourceKey;
164
165
  /** Capture the bundle stats before reading its bytes. */
165
166
  private captureArtifactBaseline;
166
- /** Allocate an opaque initial row revision without inspecting artifact bytes. */
167
- private allocateInitialRevision;
168
167
  /**
169
168
  * Read the activation-time bundle snapshot.
170
169
  * @param pkgName - package that declares the client bundle.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-modules",
3
3
  "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
4
- "version": "0.1.6-alpha.2",
4
+ "version": "0.1.7-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -38,12 +38,12 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "devDependencies": {
41
- "@deepseek-ai/dsh-host-webserver": "^0.1.6-alpha.2",
42
- "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.2",
43
- "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
44
- "@deepseek-ai/cordis": "^4.0.2",
45
- "@deepseek-ai/dsh-package-manifest": "^0.1.6-alpha.2",
46
- "@deepseek-ai/dsh-client-store": "^0.1.6-alpha.2"
41
+ "@deepseek-ai/cordis-plugin-loader": "~1.0.5",
42
+ "@deepseek-ai/dsh-host-webserver": "0.1.7-alpha.2",
43
+ "@deepseek-ai/cordis": "~4.0.4",
44
+ "@deepseek-ai/dsh-package-manifest": "0.1.7-alpha.2",
45
+ "@deepseek-ai/dsh-client-store": "0.1.7-alpha.2",
46
+ "@deepseek-ai/dsh-invariants": "0.1.7-alpha.2"
47
47
  },
48
48
  "files": [
49
49
  "lib/index.js",
@@ -52,7 +52,7 @@
52
52
  "lib/types/**/*.d.ts"
53
53
  ],
54
54
  "peerDependencies": {
55
- "@deepseek-ai/cordis": "^4.0.2"
55
+ "@deepseek-ai/cordis": "~4.0.4"
56
56
  },
57
57
  "scripts": {
58
58
  "bundle": "tsdown",