@deepseek-ai/dsh-client-modules 0.1.1-rc.2 → 0.1.2-alpha.3

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/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
- import { createHash } from "node:crypto";
3
- import { readFileSync } from "node:fs";
4
- import { readFile } from "node:fs/promises";
5
- import { dirname, join } from "node:path";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { Service } from "@deepseek-ai/cordis";
7
7
  //#region lib/types/client/manifest.js
8
8
  /**
@@ -67,11 +67,12 @@ function stripClientSuffix(spec) {
67
67
  * Node half of the client module system (`dsh.client` dual-face package): scans
68
68
  * the host Loader's entries for packages declaring `dsh.client`, composes the
69
69
  * `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
70
- * in `./client/manifest.ts`) in module-graph order, serves
71
- * `/plugins/<id>/client.js` and its source map, contributes the boot manifest
72
- * plus the parser-blocking bootstrap preloads to the webserver's index
73
- * injection table, and provides the `clientModuleHost` service (the HMR node
74
- * half's registration/notification face).
70
+ * in `./client/manifest.ts`) in module-graph order, serves one-or-more-plugin
71
+ * combo scripts plus their source maps,
72
+ * contributes the registration facade, application preloads, bootstrap scripts,
73
+ * and graph to the webserver's index injection table, and provides the
74
+ * `clientModuleHost` service (the HMR node half's registration/notification
75
+ * face).
75
76
  *
76
77
  * Scanning is incremental per package — there is no full-rescan code path.
77
78
  * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -79,9 +80,10 @@ function stripClientSuffix(spec) {
79
80
  * against the live loader entries. The activation pass seeds the same dirty
80
81
  * set with all current entries and flushes synchronously, so first scan and
81
82
  * steady state share one implementation. Package metadata (including the
82
- * negative "not a client package" verdict) is cached per name and never
83
- * expires — plugin-set changes take effect on restart; bundle content
84
- * changes reach the graph only through
83
+ * negative "not a client package" verdict) is cached per Loader specifier and
84
+ * owning-tree base URL until restart. The manifest package name identifies
85
+ * the browser module; distinct active Loader sources for that package are a
86
+ * composition error. Bundle content changes reach the graph only through
85
87
  * {@link ClientModuleRegistry.rebuilt}.
86
88
  * @module @deepseek-ai/dsh-client-modules
87
89
  */
@@ -116,6 +118,24 @@ var ClientPackageCompositionError = class extends AggregateError {
116
118
  super(failures, lines.join("\n"));
117
119
  }
118
120
  };
121
+ /** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
122
+ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
123
+ /** Generated request URLs stay below conservative browser and intermediary request-target limits. */
124
+ const MAX_COMBO_URL_BYTES = 3 * 1024;
125
+ const HASH_REVISION_LENGTH = 12;
126
+ const COMBO_REVISION_PLACEHOLDER = "0".repeat(HASH_REVISION_LENGTH);
127
+ /** Source-map trailer emitted by tsdown at the end of every client bundle. */
128
+ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/;
129
+ /** Debugger source name appended to page bundles in the WebWorker image. */
130
+ const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/;
131
+ /** Return a bare package-root specifier, excluding package subpaths and path-like entries. */
132
+ function exactPackageSpecifier(specifier) {
133
+ if (specifier.startsWith("@")) {
134
+ const parts = specifier.split("/");
135
+ return parts.length === 2 && parts.every(Boolean) ? specifier : void 0;
136
+ }
137
+ return specifier.length > 0 && !specifier.includes("/") ? specifier : void 0;
138
+ }
119
139
  /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
120
140
  function parseDshClient(pkgName, value) {
121
141
  if (value === void 0) return void 0;
@@ -144,15 +164,172 @@ function clientExportOf(pkgName, exportsField) {
144
164
  }
145
165
  throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`);
146
166
  }
147
- /** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
167
+ /** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
148
168
  function shortHash(input) {
149
- return createHash("sha1").update(input).digest("hex").slice(0, 12);
169
+ return createHash("sha1").update(input).digest("hex").slice(0, HASH_REVISION_LENGTH);
170
+ }
171
+ /** Hash several response fields without allowing bytes to move across field boundaries. */
172
+ function framedHash(domain, parts) {
173
+ const hash = createHash("sha1").update(domain).update("\0");
174
+ for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part);
175
+ return hash.digest("hex").slice(0, HASH_REVISION_LENGTH);
176
+ }
177
+ /** Hash every artifact input served after HMR observes one plugin change. */
178
+ function artifactRevision(bundle, sourceMap) {
179
+ return framedHash("plugin-artifact", sourceMap === void 0 ? [bundle] : [bundle, sourceMap.body]);
180
+ }
181
+ /** Address one ordered plugin-file list through the shared combo route. */
182
+ function comboUrl(ids, rev, sourceMap = false) {
183
+ return `/plugins/??${ids.map((id) => `${id}/client.js${sourceMap ? ".map" : ""}`).join(",")}&rev=${rev}`;
184
+ }
185
+ /** Measure the longer map-form URL used to partition a startup resource list. */
186
+ function projectedComboUrlBytes(records) {
187
+ return Buffer.byteLength(comboUrl(records.map((record) => record.entry.id), COMBO_REVISION_PLACEHOLDER, true));
188
+ }
189
+ /** Partition one phase in graph order without allowing a generated URL above the protocol limit. */
190
+ function partitionComboRecords(records) {
191
+ const chunks = [];
192
+ let current = [];
193
+ for (const record of records) {
194
+ const candidate = [...current, record];
195
+ if (projectedComboUrlBytes(candidate) <= MAX_COMBO_URL_BYTES) {
196
+ current = candidate;
197
+ continue;
198
+ }
199
+ if (current.length === 0) throw new Error(`client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`);
200
+ chunks.push(current);
201
+ current = [record];
202
+ if (projectedComboUrlBytes(current) > MAX_COMBO_URL_BYTES) throw new Error(`client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`);
203
+ }
204
+ if (current.length > 0) chunks.push(current);
205
+ return chunks;
206
+ }
207
+ /** Remove bundle-local debug directives and retain their stable generated-file name. */
208
+ function comboSource(record) {
209
+ let source = record.bundle.toString("utf8");
210
+ const sourceUrl = SOURCE_URL_TRAILER.exec(source)?.[1];
211
+ source = source.replace(SOURCE_URL_TRAILER, "").replace(SOURCE_MAP_TRAILER, "");
212
+ if (!source.endsWith("\n")) source += "\n";
213
+ const fallbackSource = sourceUrl === void 0 ? `/plugins/${record.entry.id}/client.js` : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`;
214
+ return {
215
+ source,
216
+ fallbackSource
217
+ };
218
+ }
219
+ /** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
220
+ function comboScript(input, sourceMapUrl) {
221
+ return Buffer.from(sourceMapUrl === void 0 ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`);
222
+ }
223
+ /** Parse an optional source-map artifact; missing maps do not prevent plugin execution. */
224
+ function sourceMapSnapshot(clientPath) {
225
+ let body;
226
+ try {
227
+ body = readFileSync(`${clientPath}.map`);
228
+ } catch (error) {
229
+ if (error.code === "ENOENT") return void 0;
230
+ throw error;
231
+ }
232
+ const value = JSON.parse(body.toString("utf8"));
233
+ const parsed = typeof value === "object" && value !== null ? value : void 0;
234
+ if (parsed === void 0 || parsed.version !== 3 || !Array.isArray(parsed.sources) || parsed.sources.some((source) => typeof source !== "string") || !Array.isArray(parsed.names) || parsed.names.some((name) => typeof name !== "string") || typeof parsed.mappings !== "string") throw new Error(`client-modules: ${clientPath}.map is not a regular Source Map v3 object`);
235
+ return {
236
+ body,
237
+ parsed
238
+ };
239
+ }
240
+ /** Count generated lines while assembling indexed-map section offsets. */
241
+ function newlineCount(value) {
242
+ let count = 0;
243
+ for (const char of value) if (char === "\n") count += 1;
244
+ return count;
245
+ }
246
+ /** Resolve section sources against their original per-plugin map URL before combo relocation. */
247
+ function comboSectionMap(record) {
248
+ const original = record.sourceMap?.parsed;
249
+ /* v8 ignore next -- callers add sections only for records with a source map. */
250
+ if (original === void 0) throw new Error(`client-modules: source map missing for ${record.entry.id}`);
251
+ const sourcePaths = original.sources;
252
+ const sourceRoot = typeof original.sourceRoot === "string" ? original.sourceRoot : "";
253
+ const base = new URL(`/plugins/${record.entry.id}/client.js.map`, "http://dsh.invalid");
254
+ const relocated = sourcePaths.map((source) => {
255
+ const separator = sourceRoot !== "" && !sourceRoot.endsWith("/") && !source.startsWith("/") ? "/" : "";
256
+ const resolved = new URL(`${sourceRoot}${separator}${source}`, base);
257
+ return resolved.origin === base.origin ? `${resolved.pathname}${resolved.search}${resolved.hash}` : resolved.href;
258
+ });
259
+ const section = {
260
+ ...original,
261
+ sources: relocated
262
+ };
263
+ delete section.sourceRoot;
264
+ return section;
265
+ }
266
+ /** Map each generated line to the same line in a bundled JavaScript source. */
267
+ function identitySectionMap(source, sourceUrl) {
268
+ const mappings = Array.from({ length: newlineCount(source) }, (_, index) => index === 0 ? "AAAA" : "AACA").join(";");
269
+ return {
270
+ version: 3,
271
+ names: [],
272
+ sources: [sourceUrl],
273
+ sourcesContent: [source],
274
+ mappings
275
+ };
276
+ }
277
+ /** Concatenate one or more factory registrations and compose their maps as indexed sections. */
278
+ function buildCombo(records, revision) {
279
+ let source = "";
280
+ const sections = [];
281
+ let line = 0;
282
+ for (const record of records) {
283
+ const prepared = comboSource(record);
284
+ const section = record.sourceMap === void 0 ? identitySectionMap(prepared.source, prepared.fallbackSource) : comboSectionMap(record);
285
+ sections.push({
286
+ offset: {
287
+ line,
288
+ column: 0
289
+ },
290
+ map: section
291
+ });
292
+ const bundle = `${prepared.source};\n`;
293
+ source += bundle;
294
+ line += newlineCount(bundle);
295
+ }
296
+ const sourceMap = Buffer.from(`${JSON.stringify({
297
+ version: 3,
298
+ file: "client.js",
299
+ sections
300
+ })}\n`);
301
+ const sourceBytes = Buffer.from(source);
302
+ const rev = revision ?? framedHash("combo", [sourceBytes, sourceMap]);
303
+ const entries = records.map((record) => record.entry.id);
304
+ const url = comboUrl(entries, rev);
305
+ const sourceMapUrl = comboUrl(entries, rev, true);
306
+ return {
307
+ url,
308
+ rev,
309
+ entries,
310
+ script: comboScript(source, sourceMapUrl),
311
+ sourceMap,
312
+ sourceMapUrl
313
+ };
314
+ }
315
+ /** Add initial-load scheduling metadata to a combo artifact. */
316
+ function buildBatch(phase, records) {
317
+ const artifact = buildCombo(records);
318
+ return {
319
+ ...artifact,
320
+ descriptor: {
321
+ phase,
322
+ url: artifact.url,
323
+ rev: artifact.rev,
324
+ entries: artifact.entries
325
+ }
326
+ };
150
327
  }
151
328
  /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
152
329
  function graphRow(id, rev, fields) {
153
330
  return {
154
331
  id,
155
- url: `/plugins/${id}/client.js?rev=${rev}`,
332
+ url: comboUrl([id], rev),
156
333
  rev,
157
334
  ...fields.inject !== void 0 ? { inject: fields.inject } : {},
158
335
  ...fields.immediately ? { immediately: true } : {},
@@ -194,17 +371,18 @@ function orderByModuleGraph(entries) {
194
371
  }
195
372
  /** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
196
373
  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"];
374
+ /** Dynamic bundles grouped into the parser bootstrap batch before the Vite shell. */
375
+ const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID];
199
376
  /**
200
377
  * The boot protocol as index injection rows. The inline registration queue
201
- * precedes blocking classic scripts for modules' and runtime's ordinary
202
- * `lib/client.js` artifacts. Its `create()` method materializes the modules
378
+ * precedes the application-batch preload and the blocking bootstrap batch. Its
379
+ * `create()` method materializes the modules
203
380
  * bundle, delegates construction to that bundle, and leaves the same facade
204
381
  * in live-registration mode. The graph global follows before the shell reads
205
382
  * it.
206
383
  * @param graph - the composed entry graph.
207
- * @returns head rows in execution order: queue script, preload scripts, graph global.
384
+ * @returns head rows in execution order: queue script, application preloads,
385
+ * blocking bootstrap scripts, graph global.
208
386
  */
209
387
  function bootInjections(graph) {
210
388
  const queue = `(()=>{
@@ -229,24 +407,28 @@ window.__ModuleLoader__={
229
407
  }
230
408
  }
231
409
  })()`;
232
- const preload = PARSER_PRELOAD_IDS.map((id) => graph.entries.find((entry) => entry.id === id)).filter((entry) => entry !== void 0).map((entry) => ({
410
+ const bootstrap = graph.batches.filter((batch) => batch.phase === "bootstrap");
411
+ const application = graph.batches.filter((batch) => batch.phase === "application");
412
+ const rows = [{
413
+ kind: "script",
414
+ placement: "head",
415
+ text: queue
416
+ }];
417
+ for (const batch of application) rows.push({
418
+ kind: "script-preload",
419
+ src: batch.url
420
+ });
421
+ for (const batch of bootstrap) rows.push({
233
422
  kind: "script-src",
234
423
  placement: "head",
235
- src: entry.url
236
- }));
237
- return [
238
- {
239
- kind: "script",
240
- placement: "head",
241
- text: queue
242
- },
243
- ...preload,
244
- {
245
- kind: "global",
246
- name: "__DSH_BOOT__",
247
- value: graph
248
- }
249
- ];
424
+ src: batch.url
425
+ });
426
+ rows.push({
427
+ kind: "global",
428
+ name: "__DSH_BOOT__",
429
+ value: graph
430
+ });
431
+ return rows;
250
432
  }
251
433
  /**
252
434
  * The web plugin table service: incremental `dsh.client` scan + wire composition
@@ -258,11 +440,17 @@ window.__ModuleLoader__={
258
440
  var ClientModuleRegistry = class extends Service {
259
441
  static inject = ["webServer", "loader"];
260
442
  table = /* @__PURE__ */ new Map();
443
+ sources = /* @__PURE__ */ new Map();
261
444
  pkgMeta = /* @__PURE__ */ new Map();
262
445
  rebuildListeners = /* @__PURE__ */ new Set();
263
446
  graphListeners = /* @__PURE__ */ new Set();
264
447
  dirty = /* @__PURE__ */ new Set();
265
- resolvePkgJson;
448
+ initialRevisionNonce = randomBytes(8).toString("hex");
449
+ nextInitialRevision = 0;
450
+ responses = /* @__PURE__ */ new Map();
451
+ batchResponses = /* @__PURE__ */ new Map();
452
+ /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
453
+ previousBatchResponses = /* @__PURE__ */ new Map();
266
454
  flushQueued = false;
267
455
  composed;
268
456
  /**
@@ -271,9 +459,6 @@ var ClientModuleRegistry = class extends Service {
271
459
  */
272
460
  constructor(ctx) {
273
461
  super(ctx, "clientModules");
274
- if (ctx.baseUrl === void 0) throw new Error("client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages");
275
- const require = createRequire(ctx.baseUrl);
276
- this.resolvePkgJson = (spec) => require.resolve(`${spec}/package.json`);
277
462
  ctx.on("internal/plugin", (fiber) => {
278
463
  const entryName = fiber.entry?.options.name;
279
464
  if (entryName === void 0) return;
@@ -317,6 +502,18 @@ var ClientModuleRegistry = class extends Service {
317
502
  return this.table.get(id)?.meta.clientPath;
318
503
  }
319
504
  /**
505
+ * Filesystem baseline captured before an entry's current bytes were read.
506
+ * HMR compares it with the live files when installing a watch, so a write
507
+ * between startup composition and watch installation cannot disappear into
508
+ * the watcher's initial state.
509
+ * @param id - entry id (package name).
510
+ * @returns the path and baseline, or undefined for an unknown id.
511
+ */
512
+ artifactBaseline(id) {
513
+ const baseline = this.table.get(id)?.baseline;
514
+ return baseline === void 0 ? void 0 : { ...baseline };
515
+ }
516
+ /**
320
517
  * Re-hash one bundle (the HMR watch's registration hook — the only entry
321
518
  * point through which bundle content changes reach the graph).
322
519
  * @param id - entry id (package name).
@@ -325,9 +522,16 @@ var ClientModuleRegistry = class extends Service {
325
522
  rebuilt(id) {
326
523
  const record = this.table.get(id);
327
524
  if (record === void 0) return void 0;
328
- const rev = shortHash(readFileSync(record.meta.clientPath));
525
+ const baseline = this.captureArtifactBaseline(record.meta.clientPath);
526
+ const bundle = readFileSync(record.meta.clientPath);
527
+ const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath);
528
+ const rev = artifactRevision(bundle, sourceMap);
529
+ record.baseline = baseline;
329
530
  if (rev === record.entry.rev) return rev;
330
531
  record.entry = graphRow(id, rev, record.meta);
532
+ record.bundle = bundle;
533
+ if (sourceMap === void 0) delete record.sourceMap;
534
+ else record.sourceMap = sourceMap;
331
535
  this.composed = this.compose();
332
536
  for (const notify of this.rebuildListeners) try {
333
537
  notify(id, rev);
@@ -362,9 +566,46 @@ var ClientModuleRegistry = class extends Service {
362
566
  }
363
567
  compose() {
364
568
  const entries = orderByModuleGraph([...this.table.values()].map((record) => record.entry));
569
+ const bootstrap = PARSER_PRELOAD_IDS.map((id) => this.table.get(id)).filter((record) => record !== void 0);
570
+ const bootstrapIds = new Set(bootstrap.map((record) => record.entry.id));
571
+ const application = entries.filter((entry) => !bootstrapIds.has(entry.id)).map((entry) => this.table.get(entry.id)).filter((record) => record !== void 0);
572
+ const artifacts = [];
573
+ for (const records of partitionComboRecords(bootstrap)) artifacts.push(buildBatch("bootstrap", records));
574
+ for (const records of partitionComboRecords(application)) artifacts.push(buildBatch("application", records));
575
+ const batchResponses = /* @__PURE__ */ new Map();
576
+ for (const artifact of artifacts) {
577
+ batchResponses.set(artifact.descriptor.url, {
578
+ body: artifact.script,
579
+ contentType: "text/javascript; charset=utf-8"
580
+ });
581
+ batchResponses.set(artifact.sourceMapUrl, {
582
+ body: artifact.sourceMap,
583
+ contentType: "application/json; charset=utf-8"
584
+ });
585
+ }
586
+ const responses = new Map(batchResponses);
587
+ for (const record of this.table.values()) {
588
+ const artifact = buildCombo([record], record.entry.rev);
589
+ responses.set(artifact.url, {
590
+ body: artifact.script,
591
+ contentType: "text/javascript; charset=utf-8"
592
+ });
593
+ responses.set(artifact.sourceMapUrl, {
594
+ body: artifact.sourceMap,
595
+ contentType: "application/json; charset=utf-8"
596
+ });
597
+ }
598
+ this.previousBatchResponses = this.batchResponses;
599
+ this.batchResponses = batchResponses;
600
+ this.responses = responses;
601
+ const batches = artifacts.map((artifact) => artifact.descriptor);
365
602
  return {
366
- rev: shortHash(JSON.stringify(entries)),
367
- entries
603
+ rev: shortHash(JSON.stringify({
604
+ entries,
605
+ batches
606
+ })),
607
+ entries,
608
+ batches
368
609
  };
369
610
  }
370
611
  notifyGraphChanged() {
@@ -374,64 +615,202 @@ var ClientModuleRegistry = class extends Service {
374
615
  this.ctx.logger.error(error);
375
616
  }
376
617
  }
377
- resolveMeta(pkgName) {
378
- const cached = this.pkgMeta.get(pkgName);
618
+ resolveMeta(loaderName, baseUrl) {
619
+ const sourceKey = this.sourceKey(loaderName, baseUrl);
620
+ const cached = this.pkgMeta.get(sourceKey);
379
621
  if (cached !== void 0) return cached;
380
- let pkgPath;
381
- try {
382
- pkgPath = this.resolvePkgJson(pkgName);
383
- } catch {
384
- this.pkgMeta.set(pkgName, null);
622
+ const located = this.locatePkgJson(loaderName, baseUrl);
623
+ if (located === void 0) {
624
+ this.pkgMeta.set(sourceKey, null);
385
625
  return null;
386
626
  }
627
+ const { packageName, path: pkgPath } = located;
387
628
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
388
629
  const dsh = pkg.dsh;
389
- const decl = parseDshClient(pkgName, dsh !== null && typeof dsh === "object" ? dsh.client : void 0);
630
+ const decl = parseDshClient(packageName, dsh !== null && typeof dsh === "object" ? dsh.client : void 0);
390
631
  if (decl === void 0 || decl.platform !== "web") {
391
- this.pkgMeta.set(pkgName, null);
632
+ this.pkgMeta.set(sourceKey, null);
392
633
  return null;
393
634
  }
394
- const clientRel = clientExportOf(pkgName, pkg.exports);
395
- if (clientRel === void 0) throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`);
396
- const meta = {
397
- clientPath: join(dirname(pkgPath), clientRel),
398
- ...decl.inject !== void 0 ? { inject: decl.inject } : {},
399
- external: decl.external ?? [],
400
- immediately: decl.immediately === true
635
+ const clientRel = clientExportOf(packageName, pkg.exports);
636
+ if (clientRel === void 0) throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`);
637
+ const resolved = {
638
+ packageName,
639
+ meta: {
640
+ clientPath: join(dirname(pkgPath), clientRel),
641
+ ...decl.inject !== void 0 ? { inject: decl.inject } : {},
642
+ external: decl.external ?? [],
643
+ immediately: decl.immediately === true
644
+ }
645
+ };
646
+ this.pkgMeta.set(sourceKey, resolved);
647
+ return resolved;
648
+ }
649
+ /**
650
+ * Locate the manifest of the package the Loader mounts for a row. The row's
651
+ * module location is authoritative: the specifier resolves through the same
652
+ * Loader resolution that imported the row's host half — including any
653
+ * active ESM hooks — and the nearest ancestor manifest declaring the name
654
+ * owns the module. Tree-anchored `require` resolution remains only for
655
+ * runtimes without Node internals.
656
+ * @param loaderName - module specifier of the loader row.
657
+ * @param baseUrl - resolution base of the tree that owns the row.
658
+ * @returns the manifest path, or `undefined` when the name resolves to no package root.
659
+ */
660
+ locatePkgJson(loaderName, baseUrl) {
661
+ if (loaderName.startsWith("cordis:")) return void 0;
662
+ const pathLike = loaderName.startsWith(".") || loaderName.startsWith("file:") || isAbsolute(loaderName);
663
+ const expectedPackageName = pathLike ? void 0 : exactPackageSpecifier(loaderName);
664
+ if (!pathLike && expectedPackageName === void 0) return void 0;
665
+ const internal = this.ctx.loader.internal;
666
+ if (internal === void 0 || typeof Reflect.get(internal, "resolveSync") !== "function") {
667
+ if (expectedPackageName === void 0) {
668
+ const moduleUrl = loaderName.startsWith("file:") ? loaderName : isAbsolute(loaderName) ? pathToFileURL(loaderName).href : new URL(loaderName, baseUrl).href;
669
+ return this.nearestPackage(moduleUrl);
670
+ }
671
+ try {
672
+ return {
673
+ path: createRequire(baseUrl).resolve(`${expectedPackageName}/package.json`),
674
+ packageName: expectedPackageName
675
+ };
676
+ } catch {
677
+ return;
678
+ }
679
+ }
680
+ let moduleUrl;
681
+ try {
682
+ moduleUrl = internal.version === "v2" ? internal.resolveSync(baseUrl, {
683
+ specifier: loaderName,
684
+ attributes: {}
685
+ }).url : internal.resolveSync(loaderName, baseUrl, {}).url;
686
+ } catch {
687
+ return;
688
+ }
689
+ return this.nearestPackage(moduleUrl, expectedPackageName);
690
+ }
691
+ nearestPackage(moduleUrl, expectedPackageName) {
692
+ if (!moduleUrl.startsWith("file:")) return void 0;
693
+ let dir = dirname(fileURLToPath(moduleUrl));
694
+ while (true) {
695
+ const candidate = join(dir, "package.json");
696
+ if (existsSync(candidate)) try {
697
+ const name = JSON.parse(readFileSync(candidate, "utf8")).name;
698
+ if (typeof name === "string" && (expectedPackageName === void 0 || name === expectedPackageName)) return {
699
+ path: candidate,
700
+ packageName: name
701
+ };
702
+ } catch {}
703
+ const parent = dirname(dir);
704
+ if (parent === dir) break;
705
+ dir = parent;
706
+ }
707
+ }
708
+ sourceKey(loaderName, baseUrl) {
709
+ return `${baseUrl}\0${loaderName}`;
710
+ }
711
+ /** Capture the bundle stats before reading its bytes. */
712
+ captureArtifactBaseline(clientPath) {
713
+ const bundle = statSync(clientPath);
714
+ return {
715
+ path: clientPath,
716
+ mtimeMs: bundle.mtimeMs,
717
+ size: bundle.size
401
718
  };
402
- this.pkgMeta.set(pkgName, meta);
403
- return meta;
719
+ }
720
+ /** Allocate an opaque initial row revision without inspecting artifact bytes. */
721
+ allocateInitialRevision() {
722
+ return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`;
404
723
  }
405
724
  /**
406
- * Read the activation-time bundle revision.
725
+ * Read the activation-time bundle and optional source-map snapshots.
407
726
  * @param pkgName - package that declares the client bundle.
408
727
  * @param clientPath - absolute path of the built client artifact.
409
- * @returns the bundle content's short hash for use as its revision.
728
+ * @returns the immutable bytes plus the pre-read filesystem baseline.
410
729
  * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
411
730
  */
412
- initialBundleRevision(pkgName, clientPath) {
731
+ initialBundleSnapshot(pkgName, clientPath) {
413
732
  try {
414
- return shortHash(readFileSync(clientPath));
733
+ const baseline = this.captureArtifactBaseline(clientPath);
734
+ const bundle = readFileSync(clientPath);
735
+ const sourceMap = this.readSourceMapSnapshot(clientPath);
736
+ return {
737
+ bundle,
738
+ baseline,
739
+ ...sourceMap === void 0 ? {} : { sourceMap }
740
+ };
415
741
  } catch (error) {
416
742
  if (error.code !== "ENOENT") throw error;
417
743
  throw new MissingClientBundleError(pkgName, clientPath, error);
418
744
  }
419
745
  }
420
- /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
421
- processOne(entryName) {
422
- let qualifies = false;
423
- for (const entry of this.ctx.loader.entries()) if (entry.options.name === entryName && entry.fiber !== void 0 && !entry.disabled) {
424
- qualifies = true;
425
- break;
746
+ /** Treat a missing, torn, or malformed development map as an identity-mapped artifact revision. */
747
+ readSourceMapSnapshot(clientPath) {
748
+ try {
749
+ return sourceMapSnapshot(clientPath);
750
+ } catch (error) {
751
+ this.ctx.logger.warn(error);
752
+ return;
753
+ }
754
+ }
755
+ /** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */
756
+ processOne(entryName, onError) {
757
+ const nextSources = /* @__PURE__ */ new Map();
758
+ for (const entry of this.ctx.loader.entries()) {
759
+ if (entry.options.name !== entryName || entry.fiber === void 0 || entry.disabled) continue;
760
+ const source = this.resolveSource(entry);
761
+ if (source !== void 0) nextSources.set(source.sourceKey, source);
762
+ }
763
+ const affectedPackages = /* @__PURE__ */ new Set();
764
+ for (const [sourceKey, source] of this.sources) {
765
+ if (source.loaderName !== entryName) continue;
766
+ affectedPackages.add(source.packageName);
767
+ if (!nextSources.has(sourceKey)) this.sources.delete(sourceKey);
768
+ }
769
+ for (const [sourceKey, source] of nextSources) {
770
+ affectedPackages.add(source.packageName);
771
+ this.sources.set(sourceKey, source);
772
+ }
773
+ let changed = false;
774
+ for (const packageName of affectedPackages) try {
775
+ if (this.reconcilePackage(packageName)) changed = true;
776
+ } catch (error) {
777
+ onError(error instanceof Error ? error : new Error(String(error)));
778
+ }
779
+ return changed;
780
+ }
781
+ resolveSource(entry) {
782
+ const loaderName = entry.options.name;
783
+ const baseUrl = entry.parent.tree.ctx.baseUrl;
784
+ if (baseUrl === void 0) throw new Error(`client-modules: loader entry ${loaderName} has no resolution base URL`);
785
+ const resolved = this.resolveMeta(loaderName, baseUrl);
786
+ if (resolved === null) return void 0;
787
+ return {
788
+ ...resolved,
789
+ loaderName,
790
+ baseUrl,
791
+ sourceKey: this.sourceKey(loaderName, baseUrl)
792
+ };
793
+ }
794
+ reconcilePackage(packageName) {
795
+ const sources = [];
796
+ for (const source of this.sources.values()) if (source.packageName === packageName) sources.push(source);
797
+ if (sources.length > 1) {
798
+ const locations = sources.map((source) => `${JSON.stringify(source.loaderName)} from ${source.baseUrl}`).join(", ");
799
+ throw new Error(`client-modules: package ${packageName} resolves from multiple active Loader sources: ${locations}; remove one entry`);
426
800
  }
427
- if (!qualifies) return this.table.delete(entryName);
428
- if (this.table.has(entryName)) return false;
429
- const meta = this.resolveMeta(entryName);
430
- if (meta === null) return false;
431
- const rev = this.initialBundleRevision(entryName, meta.clientPath);
432
- this.table.set(entryName, {
433
- entry: graphRow(entryName, rev, meta),
434
- meta
801
+ const source = sources[0];
802
+ if (source === void 0) return this.table.delete(packageName);
803
+ if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false;
804
+ const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath);
805
+ const rev = this.allocateInitialRevision();
806
+ this.table.set(packageName, {
807
+ entry: graphRow(packageName, rev, source.meta),
808
+ loaderName: source.loaderName,
809
+ sourceKey: source.sourceKey,
810
+ meta: source.meta,
811
+ bundle: snapshot.bundle,
812
+ baseline: snapshot.baseline,
813
+ ...snapshot.sourceMap === void 0 ? {} : { sourceMap: snapshot.sourceMap }
435
814
  });
436
815
  return true;
437
816
  }
@@ -440,7 +819,7 @@ var ClientModuleRegistry = class extends Service {
440
819
  for (const entryName of [...this.dirty]) {
441
820
  this.dirty.delete(entryName);
442
821
  try {
443
- if (this.processOne(entryName)) changed = true;
822
+ if (this.processOne(entryName, onError)) changed = true;
444
823
  } catch (error) {
445
824
  onError(error instanceof Error ? error : new Error(String(error)));
446
825
  }
@@ -456,37 +835,26 @@ var ClientModuleRegistry = class extends Service {
456
835
  this.composed = composed;
457
836
  this.notifyGraphChanged();
458
837
  }
459
- serveBundle = async (req, res) => {
838
+ serveBundle = (req, res) => {
460
839
  if (req.method !== "GET" && req.method !== "HEAD") {
461
840
  res.writeHead(405);
462
841
  res.end();
463
842
  return;
464
843
  }
465
844
  /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
466
- const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
467
- const prefix = "/plugins/";
468
- const mapSuffix = "/client.js.map";
469
- const bundleSuffix = "/client.js";
470
- const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix);
471
- const suffix = isSourceMap ? mapSuffix : bundleSuffix;
472
- const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix) ? this.clientPath(pathname.slice(9, -suffix.length)) : void 0;
473
- const path = clientPath === void 0 ? void 0 : `${clientPath}${isSourceMap ? ".map" : ""}`;
474
- if (path === void 0) {
475
- res.writeHead(404);
476
- res.end();
477
- return;
478
- }
479
- try {
480
- const body = await readFile(path);
845
+ const requestUrl = new URL(req.url ?? "/", "http://x");
846
+ const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`;
847
+ const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl);
848
+ if (response !== void 0) {
481
849
  res.writeHead(200, {
482
- "content-type": isSourceMap ? "application/json; charset=utf-8" : "text/javascript; charset=utf-8",
483
- "cache-control": "no-cache"
850
+ "content-type": response.contentType,
851
+ "cache-control": IMMUTABLE_CACHE
484
852
  });
485
- res.end(body);
486
- } catch {
487
- res.writeHead(404);
488
- res.end();
853
+ res.end(req.method === "HEAD" ? void 0 : response.body);
854
+ return;
489
855
  }
856
+ res.writeHead(404);
857
+ res.end();
490
858
  };
491
859
  };
492
860
  //#endregion