@deepseek-ai/dsh-client-modules 0.1.0-rc.6 → 0.1.0-rc.8

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: efaff699839b977cc45f89f3c164402241b90dc2
6
- README.zh.md: 6ab3c8494d7ea902e8b891008f236aec41301a35
5
+ README.md: eaf64599bc5fc5d9663d00c3b341764a4ccd38c8
6
+ README.zh.md: ea051338bb7837cb49f7a5ecd4cdad5b6b3ad71d
package/README.md CHANGED
@@ -4,11 +4,15 @@ English | [中文](README.zh.md)
4
4
 
5
5
  Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` through its `internal` contract — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
6
6
 
7
- 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, so load order needs no external sequencing; require cycles throw (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).
7
+ 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).
8
8
 
9
- Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
9
+ The Host installs `window.__ModuleLoader__` before parser preloads run. Its queue-mode `load()` retains early registrations; `create()` materializes this package's factory with an external-rejecting bootstrap require and calls its `createClientModuleSystem` export. Construction caches those same exports as the modules row, switches the same facade to live registration, and drains the remaining queue. The bundle retains the resulting system in a module closure, so its later Cordis `apply()` provides the identical instance as `ctx.modules` without another page global.
10
10
 
11
- The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
11
+ Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its classic-script factory; registered factory → materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous graph-row load and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops a non-bootstrap factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
12
+
13
+ The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, carries package-specific `dsh.client.external` requests, orders dynamic providers before consumers, and serves each bundle with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
14
+
15
+ `dsh.client.external` is an optional exact-specifier request list beyond the implicit baseline: shell-seeded React, Cordis, and static UI libraries plus parser-preloaded runtime. 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).
12
16
 
13
17
  ## Model Experience
14
18
 
package/README.zh.md CHANGED
@@ -4,11 +4,15 @@
4
4
 
5
5
  客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并通过其 `internal` 约定注入该包的 `ClientModuleLoader`;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
6
6
 
7
- 惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
7
+ 惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它;图组合会把声明的动态请求提供方放在消费者之前,而 require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
8
8
 
9
- 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
9
+ Host 会在 parser preload 运行前安装 `window.__ModuleLoader__`。其 queue 模式的 `load()` 保存提前到达的 registration;`create()` 使用拒绝 external 的 bootstrap require 物化本包 factory,并调用其 `createClientModuleSystem` 导出。构造过程把同一组导出缓存为 modules row,把同一个 facade 切换到 live registration,再排空余下 queue。Bundle 通过模块闭包保留生成的系统,因此随后 Cordis `apply()` 能把同一实例提供为 `ctx.modules`,无需另一个页面全局变量。
10
10
 
11
- Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的 bundle 哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
11
+ 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其 classic-script factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并登记 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃非 bootstrap factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
12
+
13
+ Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的 bundle 哈希和包专属 `dsh.client.external` 请求写入启动图,把动态提供方排在消费者之前,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
14
+
15
+ `dsh.client.external` 是统一基座之外的可选精确 specifier 请求列表。统一基座包括外壳播种的 React、Cordis 和静态 UI 库,以及由 HTML parser 预载的 runtime。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
12
16
 
13
17
  ## 模型体验
14
18
 
package/lib/client.js CHANGED
@@ -4,7 +4,112 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region lib/types/client/manifest.js
8
+ /**
9
+ * Client module system: the browser peer of Node's internal ESM loader, built
10
+ * as a lazy CJS table. The vendored cordis Loader consumes this object
11
+ * through its `internal` contract (the only call site is `EntryTree.import` →
12
+ * `internal.import`), which keeps entry governance (fiber lifecycle, inject
13
+ * waiting, update/refresh) entirely on the vendored side while this package
14
+ * owns code arrival.
15
+ *
16
+ * Lazy CJS model: executing a plugin bundle only REGISTERS its
17
+ * factory (`window.__ModuleLoader__.load({id, factory})`); every module body
18
+ * side effect — including CSS injection — lives inside the factory closure
19
+ * and runs at materialization, not at script execution. Materialization
20
+ * (factory(require) → exports) happens on first import/require and is
21
+ * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
22
+ * another registered-but-unmaterialized module materializes it recursively,
23
+ * so load order needs no external sequencing.
24
+ *
25
+ * Resolution branch order (import): seed word → shell instance; memoized
26
+ * record → exports; graph row → register its dependency factories and own
27
+ * factory; registered factory → materialize; anything else → throw (loud —
28
+ * the runtime mirror of the build-time bundle purity gate).
29
+ * The synchronous `require` handed to factories walks the same order minus
30
+ * the load branch. Loading is async, so a requested dynamic package must have
31
+ * registered its factory before a consumer materializes.
32
+ *
33
+ * This file is the browser-safe contract face (zero node imports): the
34
+ * `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around
35
+ * {@link ClientModuleSystem}. The package root is the host-side service that
36
+ * composes the wire.
37
+ */
38
+ /**
39
+ * Validate an optional string-array field read from a `dsh.client` declaration
40
+ * or from the boot wire.
41
+ * @param subject - diagnostic prefix naming the package or the wire row.
42
+ * @param field - field name as it appears in the diagnostic.
43
+ * @param value - the raw field value.
44
+ * @returns the validated array, or undefined when the field is absent.
45
+ * @throws {Error} when the value is present but is not an array of strings.
46
+ */
47
+ function optionalStringArray(subject, field, value) {
48
+ if (value === void 0) return void 0;
49
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`client-modules: ${subject} ${field} must be a string array`);
50
+ return value;
51
+ }
52
+ /**
53
+ * Normalize a module specifier onto the graph row that owns it: a plugin bundle
54
+ * IS its package's client half, so `<id>/client` (the exports subpath external
55
+ * bundles emit) and the bare package name resolve to the same exports. Both the
56
+ * require path and graph composition normalize here, which is what lets each
57
+ * importing package request the subpath its own code imports.
58
+ * @param spec - module specifier as a bundle requires it or a declaration spells it.
59
+ * @returns the specifier with a trailing `/client` removed.
60
+ */
61
+ function stripClientSuffix(spec) {
62
+ return spec.endsWith("/client") ? spec.slice(0, -7) : spec;
63
+ }
64
+ /**
65
+ * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
66
+ * a missing or malformed graph throws (the shell shows the loud failure —
67
+ * a page without a valid manifest cannot boot anything).
68
+ * @param wire - the raw `window.__DSH_BOOT__` value.
69
+ * @returns the manifest with optional plugin-view fields normalized.
70
+ */
71
+ function parseBootManifest(wire) {
72
+ if (typeof wire !== "object" || wire === null) throw new Error("client-modules: window.__DSH_BOOT__ is missing or not an object");
73
+ const graph = wire;
74
+ if (typeof graph.rev !== "string") throw new Error("client-modules: boot manifest rev must be a string");
75
+ if (!Array.isArray(graph.entries)) throw new Error("client-modules: boot manifest entries must be an array");
76
+ const modules = [];
77
+ const plugins = [];
78
+ for (const value of graph.entries) {
79
+ if (typeof value !== "object" || value === null) throw new Error("client-modules: boot manifest entry is not an object");
80
+ const row = value;
81
+ const where = typeof row.id === "string" ? `"${row.id}"` : JSON.stringify(row);
82
+ if (typeof row.id !== "string" || typeof row.url !== "string" || typeof row.rev !== "string") throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`);
83
+ const subject = `boot manifest entry ${where}`;
84
+ const inject = optionalStringArray(subject, "inject", row.inject);
85
+ const external = optionalStringArray(subject, "external", row.external);
86
+ if (row.immediately !== void 0 && typeof row.immediately !== "boolean") throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`);
87
+ modules.push({
88
+ id: row.id,
89
+ url: row.url,
90
+ rev: row.rev,
91
+ external: external === void 0 ? [] : [...external]
92
+ });
93
+ plugins.push({
94
+ id: row.id,
95
+ inject: inject === void 0 ? [] : [...inject],
96
+ immediately: row.immediately === true
97
+ });
98
+ }
99
+ return {
100
+ rev: graph.rev,
101
+ modules,
102
+ plugins
103
+ };
104
+ }
105
+ //#endregion
7
106
  //#region lib/types/client/system.js
107
+ /**
108
+ * ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
109
+ * contract. The conceptual contract (lazy CJS model, resolution branch order) is
110
+ * documented on the public interfaces in `./manifest.ts`; this file owns the
111
+ * state tables and the load/materialize machinery.
112
+ */
8
113
  /** Default bundle-load hook: same-origin external classic script. */
9
114
  const defaultLoadBundle = (url) => new Promise((resolve, reject) => {
10
115
  const el = document.createElement("script");
@@ -21,12 +126,6 @@ window.__ModuleLoader__.load({
21
126
  document.head.append(el);
22
127
  });
23
128
  /**
24
- * A plugin bundle IS its package's client half: `<id>/client` (the exports
25
- * subpath external bundles emit) and the bare graph id name the same
26
- * exports, so table lookups normalize the suffix away.
27
- */
28
- const stripClientSuffix = (spec) => spec.endsWith("/client") ? spec.slice(0, -7) : spec;
29
- /**
30
129
  * Claim and inventory the <style> tags a factory injected during
31
130
  * materialization: preset-emitted tags arrive pre-tagged with data-plugin;
32
131
  * any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
@@ -41,15 +140,17 @@ window.__ModuleLoader__.load({
41
140
  /**
42
141
  * The client module system: state tables plus the arrival/materialization
43
142
  * machinery implementing {@link ClientModuleLoader} (whose members carry the
44
- * contract documentation). Construction indexes the boot rows and installs the
45
- * `window.__ModuleLoader__` registration sink — once per page.
143
+ * contract documentation). Construction indexes the boot rows, retains the
144
+ * already-materialized bootstrap module, and switches the HTML-installed
145
+ * loader facade from its pending queue to live registration.
46
146
  */
47
147
  var ClientModuleSystem = class {
48
148
  version = "client";
149
+ manifest;
49
150
  loadCache = /* @__PURE__ */ new Map();
50
151
  seed;
51
- statics = /* @__PURE__ */ new Map();
52
152
  factories = /* @__PURE__ */ new Map();
153
+ bootstrapIds = /* @__PURE__ */ new Set();
53
154
  /** In-flight prefetch (script load) per id; concurrent callers share it. */
54
155
  pendingArrival = /* @__PURE__ */ new Map();
55
156
  /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
@@ -58,28 +159,45 @@ window.__ModuleLoader__.load({
58
159
  loadBundle;
59
160
  /**
60
161
  * Build the module system over the parsed boot rows.
61
- * @param options - Module rows, module-table staticModules, and bundle-load hook.
162
+ * @param options - Parsed graph, platform seed, bootstrap module, registration facade, and transport.
62
163
  */
63
164
  constructor(options) {
165
+ this.manifest = options.manifest;
64
166
  this.seed = new Map(Object.entries(options.staticModules));
65
167
  this.loadBundle = options.loadBundle ?? defaultLoadBundle;
66
- for (const row of options.modules) {
168
+ for (const row of options.manifest.modules) {
67
169
  if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`);
68
170
  this.graphRows.set(row.id, row);
69
171
  }
70
- const win = globalThis;
71
- if (win.__ModuleLoader__ !== void 0) throw new Error("client-modules: window.__ModuleLoader__ already installed (double boot?)");
72
- win.__ModuleLoader__ = { load: (handoff) => {
73
- if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`);
74
- this.factories.set(handoff.id, handoff.factory);
75
- } };
172
+ const bootstrapId = stripClientSuffix(options.bootstrapModule.id);
173
+ this.bootstrapIds.add(bootstrapId);
174
+ this.loadCache.set(bootstrapId, {
175
+ id: bootstrapId,
176
+ exports: options.bootstrapModule.exports,
177
+ styles: [],
178
+ edges: /* @__PURE__ */ new Set()
179
+ });
180
+ const target = options.registrationTarget;
181
+ if (target.mode !== "queue") throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot");
182
+ const pending = target.pendingQueue.splice(0);
183
+ target.mode = "live";
184
+ target.load = (registration) => {
185
+ this.register(registration);
186
+ };
187
+ for (const registration of pending) target.load(registration);
188
+ }
189
+ /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
190
+ register(registration) {
191
+ const id = stripClientSuffix(registration.id);
192
+ if (this.bootstrapIds.has(id) || this.factories.has(id)) throw new Error(`client-modules: duplicate factory registration for "${registration.id}" (bundle executed twice without invalidate?)`);
193
+ this.factories.set(id, registration.factory);
76
194
  }
77
195
  /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
78
196
  arrive(row) {
79
197
  const { id, url } = row;
80
198
  const pending = this.pendingArrival.get(id);
81
199
  if (pending !== void 0) return pending;
82
- if (this.factories.has(id)) return Promise.resolve();
200
+ if (this.loadCache.has(id) || this.factories.has(id)) return Promise.resolve();
83
201
  const task = this.loadBundle(url).then(() => {
84
202
  if (!this.factories.has(id)) throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`);
85
203
  }).finally(() => {
@@ -88,6 +206,19 @@ window.__ModuleLoader__.load({
88
206
  this.pendingArrival.set(id, task);
89
207
  return task;
90
208
  }
209
+ /** Register each unresolved dynamic request before registering its consumer. */
210
+ async arriveGraphRow(row, open = []) {
211
+ const cycleStart = open.indexOf(row.id);
212
+ if (cycleStart !== -1) throw new Error(`client-modules: module arrival cycle ${[...open.slice(cycleStart), row.id].join(" -> ")} (the host must reject this graph before serving it)`);
213
+ const next = [...open, row.id];
214
+ for (const request of row.external) {
215
+ const id = stripClientSuffix(request);
216
+ if (this.seed.has(request) || this.loadCache.has(id)) continue;
217
+ const dependency = this.graphRows.get(id);
218
+ if (dependency !== void 0) await this.arriveGraphRow(dependency, next);
219
+ }
220
+ await this.arrive(row);
221
+ }
91
222
  /** Materialize a registered factory (synchronous; memoized in loadCache). */
92
223
  materialize(id) {
93
224
  const existing = this.loadCache.get(id);
@@ -112,146 +243,80 @@ window.__ModuleLoader__.load({
112
243
  }
113
244
  }
114
245
  /**
115
- * The synchronous require answered to factories: seed → static → memoized
116
- * record → registered factory (recursive materialization — this is what
117
- * makes load order self-resolving). Fetching is async and therefore
118
- * unreachable from here; an unregistered plugin specifier is loud (and a
119
- * cross-plugin value import is already a build error upstream).
246
+ * The synchronous require answered to factories: seed → memoized record →
247
+ * registered factory. Fetching is async and therefore unreachable
248
+ * from here; an external dynamic package must have arrived before its
249
+ * consumer materializes.
120
250
  */
121
251
  makeRequire(edges) {
122
252
  return (spec) => {
123
253
  edges.add(spec);
124
254
  if (this.seed.has(spec)) return this.seed.get(spec);
125
- if (this.statics.has(spec)) return this.statics.get(spec);
126
255
  const id = stripClientSuffix(spec);
127
256
  const record = this.loadCache.get(id);
128
257
  if (record !== void 0) return record.exports;
129
258
  if (this.factories.has(id)) return this.materialize(id).exports;
130
- throw new Error(`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)`);
259
+ throw new Error(`client-modules: require("${spec}") missed the module table — not a platform seed word, not a materialized module, and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)`);
131
260
  };
132
261
  }
133
262
  async import(specifier) {
134
263
  if (this.seed.has(specifier)) return this.seed.get(specifier);
135
- const existing = this.loadCache.get(specifier);
264
+ const id = stripClientSuffix(specifier);
265
+ const existing = this.loadCache.get(id);
136
266
  if (existing !== void 0) return existing.exports;
137
- if (this.statics.has(specifier)) {
138
- const exports = this.statics.get(specifier);
139
- this.loadCache.set(specifier, {
140
- id: specifier,
141
- exports,
142
- styles: [],
143
- edges: /* @__PURE__ */ new Set()
144
- });
145
- return exports;
146
- }
147
- if (!this.factories.has(specifier)) {
148
- const row = this.graphRows.get(specifier);
149
- if (row === void 0) throw new Error(`client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, and not a row in the boot graph (the runtime mirror of the bundle purity gate)`);
150
- await this.arrive(row);
151
- }
152
- return this.materialize(specifier).exports;
153
- }
154
- registerStatic(id, module) {
155
- if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`);
156
- this.statics.set(id, module);
267
+ const row = this.graphRows.get(id);
268
+ if (row !== void 0) await this.arriveGraphRow(row);
269
+ 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)`);
270
+ return this.materialize(id).exports;
157
271
  }
158
272
  async prefetch(id) {
159
- if (this.statics.has(id)) return;
160
- const row = this.graphRows.get(id);
273
+ const normalized = stripClientSuffix(id);
274
+ if (this.loadCache.has(normalized)) return;
275
+ const row = this.graphRows.get(normalized);
161
276
  if (row === void 0) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`);
162
- await this.arrive(row);
277
+ await this.arriveGraphRow(row);
163
278
  }
164
279
  invalidate(id) {
165
- this.factories.delete(id);
166
- this.loadCache.delete(id);
280
+ const normalized = stripClientSuffix(id);
281
+ if (this.bootstrapIds.has(normalized)) return;
282
+ this.factories.delete(normalized);
283
+ this.loadCache.delete(normalized);
167
284
  }
168
285
  };
169
286
  //#endregion
170
- //#region lib/types/client/manifest.js
171
- /**
172
- * Client module system: the browser peer of Node's internal ESM loader, built
173
- * as a lazy CJS table. The vendored cordis Loader consumes this object
174
- * through its `internal` contract (the only call site is `EntryTree.import` →
175
- * `internal.import`), which keeps entry governance (fiber lifecycle, inject
176
- * waiting, update/refresh) entirely on the vendored side while this package
177
- * owns code arrival.
178
- *
179
- * Lazy CJS model: executing a plugin bundle only REGISTERS its
180
- * factory (`window.__ModuleLoader__.load({id, factory})`); every module body
181
- * side effect — including CSS injection — lives inside the factory closure
182
- * and runs at materialization, not at script execution. Materialization
183
- * (factory(require) → exports) happens on first import/require and is
184
- * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
185
- * another registered-but-unmaterialized module materializes it recursively,
186
- * so load order needs no external sequencing.
187
- *
188
- * Resolution branch order (import): seed word → shell instance; memoized
189
- * record → exports; static registry (shell-own modules, e.g. app-shell) →
190
- * module; registered factory → materialize; graph row → load + materialize;
191
- * anything else → throw (loud — the runtime mirror of the
192
- * build-time bundle purity gate). The synchronous `require` handed to
193
- * factories walks the same order minus the load branch: loading is async,
194
- * so only already-registered bundles can be required — and cross-plugin value
195
- * imports are a build error anyway.
196
- *
197
- * This file is the browser-safe contract face (zero node imports): the
198
- * `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around
199
- * {@link ClientModuleSystem}. The package root is the host-side service that
200
- * composes the wire.
201
- */
287
+ //#region lib/types/client/index.js
288
+ let moduleSystem;
202
289
  /**
203
- * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
204
- * a missing or malformed graph throws (the shell shows the loud failure —
205
- * a page without a valid manifest cannot boot anything).
206
- * @param wire - the raw `window.__DSH_BOOT__` value.
207
- * @returns the manifest with optional plugin-view fields normalized.
290
+ * Build the live module system from the HTML facade's materialized modules bundle.
291
+ * @param target - Stable registration facade whose pending queue becomes the live sink.
292
+ * @param bootstrapModule - This bundle's id and already-materialized exports.
293
+ * @param options - Raw boot graph, platform seed, and optional bundle transport.
294
+ * @returns The created module system, also published for this package's Cordis plugin face.
208
295
  */
209
- function parseBootManifest(wire) {
210
- if (typeof wire !== "object" || wire === null) throw new Error("client-modules: window.__DSH_BOOT__ is missing or not an object");
211
- const graph = wire;
212
- if (typeof graph.rev !== "string") throw new Error("client-modules: boot manifest rev must be a string");
213
- if (!Array.isArray(graph.entries)) throw new Error("client-modules: boot manifest entries must be an array");
214
- const modules = [];
215
- const plugins = [];
216
- for (const value of graph.entries) {
217
- if (typeof value !== "object" || value === null) throw new Error("client-modules: boot manifest entry is not an object");
218
- const row = value;
219
- const where = typeof row.id === "string" ? `"${row.id}"` : JSON.stringify(row);
220
- if (typeof row.id !== "string" || typeof row.url !== "string" || typeof row.rev !== "string") throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`);
221
- if (row.inject !== void 0 && (!Array.isArray(row.inject) || row.inject.some((i) => typeof i !== "string"))) throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`);
222
- if (row.immediately !== void 0 && typeof row.immediately !== "boolean") throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`);
223
- modules.push({
224
- id: row.id,
225
- url: row.url,
226
- rev: row.rev
227
- });
228
- plugins.push({
229
- id: row.id,
230
- inject: row.inject === void 0 ? [] : [...row.inject],
231
- immediately: row.immediately === true
232
- });
233
- }
234
- return {
235
- rev: graph.rev,
236
- modules,
237
- plugins
238
- };
296
+ function createClientModuleSystem(target, bootstrapModule, options) {
297
+ moduleSystem = new ClientModuleSystem({
298
+ manifest: parseBootManifest(options.boot),
299
+ staticModules: options.staticModules,
300
+ registrationTarget: target,
301
+ bootstrapModule,
302
+ ...options.loadBundle === void 0 ? {} : { loadBundle: options.loadBundle }
303
+ });
304
+ return moduleSystem;
239
305
  }
240
- //#endregion
241
- //#region lib/types/client/index.js
242
306
  /**
243
307
  * Enroll the kernel-built module system as `ctx.modules`.
244
308
  * @param ctx - client root context.
245
309
  */
246
310
  function apply(ctx) {
247
- const modules = globalThis.__DSH_MODULES__;
248
- if (modules === void 0) throw new Error("client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot");
249
- ctx.reflect.provide("modules", modules);
311
+ if (moduleSystem === void 0) throw new Error("client-modules: createClientModuleSystem must run before plugin boot");
312
+ ctx.reflect.provide("modules", moduleSystem);
250
313
  }
251
314
  //#endregion
252
315
  exports.ClientModuleSystem = ClientModuleSystem;
253
316
  exports.apply = apply;
317
+ exports.createClientModuleSystem = createClientModuleSystem;
254
318
  exports.parseBootManifest = parseBootManifest;
319
+ exports.stripClientSuffix = stripClientSuffix;
255
320
  return module.exports;
256
321
  }
257
322
  });
package/lib/index.js CHANGED
@@ -4,15 +4,74 @@ import { readFileSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { Service } from "@deepseek-ai/cordis";
7
+ //#region lib/types/client/manifest.js
8
+ /**
9
+ * Client module system: the browser peer of Node's internal ESM loader, built
10
+ * as a lazy CJS table. The vendored cordis Loader consumes this object
11
+ * through its `internal` contract (the only call site is `EntryTree.import` →
12
+ * `internal.import`), which keeps entry governance (fiber lifecycle, inject
13
+ * waiting, update/refresh) entirely on the vendored side while this package
14
+ * owns code arrival.
15
+ *
16
+ * Lazy CJS model: executing a plugin bundle only REGISTERS its
17
+ * factory (`window.__ModuleLoader__.load({id, factory})`); every module body
18
+ * side effect — including CSS injection — lives inside the factory closure
19
+ * and runs at materialization, not at script execution. Materialization
20
+ * (factory(require) → exports) happens on first import/require and is
21
+ * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
22
+ * another registered-but-unmaterialized module materializes it recursively,
23
+ * so load order needs no external sequencing.
24
+ *
25
+ * Resolution branch order (import): seed word → shell instance; memoized
26
+ * record → exports; graph row → register its dependency factories and own
27
+ * factory; registered factory → materialize; anything else → throw (loud —
28
+ * the runtime mirror of the build-time bundle purity gate).
29
+ * The synchronous `require` handed to factories walks the same order minus
30
+ * the load branch. Loading is async, so a requested dynamic package must have
31
+ * registered its factory before a consumer materializes.
32
+ *
33
+ * This file is the browser-safe contract face (zero node imports): the
34
+ * `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around
35
+ * {@link ClientModuleSystem}. The package root is the host-side service that
36
+ * composes the wire.
37
+ */
38
+ /**
39
+ * Validate an optional string-array field read from a `dsh.client` declaration
40
+ * or from the boot wire.
41
+ * @param subject - diagnostic prefix naming the package or the wire row.
42
+ * @param field - field name as it appears in the diagnostic.
43
+ * @param value - the raw field value.
44
+ * @returns the validated array, or undefined when the field is absent.
45
+ * @throws {Error} when the value is present but is not an array of strings.
46
+ */
47
+ function optionalStringArray(subject, field, value) {
48
+ if (value === void 0) return void 0;
49
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`client-modules: ${subject} ${field} must be a string array`);
50
+ return value;
51
+ }
52
+ /**
53
+ * Normalize a module specifier onto the graph row that owns it: a plugin bundle
54
+ * IS its package's client half, so `<id>/client` (the exports subpath external
55
+ * bundles emit) and the bare package name resolve to the same exports. Both the
56
+ * require path and graph composition normalize here, which is what lets each
57
+ * importing package request the subpath its own code imports.
58
+ * @param spec - module specifier as a bundle requires it or a declaration spells it.
59
+ * @returns the specifier with a trailing `/client` removed.
60
+ */
61
+ function stripClientSuffix(spec) {
62
+ return spec.endsWith("/client") ? spec.slice(0, -7) : spec;
63
+ }
64
+ //#endregion
7
65
  //#region lib/types/index.js
8
66
  /**
9
67
  * Node half of the client module system (`dsh.client` dual-face package): scans
10
68
  * the host Loader's entries for packages declaring `dsh.client`, composes the
11
69
  * `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
12
- * in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
13
- * map, taps the index render to inject the boot manifest, and provides the
14
- * `clientModuleHost` service (the HMR node half's registration/notification
15
- * face).
70
+ * in `./client/manifest.ts`) in module-graph order, serves
71
+ * `/plugins/<id>/client.js` and its source map, taps the index render to
72
+ * inject the boot manifest plus the parser-blocking bootstrap preloads, and
73
+ * provides the `clientModuleHost` service (the HMR node half's
74
+ * registration/notification face).
16
75
  *
17
76
  * Scanning is incremental per package — there is no full-rescan code path.
18
77
  * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -63,11 +122,13 @@ function parseDshClient(pkgName, value) {
63
122
  if (typeof value !== "object" || value === null) throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`);
64
123
  const decl = value;
65
124
  if (typeof decl.platform !== "string") throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`);
66
- if (decl.inject !== void 0 && (!Array.isArray(decl.inject) || decl.inject.some((i) => typeof i !== "string"))) throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`);
125
+ const inject = optionalStringArray(pkgName, "dsh.client.inject", decl.inject);
126
+ const external = optionalStringArray(pkgName, "dsh.client.external", decl.external);
67
127
  if (decl.immediately !== void 0 && typeof decl.immediately !== "boolean") throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`);
68
128
  return {
69
129
  platform: decl.platform,
70
- ...decl.inject !== void 0 ? { inject: decl.inject } : {},
130
+ ...inject !== void 0 ? { inject } : {},
131
+ ...external !== void 0 ? { external } : {},
71
132
  ...decl.immediately !== void 0 ? { immediately: decl.immediately } : {}
72
133
  };
73
134
  }
@@ -88,25 +149,93 @@ function shortHash(input) {
88
149
  return createHash("sha1").update(input).digest("hex").slice(0, 12);
89
150
  }
90
151
  /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
91
- function graphRow(id, rev, injectEdges, immediately) {
152
+ function graphRow(id, rev, fields) {
92
153
  return {
93
154
  id,
94
155
  url: `/plugins/${id}/client.js?rev=${rev}`,
95
156
  rev,
96
- ...injectEdges !== void 0 ? { inject: injectEdges } : {},
97
- ...immediately ? { immediately: true } : {}
157
+ ...fields.inject !== void 0 ? { inject: fields.inject } : {},
158
+ ...fields.immediately ? { immediately: true } : {},
159
+ ...fields.external.length > 0 ? { external: fields.external } : {}
160
+ };
161
+ }
162
+ /**
163
+ * Order composed rows so every requested dynamic package precedes its
164
+ * consumers. An `external` specifier is either the package row it names
165
+ * (`<pkg>/client` aliases the bare package) or a static-table name that adds no
166
+ * graph edge.
167
+ * @param entries - composed rows in scan order.
168
+ * @returns the same rows reordered; scan order breaks every tie.
169
+ * @throws {Error} when a row requests itself or when the module graph has a
170
+ * cycle; the message lists the packages on it.
171
+ */
172
+ function orderByModuleGraph(entries) {
173
+ const rowsById = /* @__PURE__ */ new Map();
174
+ for (const entry of entries) rowsById.set(entry.id, entry);
175
+ const ordered = [];
176
+ const placed = /* @__PURE__ */ new Set();
177
+ const open = [];
178
+ const visit = (entry) => {
179
+ if (placed.has(entry.id)) return;
180
+ const cycleStart = open.indexOf(entry.id);
181
+ if (cycleStart !== -1) throw new Error(`client-modules: module graph cycle ${[...open.slice(cycleStart), entry.id].join(" -> ")} — a requested package row must precede its consumers, and factory-form CJS cannot deliver partial exports`);
182
+ open.push(entry.id);
183
+ for (const name of entry.external ?? []) {
184
+ const dependency = rowsById.get(name) ?? rowsById.get(stripClientSuffix(name));
185
+ if (dependency === entry) throw new Error(`client-modules: "${entry.id}" requests module "${name}" that it answers itself — a row must not declare its own package in dsh.client.external`);
186
+ if (dependency !== void 0) visit(dependency);
187
+ }
188
+ open.pop();
189
+ placed.add(entry.id);
190
+ ordered.push(entry);
98
191
  };
192
+ for (const entry of entries) visit(entry);
193
+ return ordered;
194
+ }
195
+ /** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
196
+ const CLIENT_MODULES_ID = "@deepseek-ai/dsh-client-modules";
197
+ /** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
198
+ const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, "@deepseek-ai/dsh-client-runtime"];
199
+ /** Escape a graph URL before placing it in a quoted HTML attribute. */
200
+ function escapeHtmlAttribute(value) {
201
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
99
202
  }
100
203
  /**
101
- * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
102
- * first script in <head> (before the shell bundle reads it). `<` is escaped in
103
- * the JSON so plugin-controlled strings cannot break out of the script element.
204
+ * Inject the boot protocol into index.html. The inline registration queue precedes
205
+ * blocking classic scripts for modules' and runtime's ordinary
206
+ * `lib/client.js` artifacts. Its `create()` method materializes the modules
207
+ * bundle, delegates construction to that bundle, and leaves the same facade
208
+ * in live-registration mode. The graph script follows before the shell reads
209
+ * it. `<` is escaped in JSON so a plugin-controlled string cannot break out
210
+ * of the script element.
104
211
  * @param html - the index.html source.
105
212
  * @param graph - the composed entry graph.
106
213
  * @returns the html with the graph script injected.
107
214
  */
108
215
  function injectBootManifest(html, graph) {
109
- const script = `<script>window.__DSH_BOOT__ = ${JSON.stringify(graph).replaceAll("<", "\\u003c")}<\/script>`;
216
+ const json = JSON.stringify(graph).replaceAll("<", "\\u003c");
217
+ const script = `${`<script>(()=>{
218
+ const pendingQueue=[]
219
+ window.__ModuleLoader__={
220
+ mode:"queue",
221
+ pendingQueue,
222
+ load(registration){pendingQueue.push(registration)},
223
+ create(options){
224
+ if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot")
225
+ const index=pendingQueue.findIndex(registration=>registration.id===${JSON.stringify(CLIENT_MODULES_ID)})
226
+ const registration=pendingQueue[index]
227
+ if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js")
228
+ pendingQueue.splice(index,1)
229
+ const exports=registration.factory(specifier=>{
230
+ throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed')
231
+ })
232
+ if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){
233
+ throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face")
234
+ }
235
+ return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
236
+ }
237
+ }
238
+ })()<\/script>`}${PARSER_PRELOAD_IDS.map((id) => graph.entries.find((entry) => entry.id === id)).filter((entry) => entry !== void 0).map((entry) => `<script src="${escapeHtmlAttribute(entry.url)}"><\/script>`).join("")}<script>window.__DSH_BOOT__ = ${json}<\/script>`;
110
239
  const head = html.indexOf("<head>");
111
240
  if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`;
112
241
  return `${script}${html}`;
@@ -175,7 +304,7 @@ var ClientModuleRegistry = class extends Service {
175
304
  * @returns the path, or undefined for an unknown id.
176
305
  */
177
306
  clientPath(id) {
178
- return this.table.get(id)?.clientPath;
307
+ return this.table.get(id)?.meta.clientPath;
179
308
  }
180
309
  /**
181
310
  * Re-hash one bundle (the HMR watch's registration hook — the only entry
@@ -186,9 +315,9 @@ var ClientModuleRegistry = class extends Service {
186
315
  rebuilt(id) {
187
316
  const record = this.table.get(id);
188
317
  if (record === void 0) return void 0;
189
- const rev = shortHash(readFileSync(record.clientPath));
318
+ const rev = shortHash(readFileSync(record.meta.clientPath));
190
319
  if (rev === record.entry.rev) return rev;
191
- record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true);
320
+ record.entry = graphRow(id, rev, record.meta);
192
321
  this.composed = this.compose();
193
322
  for (const notify of this.rebuildListeners) try {
194
323
  notify(id, rev);
@@ -222,7 +351,7 @@ var ClientModuleRegistry = class extends Service {
222
351
  };
223
352
  }
224
353
  compose() {
225
- const entries = [...this.table.values()].map((record) => record.entry);
354
+ const entries = orderByModuleGraph([...this.table.values()].map((record) => record.entry));
226
355
  return {
227
356
  rev: shortHash(JSON.stringify(entries)),
228
357
  entries
@@ -257,6 +386,7 @@ var ClientModuleRegistry = class extends Service {
257
386
  const meta = {
258
387
  clientPath: join(dirname(pkgPath), clientRel),
259
388
  ...decl.inject !== void 0 ? { inject: decl.inject } : {},
389
+ external: decl.external ?? [],
260
390
  immediately: decl.immediately === true
261
391
  };
262
392
  this.pkgMeta.set(pkgName, meta);
@@ -290,8 +420,8 @@ var ClientModuleRegistry = class extends Service {
290
420
  if (meta === null) return false;
291
421
  const rev = this.initialBundleRevision(entryName, meta.clientPath);
292
422
  this.table.set(entryName, {
293
- entry: graphRow(entryName, rev, meta.inject, meta.immediately),
294
- clientPath: meta.clientPath
423
+ entry: graphRow(entryName, rev, meta),
424
+ meta
295
425
  });
296
426
  return true;
297
427
  }
@@ -305,10 +435,16 @@ var ClientModuleRegistry = class extends Service {
305
435
  onError(error instanceof Error ? error : new Error(String(error)));
306
436
  }
307
437
  }
308
- if (changed) {
309
- this.composed = this.compose();
310
- this.notifyGraphChanged();
438
+ if (!changed) return;
439
+ let composed;
440
+ try {
441
+ composed = this.compose();
442
+ } catch (error) {
443
+ onError(error);
444
+ return;
311
445
  }
446
+ this.composed = composed;
447
+ this.notifyGraphChanged();
312
448
  }
313
449
  serveBundle = async (req, res) => {
314
450
  if (req.method !== "GET" && req.method !== "HEAD") {
@@ -344,4 +480,4 @@ var ClientModuleRegistry = class extends Service {
344
480
  };
345
481
  };
346
482
  //#endregion
347
- export { ClientModuleRegistry, ClientModuleRegistry as default, injectBootManifest };
483
+ export { ClientModuleRegistry, ClientModuleRegistry as default, injectBootManifest, orderByModuleGraph, stripClientSuffix };
@@ -2,17 +2,28 @@
2
2
  * Browser half (the standard `./client` export): the module-system class and
3
3
  * wire contract, plus the enrollment plugin face. The module system itself is
4
4
  * built by the shell kernel BEFORE cordis exists (the bootstrap exception —
5
- * the mechanism that loads plugins cannot arrive through
6
- * itself); the plugin face only enrolls that pre-existing instance by
7
- * providing it as `ctx.modules`. The kernel statically registers this module,
8
- * so the graph row for this package never triggers a real fetch — arrival is
9
- * a no-op against the already-registered entry.
5
+ * the mechanism that loads plugins cannot arrive through itself). The host
6
+ * parser-preloads this ordinary client bundle into the pending registration
7
+ * queue. The HTML-installed loader facade materializes this bundle and calls
8
+ * its bootstrap export, which constructs the system and retains the same
9
+ * exports for this package's graph row. The plugin face only enrolls that
10
+ * pre-existing instance by providing it as `ctx.modules`.
10
11
  * @module @deepseek-ai/dsh-client-modules/client
11
12
  */
12
13
  import type { Context } from '@deepseek-ai/cordis';
13
- export { ClientModuleSystem } from './system.ts';
14
- export { parseBootManifest } from './manifest.ts';
15
- export type { BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord, ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph, } from './manifest.ts';
14
+ import { ClientModuleSystem } from './system.ts';
15
+ import type { ClientBootstrapModule, ClientModuleCreateOptions, ClientModuleLoaderTarget } from './manifest.ts';
16
+ export { ClientModuleSystem };
17
+ export { parseBootManifest, stripClientSuffix } from './manifest.ts';
18
+ export type { BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration, ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord, ClientModuleSystemOptions, DshWindow, WebBootEntry, WebBootGraph, } from './manifest.ts';
19
+ /**
20
+ * Build the live module system from the HTML facade's materialized modules bundle.
21
+ * @param target - Stable registration facade whose pending queue becomes the live sink.
22
+ * @param bootstrapModule - This bundle's id and already-materialized exports.
23
+ * @param options - Raw boot graph, platform seed, and optional bundle transport.
24
+ * @returns The created module system, also published for this package's Cordis plugin face.
25
+ */
26
+ export declare function createClientModuleSystem(target: ClientModuleLoaderTarget, bootstrapModule: ClientBootstrapModule, options: ClientModuleCreateOptions): ClientModuleSystem;
16
27
  /**
17
28
  * Enroll the kernel-built module system as `ctx.modules`.
18
29
  * @param ctx - client root context.
@@ -16,13 +16,12 @@
16
16
  * so load order needs no external sequencing.
17
17
  *
18
18
  * Resolution branch order (import): seed word → shell instance; memoized
19
- * record → exports; static registry (shell-own modules, e.g. app-shell) →
20
- * module; registered factory → materialize; graph row → load + materialize;
21
- * anything else → throw (loud — the runtime mirror of the
22
- * build-time bundle purity gate). The synchronous `require` handed to
23
- * factories walks the same order minus the load branch: loading is async,
24
- * so only already-registered bundles can be required — and cross-plugin value
25
- * imports are a build error anyway.
19
+ * record → exports; graph row → register its dependency factories and own
20
+ * factory; registered factory → materialize; anything else → throw (loud —
21
+ * the runtime mirror of the build-time bundle purity gate).
22
+ * The synchronous `require` handed to factories walks the same order minus
23
+ * the load branch. Loading is async, so a requested dynamic package must have
24
+ * registered its factory before a consumer materializes.
26
25
  *
27
26
  * This file is the browser-safe contract face (zero node imports): the
28
27
  * `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around
@@ -41,7 +40,9 @@ declare module '@deepseek-ai/cordis' {
41
40
  * single source: the host node half (package root) produces this same shape.
42
41
  * `immediately` marks stage-one prefetch; `inject` is informational graph
43
42
  * metadata (the authoritative edges live in each package's `dsh.client`
44
- * declaration and reach fibers through entry creation).
43
+ * declaration and reach fibers through entry creation). `external` carries
44
+ * module-graph edges: unlike `inject`, they constrain code arrival because
45
+ * `require` is synchronous (see {@link WebBootGraph.entries}).
45
46
  */
46
47
  export interface WebBootEntry {
47
48
  /** Entry name == package name. */
@@ -54,12 +55,18 @@ export interface WebBootEntry {
54
55
  inject?: string[];
55
56
  /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
56
57
  immediately?: boolean;
58
+ /** Non-baseline module specifiers this row requests; omitted when it requests none. */
59
+ external?: string[];
57
60
  }
58
61
  /** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
59
62
  export interface WebBootGraph {
60
63
  /** Consistency anchor over the whole graph (content + bundle hashes). */
61
64
  rev: string;
62
- /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
65
+ /**
66
+ * Composed entries in module-graph order — a dynamic package row precedes
67
+ * rows whose `external` requests that package. Cordis activation order is
68
+ * unrelated and remains owned by fiber service waiting.
69
+ */
63
70
  entries: WebBootEntry[];
64
71
  }
65
72
  /** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
@@ -70,6 +77,8 @@ export interface BootModuleRow {
70
77
  url: string;
71
78
  /** Bundle content hash. */
72
79
  rev: string;
80
+ /** Module specifiers this row requests from the module table ([] when the wire omits them). */
81
+ external: string[];
73
82
  }
74
83
  /** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
75
84
  export interface BootPluginRow {
@@ -89,6 +98,26 @@ export interface BootManifest {
89
98
  /** Rows as entry composition consumes them. */
90
99
  plugins: BootPluginRow[];
91
100
  }
101
+ /**
102
+ * Validate an optional string-array field read from a `dsh.client` declaration
103
+ * or from the boot wire.
104
+ * @param subject - diagnostic prefix naming the package or the wire row.
105
+ * @param field - field name as it appears in the diagnostic.
106
+ * @param value - the raw field value.
107
+ * @returns the validated array, or undefined when the field is absent.
108
+ * @throws {Error} when the value is present but is not an array of strings.
109
+ */
110
+ export declare function optionalStringArray(subject: string, field: string, value: unknown): string[] | undefined;
111
+ /**
112
+ * Normalize a module specifier onto the graph row that owns it: a plugin bundle
113
+ * IS its package's client half, so `<id>/client` (the exports subpath external
114
+ * bundles emit) and the bare package name resolve to the same exports. Both the
115
+ * require path and graph composition normalize here, which is what lets each
116
+ * importing package request the subpath its own code imports.
117
+ * @param spec - module specifier as a bundle requires it or a declaration spells it.
118
+ * @returns the specifier with a trailing `/client` removed.
119
+ */
120
+ export declare function stripClientSuffix(spec: string): string;
92
121
  /**
93
122
  * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
94
123
  * a missing or malformed graph throws (the shell shows the loud failure —
@@ -97,8 +126,8 @@ export interface BootManifest {
97
126
  * @returns the manifest with optional plugin-view fields normalized.
98
127
  */
99
128
  export declare function parseBootManifest(wire: unknown): BootManifest;
100
- /** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff). */
101
- export interface ClientPluginHandoff {
129
+ /** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */
130
+ export interface ClientBundleRegistration {
102
131
  /** Plugin id (package name) — the registration key; must match the graph row being executed. */
103
132
  id: string;
104
133
  /**
@@ -108,27 +137,45 @@ export interface ClientPluginHandoff {
108
137
  */
109
138
  factory: (require: (spec: string) => unknown) => Record<string, unknown>;
110
139
  }
111
- /** Window API of the web boot protocol: the host-injected graph, registration sink, and kernel handoff slot. */
140
+ /** Inputs passed by the web entry when it creates the client module system. */
141
+ export interface ClientModuleCreateOptions {
142
+ /** Raw Host-injected boot graph; the modules bundle owns validation and projection. */
143
+ boot: unknown;
144
+ /** Module-table seed: platform-singleton specifier → shell instance. */
145
+ staticModules: Record<string, unknown>;
146
+ /** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
147
+ loadBundle?: (url: string) => Promise<void>;
148
+ }
149
+ /** The modules bundle after its factory has been materialized by the HTML bootstrap facade. */
150
+ export interface ClientBootstrapModule {
151
+ /** Graph/module id carried by the modules bundle registration. */
152
+ id: string;
153
+ /** Materialized exports reused when Cordis later activates the modules entry. */
154
+ exports: Record<string, unknown>;
155
+ }
156
+ /** Stable page-global facade: queues early bundle registrations, then registers them live. */
157
+ export interface ClientModuleLoaderTarget {
158
+ /** Queue before {@link create}; live registration after it returns. */
159
+ mode: 'queue' | 'live';
160
+ /** Registrations submitted by parser-preloaded scripts before the module system exists. */
161
+ pendingQueue: ClientBundleRegistration[];
162
+ /** Queue or immediately register one bundle factory according to {@link mode}. */
163
+ load(registration: ClientBundleRegistration): void;
164
+ /** Create the module system exactly once from the parser-preloaded modules bundle. */
165
+ create(options: ClientModuleCreateOptions): ClientModuleSystem;
166
+ }
167
+ /** Window API of the web boot protocol: the host-injected graph and registration facade. */
112
168
  export interface DshWindow {
113
169
  /** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
114
170
  __DSH_BOOT__?: unknown;
115
- /** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor. */
116
- __ModuleLoader__?: {
117
- load(handoff: ClientPluginHandoff): void;
118
- };
119
- /**
120
- * Kernel handoff slot: the shell kernel stores the instance here right
121
- * after construction (before cordis exists) so the `./client` wrapper
122
- * plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
123
- * time = kernel sequencing bug, thrown loud.
124
- */
125
- __DSH_MODULES__?: ClientModuleSystem;
171
+ /** HTML-installed facade: a pending registration queue, then the live module-system target. */
172
+ __ModuleLoader__?: ClientModuleLoaderTarget;
126
173
  }
127
174
  /** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph boundary, flat today). */
128
175
  export interface ClientModuleRecord {
129
176
  /** Module id (entry name / package name). */
130
177
  id: string;
131
- /** Materialized exports (`module.exports` from a factory, or a statically registered shell module). */
178
+ /** Materialized exports (`module.exports` from a factory or bootstrap registration). */
132
179
  exports: unknown;
133
180
  /** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
134
181
  styles: string[];
@@ -143,6 +190,8 @@ export interface ClientModuleRecord {
143
190
  export interface ClientModuleLoader {
144
191
  /** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
145
192
  version: 'client';
193
+ /** Parsed Host boot graph shared with the web entry after module-system creation. */
194
+ manifest: BootManifest;
146
195
  /** Materialized-module registry: id → record. The governance-side read API for entry exports. */
147
196
  loadCache: Map<string, ClientModuleRecord>;
148
197
  /**
@@ -156,34 +205,34 @@ export interface ClientModuleLoader {
156
205
  */
157
206
  import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>;
158
207
  /**
159
- * Register a shell-own module (app-shell — code that ships inside the shell
160
- * bundle and never arrives as a plugin bundle).
161
- * @param id - entry name (shell-owned pseudo id).
162
- * @param module - the statically imported module namespace.
163
- */
164
- registerStatic(id: string, module: unknown): void;
165
- /**
166
- * Stage-one arrival: load the entry's script to register its factory (no
167
- * materialization — module side effects wait for import).
168
- * No-op for static-registered ids and ids whose factory is already
169
- * registered; concurrent calls share one in-flight task. To force a fresh
170
- * load (HMR), {@link invalidate} first.
208
+ * Stage-one arrival: load the entry's declared dynamic requests, then its
209
+ * own script, to register their factories (no materialization — module side
210
+ * effects wait for import).
211
+ * No-op for materialized bootstrap ids. A registered graph row still
212
+ * registers any unresolved declared requests before skipping its own script;
213
+ * concurrent arrivals share one in-flight task. To force a fresh load (HMR),
214
+ * {@link invalidate} first.
171
215
  * @param id - graph entry name.
172
216
  */
173
217
  prefetch(id: string): Promise<void>;
174
218
  /**
175
- * Full reset of one module: drop its registered factory and materialized
176
- * record so the next prefetch/import reloads it (the HMR invalidation hook).
219
+ * Full reset of one non-bootstrap module: drop its registered factory and
220
+ * materialized record so the next prefetch/import reloads it (the HMR
221
+ * invalidation hook). The bootstrap module remains materialized.
177
222
  * @param id - entry name to invalidate.
178
223
  */
179
224
  invalidate(id: string): void;
180
225
  }
181
- /** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
226
+ /** Internal construction inputs assembled by the modules bundle's bootstrap export. */
182
227
  export interface ClientModuleSystemOptions {
183
- /** Boot rows in the module-table view (from {@link parseBootManifest}). */
184
- modules: BootModuleRow[];
228
+ /** Parsed boot graph owned by the resulting module system. */
229
+ manifest: BootManifest;
185
230
  /** Module-table seed: platform-singleton specifier → shell instance. */
186
231
  staticModules: Record<string, unknown>;
232
+ /** Stable HTML-installed registration facade to switch from queue to live mode. */
233
+ registrationTarget: ClientModuleLoaderTarget;
234
+ /** Already-materialized modules bundle consumed while creating the system. */
235
+ bootstrapModule: ClientBootstrapModule;
187
236
  /** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
188
237
  loadBundle?: (url: string) => Promise<void>;
189
238
  }
@@ -1,22 +1,18 @@
1
- /**
2
- * ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
3
- * contract. The conceptual contract (lazy CJS model, resolution branch order) is
4
- * documented on the public interfaces in `./manifest.ts`; this file owns the
5
- * state tables and the load/materialize machinery.
6
- */
7
- import type { ClientModuleLoader, ClientModuleRecord, ClientModuleSystemOptions } from './manifest.ts';
1
+ import type { BootManifest, ClientModuleLoader, ClientModuleRecord, ClientModuleSystemOptions } from './manifest.ts';
8
2
  /**
9
3
  * The client module system: state tables plus the arrival/materialization
10
4
  * machinery implementing {@link ClientModuleLoader} (whose members carry the
11
- * contract documentation). Construction indexes the boot rows and installs the
12
- * `window.__ModuleLoader__` registration sink — once per page.
5
+ * contract documentation). Construction indexes the boot rows, retains the
6
+ * already-materialized bootstrap module, and switches the HTML-installed
7
+ * loader facade from its pending queue to live registration.
13
8
  */
14
9
  export declare class ClientModuleSystem implements ClientModuleLoader {
15
10
  readonly version = "client";
11
+ readonly manifest: BootManifest;
16
12
  readonly loadCache: Map<string, ClientModuleRecord>;
17
13
  private readonly seed;
18
- private readonly statics;
19
14
  private readonly factories;
15
+ private readonly bootstrapIds;
20
16
  /** In-flight prefetch (script load) per id; concurrent callers share it. */
21
17
  private readonly pendingArrival;
22
18
  /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
@@ -25,23 +21,25 @@ export declare class ClientModuleSystem implements ClientModuleLoader {
25
21
  private readonly loadBundle;
26
22
  /**
27
23
  * Build the module system over the parsed boot rows.
28
- * @param options - Module rows, module-table staticModules, and bundle-load hook.
24
+ * @param options - Parsed graph, platform seed, bootstrap module, registration facade, and transport.
29
25
  */
30
26
  constructor(options: ClientModuleSystemOptions);
27
+ /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
28
+ private register;
31
29
  /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
32
30
  private arrive;
31
+ /** Register each unresolved dynamic request before registering its consumer. */
32
+ private arriveGraphRow;
33
33
  /** Materialize a registered factory (synchronous; memoized in loadCache). */
34
34
  private materialize;
35
35
  /**
36
- * The synchronous require answered to factories: seed → static → memoized
37
- * record → registered factory (recursive materialization — this is what
38
- * makes load order self-resolving). Fetching is async and therefore
39
- * unreachable from here; an unregistered plugin specifier is loud (and a
40
- * cross-plugin value import is already a build error upstream).
36
+ * The synchronous require answered to factories: seed → memoized record →
37
+ * registered factory. Fetching is async and therefore unreachable
38
+ * from here; an external dynamic package must have arrived before its
39
+ * consumer materializes.
41
40
  */
42
41
  private makeRequire;
43
42
  import(specifier: string): Promise<unknown>;
44
- registerStatic(id: string, module: unknown): void;
45
43
  prefetch(id: string): Promise<void>;
46
44
  invalidate(id: string): void;
47
45
  }
@@ -2,10 +2,11 @@
2
2
  * Node half of the client module system (`dsh.client` dual-face package): scans
3
3
  * the host Loader's entries for packages declaring `dsh.client`, composes the
4
4
  * `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
5
- * in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
6
- * map, taps the index render to inject the boot manifest, and provides the
7
- * `clientModuleHost` service (the HMR node half's registration/notification
8
- * face).
5
+ * in `./client/manifest.ts`) in module-graph order, serves
6
+ * `/plugins/<id>/client.js` and its source map, taps the index render to
7
+ * inject the boot manifest plus the parser-blocking bootstrap preloads, and
8
+ * provides the `clientModuleHost` service (the HMR node half's
9
+ * registration/notification face).
9
10
  *
10
11
  * Scanning is incremental per package — there is no full-rescan code path.
11
12
  * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -21,7 +22,8 @@
21
22
  */
22
23
  import { Service } from '@deepseek-ai/cordis';
23
24
  import type { Context } from '@deepseek-ai/cordis';
24
- import type { WebBootGraph } from './client/manifest.ts';
25
+ import type { WebBootEntry, WebBootGraph } from './client/manifest.ts';
26
+ export { stripClientSuffix } from './client/manifest.ts';
25
27
  export type { BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph, } from './client/manifest.ts';
26
28
  declare module '@deepseek-ai/cordis' {
27
29
  interface Context {
@@ -30,9 +32,24 @@ declare module '@deepseek-ai/cordis' {
30
32
  }
31
33
  }
32
34
  /**
33
- * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
34
- * first script in <head> (before the shell bundle reads it). `<` is escaped in
35
- * the JSON so plugin-controlled strings cannot break out of the script element.
35
+ * Order composed rows so every requested dynamic package precedes its
36
+ * consumers. An `external` specifier is either the package row it names
37
+ * (`<pkg>/client` aliases the bare package) or a static-table name that adds no
38
+ * graph edge.
39
+ * @param entries - composed rows in scan order.
40
+ * @returns the same rows reordered; scan order breaks every tie.
41
+ * @throws {Error} when a row requests itself or when the module graph has a
42
+ * cycle; the message lists the packages on it.
43
+ */
44
+ export declare function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEntry[];
45
+ /**
46
+ * Inject the boot protocol into index.html. The inline registration queue precedes
47
+ * blocking classic scripts for modules' and runtime's ordinary
48
+ * `lib/client.js` artifacts. Its `create()` method materializes the modules
49
+ * bundle, delegates construction to that bundle, and leaves the same facade
50
+ * in live-registration mode. The graph script follows before the shell reads
51
+ * it. `<` is escaped in JSON so a plugin-controlled string cannot break out
52
+ * of the script element.
36
53
  * @param html - the index.html source.
37
54
  * @param graph - the composed entry graph.
38
55
  * @returns the html with the graph script injected.
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.0-rc.6",
4
+ "version": "0.1.0-rc.8",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "devDependencies": {
41
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
42
41
  "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
43
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
42
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
43
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
44
44
  "@deepseek-ai/cordis": "^4.0.1"
45
45
  },
46
46
  "files": [
@@ -50,8 +50,10 @@
50
50
  "lib/types/**/*.d.ts"
51
51
  ],
52
52
  "peerDependencies": {
53
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
54
- "@deepseek-ai/cordis": "^4.0.1"
53
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
54
+ "@deepseek-ai/cordis": "^4.0.1",
55
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
56
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8"
55
57
  },
56
58
  "scripts": {
57
59
  "bundle": "tsdown",