@hydraharness/harness-client-modules 0.1.1-rc.6
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/LICENSE +21 -0
- package/README.md +26 -0
- package/lib/client.js +324 -0
- package/lib/index.js +493 -0
- package/lib/invariant.js +34 -0
- package/lib/types/client/index.d.ts +32 -0
- package/lib/types/client/manifest.d.ts +239 -0
- package/lib/types/client/system.d.ts +46 -0
- package/lib/types/index.d.ts +127 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +65 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
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";
|
|
6
|
+
import { Service } from "@hydraharness/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
|
+
* `__HYDRA_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 `hydra.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
|
|
65
|
+
//#region lib/types/index.js
|
|
66
|
+
/**
|
|
67
|
+
* Node half of the client module system (`hydra.client` dual-face package): scans
|
|
68
|
+
* the host Loader's entries for packages declaring `hydra.client`, composes the
|
|
69
|
+
* `window.__HYDRA_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).
|
|
75
|
+
*
|
|
76
|
+
* Scanning is incremental per package — there is no full-rescan code path.
|
|
77
|
+
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
|
|
78
|
+
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
|
|
79
|
+
* against the live loader entries. The activation pass seeds the same dirty
|
|
80
|
+
* set with all current entries and flushes synchronously, so first scan and
|
|
81
|
+
* 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
|
|
85
|
+
* {@link ClientModuleRegistry.rebuilt}.
|
|
86
|
+
* @module @hydraharness/harness-client-modules
|
|
87
|
+
*/
|
|
88
|
+
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
|
|
89
|
+
const CLIENT_BUNDLE_BUILD_INSTRUCTION = "run `pnpm run build` before launch";
|
|
90
|
+
/** Missing built client export, retained as structured data for activation-error grouping. */
|
|
91
|
+
var MissingClientBundleError = class extends Error {
|
|
92
|
+
packageName;
|
|
93
|
+
clientPath;
|
|
94
|
+
constructor(packageName, clientPath, cause) {
|
|
95
|
+
super([
|
|
96
|
+
`client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
|
|
97
|
+
` package: ${packageName}`,
|
|
98
|
+
` path: ${clientPath}`
|
|
99
|
+
].join("\n"), { cause });
|
|
100
|
+
this.packageName = packageName;
|
|
101
|
+
this.clientPath = clientPath;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/** Activation failures grouped by actionable package-build errors and unrelated failures. */
|
|
105
|
+
var ClientPackageCompositionError = class extends AggregateError {
|
|
106
|
+
constructor(failures) {
|
|
107
|
+
const missingBundles = failures.filter((error) => error instanceof MissingClientBundleError);
|
|
108
|
+
const otherFailures = failures.filter((error) => !(error instanceof MissingClientBundleError));
|
|
109
|
+
const packageNoun = failures.length === 1 ? "package" : "packages";
|
|
110
|
+
const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`];
|
|
111
|
+
if (missingBundles.length > 0) {
|
|
112
|
+
lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`);
|
|
113
|
+
for (const error of missingBundles) lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`);
|
|
114
|
+
}
|
|
115
|
+
if (otherFailures.length > 0) lines.push(" other failures:", ...otherFailures.map((error) => ` - ${error.message}`));
|
|
116
|
+
super(failures, lines.join("\n"));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
/** Narrow an unknown parsed JSON value to the `hydra.client` declaration, throwing on malformed fields. */
|
|
120
|
+
function parseHydraClient(pkgName, value) {
|
|
121
|
+
if (value === void 0) return void 0;
|
|
122
|
+
if (typeof value !== "object" || value === null) throw new Error(`client-modules: ${pkgName} has a non-object hydra.client declaration`);
|
|
123
|
+
const decl = value;
|
|
124
|
+
if (typeof decl.platform !== "string") throw new Error(`client-modules: ${pkgName} hydra.client.platform must be a string`);
|
|
125
|
+
const inject = optionalStringArray(pkgName, "hydra.client.inject", decl.inject);
|
|
126
|
+
const external = optionalStringArray(pkgName, "hydra.client.external", decl.external);
|
|
127
|
+
if (decl.immediately !== void 0 && typeof decl.immediately !== "boolean") throw new Error(`client-modules: ${pkgName} hydra.client.immediately must be a boolean`);
|
|
128
|
+
return {
|
|
129
|
+
platform: decl.platform,
|
|
130
|
+
...inject !== void 0 ? { inject } : {},
|
|
131
|
+
...external !== void 0 ? { external } : {},
|
|
132
|
+
...decl.immediately !== void 0 ? { immediately: decl.immediately } : {}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
|
|
136
|
+
function clientExportOf(pkgName, exportsField) {
|
|
137
|
+
if (typeof exportsField !== "object" || exportsField === null) return void 0;
|
|
138
|
+
const client = exportsField["./client"];
|
|
139
|
+
if (client === void 0) return void 0;
|
|
140
|
+
if (typeof client === "string") return client;
|
|
141
|
+
if (typeof client === "object" && client !== null) {
|
|
142
|
+
const fallback = client.default;
|
|
143
|
+
if (typeof fallback === "string") return fallback;
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`);
|
|
146
|
+
}
|
|
147
|
+
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
|
|
148
|
+
function shortHash(input) {
|
|
149
|
+
return createHash("sha1").update(input).digest("hex").slice(0, 12);
|
|
150
|
+
}
|
|
151
|
+
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
|
|
152
|
+
function graphRow(id, rev, fields) {
|
|
153
|
+
return {
|
|
154
|
+
id,
|
|
155
|
+
url: `/plugins/${id}/client.js?rev=${rev}`,
|
|
156
|
+
rev,
|
|
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 hydra.client.external`);
|
|
186
|
+
if (dependency !== void 0) visit(dependency);
|
|
187
|
+
}
|
|
188
|
+
open.pop();
|
|
189
|
+
placed.add(entry.id);
|
|
190
|
+
ordered.push(entry);
|
|
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 = "@hydraharness/harness-client-modules";
|
|
197
|
+
/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
|
|
198
|
+
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, "@hydraharness/harness-client-runtime"];
|
|
199
|
+
/**
|
|
200
|
+
* 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
|
|
203
|
+
* bundle, delegates construction to that bundle, and leaves the same facade
|
|
204
|
+
* in live-registration mode. The graph global follows before the shell reads
|
|
205
|
+
* it.
|
|
206
|
+
* @param graph - the composed entry graph.
|
|
207
|
+
* @returns head rows in execution order: queue script, preload scripts, graph global.
|
|
208
|
+
*/
|
|
209
|
+
function bootInjections(graph) {
|
|
210
|
+
const queue = `(()=>{
|
|
211
|
+
const pendingQueue=[]
|
|
212
|
+
window.__ModuleLoader__={
|
|
213
|
+
mode:"queue",
|
|
214
|
+
pendingQueue,
|
|
215
|
+
load(registration){pendingQueue.push(registration)},
|
|
216
|
+
create(options){
|
|
217
|
+
if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot")
|
|
218
|
+
const index=pendingQueue.findIndex(registration=>registration.id===${JSON.stringify(CLIENT_MODULES_ID)})
|
|
219
|
+
const registration=pendingQueue[index]
|
|
220
|
+
if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js")
|
|
221
|
+
pendingQueue.splice(index,1)
|
|
222
|
+
const exports=registration.factory(specifier=>{
|
|
223
|
+
throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed')
|
|
224
|
+
})
|
|
225
|
+
if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){
|
|
226
|
+
throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face")
|
|
227
|
+
}
|
|
228
|
+
return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
})()`;
|
|
232
|
+
const preload = PARSER_PRELOAD_IDS.map((id) => graph.entries.find((entry) => entry.id === id)).filter((entry) => entry !== void 0).map((entry) => ({
|
|
233
|
+
kind: "script-src",
|
|
234
|
+
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: "__HYDRA_BOOT__",
|
|
247
|
+
value: graph
|
|
248
|
+
}
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The web plugin table service: incremental `hydra.client` scan + wire composition
|
|
253
|
+
* + bundle route + index injection rows. Construction runs the activation scan
|
|
254
|
+
* synchronously — a malformed declaration or missing bundle among the
|
|
255
|
+
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
|
|
256
|
+
* boot activation audit reports it).
|
|
257
|
+
*/
|
|
258
|
+
var ClientModuleRegistry = class extends Service {
|
|
259
|
+
static inject = ["webServer", "loader"];
|
|
260
|
+
table = /* @__PURE__ */ new Map();
|
|
261
|
+
pkgMeta = /* @__PURE__ */ new Map();
|
|
262
|
+
rebuildListeners = /* @__PURE__ */ new Set();
|
|
263
|
+
graphListeners = /* @__PURE__ */ new Set();
|
|
264
|
+
dirty = /* @__PURE__ */ new Set();
|
|
265
|
+
resolvePkgJson;
|
|
266
|
+
flushQueued = false;
|
|
267
|
+
composed;
|
|
268
|
+
/**
|
|
269
|
+
* Build the service: subscribe, seed, and run the activation flush.
|
|
270
|
+
* @param ctx - plugin context carrying webServer and loader.
|
|
271
|
+
*/
|
|
272
|
+
constructor(ctx) {
|
|
273
|
+
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
|
+
ctx.on("internal/plugin", (fiber) => {
|
|
278
|
+
const entryName = fiber.entry?.options.name;
|
|
279
|
+
if (entryName === void 0) return;
|
|
280
|
+
this.dirty.add(entryName);
|
|
281
|
+
if (this.flushQueued) return;
|
|
282
|
+
this.flushQueued = true;
|
|
283
|
+
queueMicrotask(() => {
|
|
284
|
+
this.flushQueued = false;
|
|
285
|
+
this.flush((err) => {
|
|
286
|
+
ctx.logger.warn(err);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name);
|
|
291
|
+
this.composed = this.compose();
|
|
292
|
+
const failures = [];
|
|
293
|
+
this.flush((err) => failures.push(err));
|
|
294
|
+
if (failures.length > 0) throw new ClientPackageCompositionError(failures);
|
|
295
|
+
ctx.effect(() => ctx.webServer.register({
|
|
296
|
+
kind: "prefix",
|
|
297
|
+
path: "/plugins",
|
|
298
|
+
handler: this.serveBundle
|
|
299
|
+
}), "client-modules: bundle route");
|
|
300
|
+
ctx.on("webserver/index-inject", (table) => {
|
|
301
|
+
table.push(...bootInjections(this.composed));
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Current composed entry graph (stable object between changes).
|
|
306
|
+
* @returns the graph served as `window.__HYDRA_BOOT__`.
|
|
307
|
+
*/
|
|
308
|
+
graph() {
|
|
309
|
+
return this.composed;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Absolute path of an entry's client bundle.
|
|
313
|
+
* @param id - entry id (package name).
|
|
314
|
+
* @returns the path, or undefined for an unknown id.
|
|
315
|
+
*/
|
|
316
|
+
clientPath(id) {
|
|
317
|
+
return this.table.get(id)?.meta.clientPath;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Re-hash one bundle (the HMR watch's registration hook — the only entry
|
|
321
|
+
* point through which bundle content changes reach the graph).
|
|
322
|
+
* @param id - entry id (package name).
|
|
323
|
+
* @returns the new rev, or undefined for an unknown id.
|
|
324
|
+
*/
|
|
325
|
+
rebuilt(id) {
|
|
326
|
+
const record = this.table.get(id);
|
|
327
|
+
if (record === void 0) return void 0;
|
|
328
|
+
const rev = shortHash(readFileSync(record.meta.clientPath));
|
|
329
|
+
if (rev === record.entry.rev) return rev;
|
|
330
|
+
record.entry = graphRow(id, rev, record.meta);
|
|
331
|
+
this.composed = this.compose();
|
|
332
|
+
for (const notify of this.rebuildListeners) try {
|
|
333
|
+
notify(id, rev);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
this.ctx.logger.error(error);
|
|
336
|
+
}
|
|
337
|
+
this.notifyGraphChanged();
|
|
338
|
+
return rev;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
|
|
342
|
+
* @param listener - receives the entry id and its new bundle rev.
|
|
343
|
+
* @returns the unsubscriber.
|
|
344
|
+
*/
|
|
345
|
+
onRebuilt(listener) {
|
|
346
|
+
this.rebuildListeners.add(listener);
|
|
347
|
+
return () => {
|
|
348
|
+
this.rebuildListeners.delete(listener);
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Fires after any flush that recomposed the graph (row added/removed, or a
|
|
353
|
+
* rebuilt rev change). Pull model: listeners re-read {@link graph}.
|
|
354
|
+
* @param listener - notified with no payload.
|
|
355
|
+
* @returns the unsubscriber.
|
|
356
|
+
*/
|
|
357
|
+
onGraphChanged(listener) {
|
|
358
|
+
this.graphListeners.add(listener);
|
|
359
|
+
return () => {
|
|
360
|
+
this.graphListeners.delete(listener);
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
compose() {
|
|
364
|
+
const entries = orderByModuleGraph([...this.table.values()].map((record) => record.entry));
|
|
365
|
+
return {
|
|
366
|
+
rev: shortHash(JSON.stringify(entries)),
|
|
367
|
+
entries
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
notifyGraphChanged() {
|
|
371
|
+
for (const listener of this.graphListeners) try {
|
|
372
|
+
listener();
|
|
373
|
+
} catch (error) {
|
|
374
|
+
this.ctx.logger.error(error);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
resolveMeta(pkgName) {
|
|
378
|
+
const cached = this.pkgMeta.get(pkgName);
|
|
379
|
+
if (cached !== void 0) return cached;
|
|
380
|
+
let pkgPath;
|
|
381
|
+
try {
|
|
382
|
+
pkgPath = this.resolvePkgJson(pkgName);
|
|
383
|
+
} catch {
|
|
384
|
+
this.pkgMeta.set(pkgName, null);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
388
|
+
const hydra = pkg.hydra;
|
|
389
|
+
const decl = parseHydraClient(pkgName, hydra !== null && typeof hydra === "object" ? hydra.client : void 0);
|
|
390
|
+
if (decl === void 0 || decl.platform !== "web") {
|
|
391
|
+
this.pkgMeta.set(pkgName, null);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const clientRel = clientExportOf(pkgName, pkg.exports);
|
|
395
|
+
if (clientRel === void 0) throw new Error(`client-modules: ${pkgName} declares hydra.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
|
|
401
|
+
};
|
|
402
|
+
this.pkgMeta.set(pkgName, meta);
|
|
403
|
+
return meta;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Read the activation-time bundle revision.
|
|
407
|
+
* @param pkgName - package that declares the client bundle.
|
|
408
|
+
* @param clientPath - absolute path of the built client artifact.
|
|
409
|
+
* @returns the bundle content's short hash for use as its revision.
|
|
410
|
+
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
|
|
411
|
+
*/
|
|
412
|
+
initialBundleRevision(pkgName, clientPath) {
|
|
413
|
+
try {
|
|
414
|
+
return shortHash(readFileSync(clientPath));
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error.code !== "ENOENT") throw error;
|
|
417
|
+
throw new MissingClientBundleError(pkgName, clientPath, error);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
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;
|
|
426
|
+
}
|
|
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
|
|
435
|
+
});
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
flush(onError) {
|
|
439
|
+
let changed = false;
|
|
440
|
+
for (const entryName of [...this.dirty]) {
|
|
441
|
+
this.dirty.delete(entryName);
|
|
442
|
+
try {
|
|
443
|
+
if (this.processOne(entryName)) changed = true;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!changed) return;
|
|
449
|
+
let composed;
|
|
450
|
+
try {
|
|
451
|
+
composed = this.compose();
|
|
452
|
+
} catch (error) {
|
|
453
|
+
onError(error);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
this.composed = composed;
|
|
457
|
+
this.notifyGraphChanged();
|
|
458
|
+
}
|
|
459
|
+
serveBundle = async (req, res) => {
|
|
460
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
461
|
+
res.writeHead(405);
|
|
462
|
+
res.end();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
/* 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);
|
|
481
|
+
res.writeHead(200, {
|
|
482
|
+
"content-type": isSourceMap ? "application/json; charset=utf-8" : "text/javascript; charset=utf-8",
|
|
483
|
+
"cache-control": "no-cache"
|
|
484
|
+
});
|
|
485
|
+
res.end(body);
|
|
486
|
+
} catch {
|
|
487
|
+
res.writeHead(404);
|
|
488
|
+
res.end();
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
//#endregion
|
|
493
|
+
export { ClientModuleRegistry, ClientModuleRegistry as default, bootInjections, orderByModuleGraph, stripClientSuffix };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-client-modules`.
|
|
4
|
+
* @module @hydraharness/harness-client-modules/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-client-modules";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "client-modules-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* Owned relation: the node half's boot entry graph must stay self-consistent
|
|
13
|
+
* — every row must resolve a clientPath under the same id (the
|
|
14
|
+
* /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
|
|
15
|
+
* that just received the graph). Checked on every scan trigger (cordis
|
|
16
|
+
* 'internal/plugin'): graph() and clientPath() read the same table object,
|
|
17
|
+
* so the relation holds at any instant — no need to wait out the node half's
|
|
18
|
+
* own microtask-debounced flush.
|
|
19
|
+
*/
|
|
20
|
+
const install = (ctx, fail) => {
|
|
21
|
+
ctx.on("internal/plugin", () => {
|
|
22
|
+
const host = ctx.get("clientModules");
|
|
23
|
+
if (host === void 0) return;
|
|
24
|
+
for (const row of host.graph().entries) if (host.clientPath(row.id) === void 0) fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __HYDRA_BOOT__ would 404 on fetch`);
|
|
25
|
+
}, { global: true });
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Register this package's invariant companion.
|
|
29
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
30
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
31
|
+
*/
|
|
32
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
33
|
+
//#endregion
|
|
34
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half (the standard `./client` export): the module-system class and
|
|
3
|
+
* wire contract, plus the enrollment plugin face. The module system itself is
|
|
4
|
+
* built by the shell kernel BEFORE cordis exists (the bootstrap exception —
|
|
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`.
|
|
11
|
+
* @module @hydraharness/harness-client-modules/client
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from '@hydraharness/cordis';
|
|
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, HydraWindow, 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;
|
|
27
|
+
/**
|
|
28
|
+
* Enroll the kernel-built module system as `ctx.modules`.
|
|
29
|
+
* @param ctx - client root context.
|
|
30
|
+
*/
|
|
31
|
+
export declare function apply(ctx: Context): void;
|
|
32
|
+
//# sourceMappingURL=index.d.ts.map
|