@ethisyscore/extension-runtime 1.6.0

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.
Files changed (39) hide show
  1. package/README.md +42 -0
  2. package/bin/mock-host.cjs +14 -0
  3. package/dist/host/index.cjs +600 -0
  4. package/dist/host/index.cjs.map +1 -0
  5. package/dist/host/index.d.cts +260 -0
  6. package/dist/host/index.d.ts +260 -0
  7. package/dist/host/index.js +577 -0
  8. package/dist/host/index.js.map +1 -0
  9. package/dist/index.cjs +16 -0
  10. package/dist/index.cjs.map +1 -0
  11. package/dist/index.d.cts +6 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.js +10 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/mock-host/cli.cjs +800 -0
  16. package/dist/mock-host/cli.cjs.map +1 -0
  17. package/dist/mock-host/cli.d.cts +155 -0
  18. package/dist/mock-host/cli.d.ts +155 -0
  19. package/dist/mock-host/cli.js +770 -0
  20. package/dist/mock-host/cli.js.map +1 -0
  21. package/dist/mock-host/index.cjs +74 -0
  22. package/dist/mock-host/index.cjs.map +1 -0
  23. package/dist/mock-host/index.d.cts +95 -0
  24. package/dist/mock-host/index.d.ts +95 -0
  25. package/dist/mock-host/index.js +71 -0
  26. package/dist/mock-host/index.js.map +1 -0
  27. package/dist/plugin/index.cjs +113 -0
  28. package/dist/plugin/index.cjs.map +1 -0
  29. package/dist/plugin/index.d.cts +120 -0
  30. package/dist/plugin/index.d.ts +120 -0
  31. package/dist/plugin/index.js +107 -0
  32. package/dist/plugin/index.js.map +1 -0
  33. package/dist/registry-DpCx_LxF.d.cts +25 -0
  34. package/dist/registry-DpCx_LxF.d.ts +25 -0
  35. package/dist/transport-73otePiw.d.cts +307 -0
  36. package/dist/transport-73otePiw.d.ts +307 -0
  37. package/dist/transport-DVn2GVZh.d.cts +32 -0
  38. package/dist/transport-DVn2GVZh.d.ts +32 -0
  39. package/package.json +78 -0
@@ -0,0 +1,770 @@
1
+ import { promises } from 'fs';
2
+ import * as path from 'path';
3
+ import { KNOWN_PRIMITIVES } from '@ethisyscore/protocol';
4
+
5
+ // src/mock-host/cli.ts
6
+
7
+ // src/host/worker/offscreen.ts
8
+ var transferredCanvases = /* @__PURE__ */ new WeakSet();
9
+ function createOffscreenCanvasTransfer(options) {
10
+ const { canvas, surfaceId, postMessage } = options;
11
+ const proto = HTMLCanvasElement.prototype;
12
+ if (typeof proto.transferControlToOffscreen !== "function") {
13
+ throw new Error(
14
+ "[offscreen-canvas] OffscreenCanvas is not supported in this environment. Contract B canvas surfaces require a browser with HTMLCanvasElement.transferControlToOffscreen."
15
+ );
16
+ }
17
+ if (transferredCanvases.has(canvas)) {
18
+ throw new Error(
19
+ `[offscreen-canvas] canvas already transferred (surfaceId='${surfaceId}'). Create a fresh <canvas> for each surface instead of re-transferring.`
20
+ );
21
+ }
22
+ const offscreen = canvas.transferControlToOffscreen();
23
+ transferredCanvases.add(canvas);
24
+ const envelope = {
25
+ type: "ethisys:offscreen:transfer",
26
+ surfaceId,
27
+ width: canvas.width,
28
+ height: canvas.height,
29
+ offscreen
30
+ };
31
+ postMessage(envelope, [offscreen]);
32
+ return { offscreen };
33
+ }
34
+
35
+ // src/host/worker/transport.ts
36
+ var WORKER_TRANSPORT_PROTOCOL = "ethisys.worker.remotedom.v1";
37
+ var DEFAULT_MAX_CONCURRENT_MCP_REQUESTS = 8;
38
+ var WorkerRemoteDomTransport = class {
39
+ worker;
40
+ hostPort;
41
+ workerPort;
42
+ mcpClient;
43
+ capabilityTokenProvider;
44
+ coalesceMs;
45
+ maxConcurrentMcpRequests;
46
+ abortController;
47
+ inFlightMcpRequests = 0;
48
+ remoteDomConsumer;
49
+ disposed = false;
50
+ connected = false;
51
+ constructor(options) {
52
+ const WorkerCtor = options.workerCtor ?? globalThis.Worker;
53
+ if (!WorkerCtor) {
54
+ throw new Error("WorkerRemoteDomTransport: no Worker constructor available");
55
+ }
56
+ this.mcpClient = options.mcpClient;
57
+ this.capabilityTokenProvider = options.capabilityToken;
58
+ this.coalesceMs = options.coalesceMs ?? 16;
59
+ this.maxConcurrentMcpRequests = Math.max(
60
+ 1,
61
+ options.maxConcurrentMcpRequests ?? DEFAULT_MAX_CONCURRENT_MCP_REQUESTS
62
+ );
63
+ this.abortController = new AbortController();
64
+ this.worker = new WorkerCtor(options.bootstrapUrl, { type: "module" });
65
+ const channel = new MessageChannel();
66
+ this.hostPort = channel.port1;
67
+ this.workerPort = channel.port2;
68
+ this.hostPort.onmessage = (ev) => this.handlePortMessage(ev.data);
69
+ this.hostPort.start();
70
+ this.worker.onerror = (ev) => {
71
+ };
72
+ }
73
+ /**
74
+ * Post the handshake to the worker. Idempotent — only the FIRST call
75
+ * transfers the {@link MessagePort} and posts the handshake payload;
76
+ * subsequent calls are silent no-ops. Splitting this off from the
77
+ * constructor lets the host mount fetch the per-plugin
78
+ * `worker-bundle.import-map.json` (and resolve the bundle's module URL)
79
+ * before the bootstrap script consumes them.
80
+ *
81
+ * Wire shape: {@link WorkerHandshakePayload}. The capability token NEVER
82
+ * appears in the payload — it's bound on the host side via the
83
+ * {@link WorkerRemoteDomTransportOptions.capabilityToken} provider.
84
+ *
85
+ * The `moduleUrl` and `importMap` are forwarded to the worker so the
86
+ * bootstrap script (served from the host-pinned
87
+ * `/extensions/runtime/worker-bootstrap.js`) can:
88
+ * 1. Compose the frozen, same-origin-validated `IMPORT_MAP` from the
89
+ * handshake payload (rejecting any cross-origin entries).
90
+ * 2. `safeImport(moduleUrl)` the plugin's entry module.
91
+ *
92
+ * Same-origin enforcement is the bootstrap's job — this transport is
93
+ * structurally agnostic to the host origin and only forwards what it's
94
+ * handed.
95
+ */
96
+ connect(moduleUrl, importMap) {
97
+ if (this.connected) {
98
+ return;
99
+ }
100
+ this.connected = true;
101
+ const handshake = Object.freeze({
102
+ type: "ethisys:worker:handshake",
103
+ protocol: WORKER_TRANSPORT_PROTOCOL,
104
+ moduleUrl,
105
+ importMap: Object.freeze({ ...importMap })
106
+ });
107
+ this.worker.postMessage(handshake, [this.workerPort]);
108
+ }
109
+ /**
110
+ * Bind a consumer for Remote DOM mutation payloads emitted by the worker.
111
+ * The Remote DOM receiver wiring is owned by the caller (typically a host
112
+ * React component); this method exposes the raw stream so the receiver
113
+ * can integrate cleanly without a circular package import.
114
+ */
115
+ onRemoteDom(consumer) {
116
+ this.remoteDomConsumer = consumer;
117
+ }
118
+ /**
119
+ * Transfer control of a host-owned `<canvas>` to the worker so plugin code
120
+ * can render via `OffscreenCanvas`. The host retains the `<canvas>` for
121
+ * layout / accessibility purposes only — every pixel is produced inside the
122
+ * worker, so the main thread is never blocked per frame.
123
+ *
124
+ * The transfer rides the established MessagePort (not the worker global
125
+ * `postMessage`) so it is multiplexed with the rest of the host ↔ worker
126
+ * traffic on the same channel. The `OffscreenCanvas` handle is the sole
127
+ * `Transferable` in the envelope; no capability token, MCP context, or
128
+ * other host-only data crosses the boundary.
129
+ *
130
+ * Throws if the environment lacks `transferControlToOffscreen()` or if the
131
+ * canvas has already been transferred — see {@link createOffscreenCanvasTransfer}
132
+ * for the exact diagnostics.
133
+ */
134
+ transferCanvas(canvas, options) {
135
+ return createOffscreenCanvasTransfer({
136
+ canvas,
137
+ surfaceId: options.surfaceId,
138
+ postMessage: (message, transfer) => {
139
+ this.hostPort.postMessage(message, transfer);
140
+ }
141
+ });
142
+ }
143
+ /**
144
+ * Forward a host-sourced input event to the worker over the port. The
145
+ * payload shape is opaque to the transport — pointer / wheel / keyboard
146
+ * envelopes share the same wire type. Callers are expected to wrap
147
+ * high-frequency (pointer-move, wheel, scroll) events in
148
+ * {@link createCoalescer} or {@link createInputEventCoalescer} BEFORE
149
+ * calling this method so the port never sees the un-coalesced flood.
150
+ *
151
+ * Keyboard / pointer-down / pointer-up events are discrete and should be
152
+ * delivered without coalescing — pass them straight through.
153
+ */
154
+ postInputEvent(payload) {
155
+ this.hostPort.postMessage({
156
+ type: "ethisys:input:event",
157
+ payload
158
+ });
159
+ }
160
+ /**
161
+ * Build a trailing-edge coalescer keyed to {@link WorkerRemoteDomTransportOptions.coalesceMs}.
162
+ *
163
+ * Use it to wrap pointer-move / scroll / resize callbacks **before**
164
+ * they cross the port. The last payload in any coalescing window is the
165
+ * one that wins.
166
+ */
167
+ createCoalescer(consumer) {
168
+ let pending;
169
+ let timer;
170
+ const flush = () => {
171
+ timer = void 0;
172
+ const value = pending;
173
+ pending = void 0;
174
+ if (value !== void 0) {
175
+ consumer(value);
176
+ }
177
+ };
178
+ return (payload) => {
179
+ pending = payload;
180
+ if (timer === void 0) {
181
+ timer = setTimeout(flush, this.coalesceMs);
182
+ }
183
+ };
184
+ }
185
+ /**
186
+ * Tear down the worker and port. Idempotent.
187
+ *
188
+ * Order matters: we abort BEFORE closing the port so any in-flight
189
+ * `mcpClient.fetch` that observes the signal short-circuits and the
190
+ * subsequent attempt to post a reply lands in the disposed-guard branch
191
+ * (which silently drops the post) instead of throwing on a closed port.
192
+ */
193
+ dispose() {
194
+ if (this.disposed) {
195
+ return;
196
+ }
197
+ this.disposed = true;
198
+ try {
199
+ this.abortController.abort();
200
+ } catch {
201
+ }
202
+ try {
203
+ this.hostPort.close();
204
+ } catch {
205
+ }
206
+ try {
207
+ this.worker.terminate();
208
+ } catch {
209
+ }
210
+ }
211
+ /**
212
+ * Best-effort wrapper around `hostPort.postMessage` that tolerates posts
213
+ * arriving after {@link dispose}. The browser throws on a closed port and
214
+ * the async handlers below can race dispose, so any post initiated by an
215
+ * awaited continuation must be guarded.
216
+ */
217
+ safePostMessage(message) {
218
+ if (this.disposed) {
219
+ return;
220
+ }
221
+ try {
222
+ this.hostPort.postMessage(message);
223
+ } catch {
224
+ }
225
+ }
226
+ // ─── Port message handling ──────────────────────────────────────────────
227
+ handlePortMessage(message) {
228
+ if (this.disposed) {
229
+ return;
230
+ }
231
+ switch (message.type) {
232
+ case "ethisys:mcp:invokeTool":
233
+ this.dispatchMcp(message, "ethisys:mcp:invokeTool:result", (m) => this.handleInvokeTool(m));
234
+ return;
235
+ case "ethisys:mcp:getResource":
236
+ this.dispatchMcp(message, "ethisys:mcp:getResource:result", (m) => this.handleGetResource(m));
237
+ return;
238
+ case "ethisys:remotedom":
239
+ this.remoteDomConsumer?.(message.payload);
240
+ return;
241
+ default:
242
+ return;
243
+ }
244
+ }
245
+ /**
246
+ * Gate inbound MCP requests through the bounded concurrency window,
247
+ * reject with a deterministic error reply when the cap is exceeded, and
248
+ * decrement the counter unconditionally when the underlying handler
249
+ * settles (regardless of resolution/rejection shape).
250
+ */
251
+ dispatchMcp(message, resultType, handler) {
252
+ if (this.inFlightMcpRequests >= this.maxConcurrentMcpRequests) {
253
+ this.safePostMessage({
254
+ id: message.id,
255
+ type: resultType,
256
+ ok: false,
257
+ error: `MCP back-pressure: in-flight cap of ${this.maxConcurrentMcpRequests} reached.`
258
+ });
259
+ return;
260
+ }
261
+ this.inFlightMcpRequests++;
262
+ handler(message).finally(() => {
263
+ this.inFlightMcpRequests = Math.max(0, this.inFlightMcpRequests - 1);
264
+ });
265
+ }
266
+ async handleInvokeTool(message) {
267
+ let token;
268
+ try {
269
+ token = await this.capabilityTokenProvider();
270
+ } catch (err) {
271
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
272
+ return;
273
+ }
274
+ if (this.disposed) {
275
+ return;
276
+ }
277
+ try {
278
+ const result = await this.mcpClient.fetch({
279
+ kind: "invokeTool",
280
+ name: message.name,
281
+ args: message.args,
282
+ capabilityToken: token,
283
+ signal: this.abortController.signal
284
+ });
285
+ this.safePostMessage({
286
+ id: message.id,
287
+ type: "ethisys:mcp:invokeTool:result",
288
+ ok: result.ok,
289
+ data: result.data,
290
+ error: result.error
291
+ });
292
+ } catch (err) {
293
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
294
+ }
295
+ }
296
+ async handleGetResource(message) {
297
+ let token;
298
+ try {
299
+ token = await this.capabilityTokenProvider();
300
+ } catch (err) {
301
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
302
+ return;
303
+ }
304
+ if (this.disposed) {
305
+ return;
306
+ }
307
+ try {
308
+ const result = await this.mcpClient.fetch({
309
+ kind: "getResource",
310
+ uri: message.uri,
311
+ capabilityToken: token,
312
+ signal: this.abortController.signal
313
+ });
314
+ this.safePostMessage({
315
+ id: message.id,
316
+ type: "ethisys:mcp:getResource:result",
317
+ ok: result.ok,
318
+ data: result.data,
319
+ error: result.error
320
+ });
321
+ } catch (err) {
322
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
323
+ }
324
+ }
325
+ replyError(id, type, err) {
326
+ const message = err instanceof Error ? err.message : "MCP request failed";
327
+ this.safePostMessage({ id, type, ok: false, error: message });
328
+ }
329
+ };
330
+
331
+ // src/host/worker/component-registry.ts
332
+ var CONTRACT_B_PRIMITIVES = [
333
+ "Button",
334
+ "DataTable",
335
+ "Form",
336
+ "EntityPicker",
337
+ "CommandBar",
338
+ "Drawer",
339
+ "Modal",
340
+ "CanvasSurface",
341
+ "WebGLSurface"
342
+ ];
343
+ new Set(CONTRACT_B_PRIMITIVES);
344
+
345
+ // src/mock-host/cli.ts
346
+ async function loadResources(dir) {
347
+ const absDir = path.resolve(dir);
348
+ const files = await walkJson(absDir);
349
+ const out = {};
350
+ const errors = [];
351
+ for (const file of files) {
352
+ try {
353
+ const raw = await promises.readFile(file, "utf8");
354
+ const parsed = JSON.parse(raw);
355
+ const { uri, tree } = normaliseEntry(parsed, file, absDir);
356
+ if (out[uri] !== void 0) {
357
+ errors.push(`Duplicate resource URI '${uri}' (from ${file})`);
358
+ continue;
359
+ }
360
+ out[uri] = tree;
361
+ } catch (e) {
362
+ errors.push(`${file}: ${e.message}`);
363
+ }
364
+ }
365
+ if (errors.length > 0) {
366
+ throw new Error(
367
+ `Failed to load ${errors.length} mock-host resource(s):
368
+ - ${errors.join("\n - ")}`
369
+ );
370
+ }
371
+ return out;
372
+ }
373
+ function normaliseEntry(parsed, absFile, absDir) {
374
+ if (parsed === null || typeof parsed !== "object") {
375
+ throw new Error("Resource JSON must be an object");
376
+ }
377
+ const obj = parsed;
378
+ if (typeof obj.uri === "string" && obj.tree !== void 0) {
379
+ return { uri: obj.uri, tree: obj.tree };
380
+ }
381
+ const rel = path.relative(absDir, absFile).replace(/\\/g, "/");
382
+ const uri = rel.replace(/\.json$/i, "");
383
+ return { uri, tree: parsed };
384
+ }
385
+ async function walkJson(root) {
386
+ const out = [];
387
+ async function recurse(dir) {
388
+ let entries;
389
+ try {
390
+ entries = await promises.readdir(dir, { withFileTypes: true });
391
+ } catch (e) {
392
+ const err = e;
393
+ if (err.code === "ENOENT") {
394
+ throw new Error(`Resources directory not found: ${root}`);
395
+ }
396
+ throw e;
397
+ }
398
+ for (const entry of entries) {
399
+ const full = path.join(dir, entry.name);
400
+ if (entry.isDirectory()) {
401
+ await recurse(full);
402
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".json")) {
403
+ out.push(full);
404
+ }
405
+ }
406
+ }
407
+ await recurse(root);
408
+ out.sort();
409
+ return out;
410
+ }
411
+ function renderIndexHtml(resourceUris) {
412
+ const defaultUri = resourceUris[0] ?? "";
413
+ return `<!doctype html>
414
+ <html lang="en">
415
+ <head>
416
+ <meta charset="utf-8" />
417
+ <title>mock-host (${resourceUris.length} resources)</title>
418
+ <style>
419
+ body { font-family: system-ui, sans-serif; margin: 1rem; }
420
+ header { border-bottom: 1px solid #ccc; padding-bottom: 0.5rem; margin-bottom: 1rem; }
421
+ select { padding: 0.25rem 0.5rem; }
422
+ [data-mock-primitive] { padding: 0.5rem; margin: 0.25rem 0; border: 1px dashed #aaa; }
423
+ [data-mock-primitive]::before { content: attr(data-mock-primitive); font-size: 0.75rem; color: #666; display: block; }
424
+ </style>
425
+ </head>
426
+ <body>
427
+ <header>
428
+ <strong>mock-host</strong>
429
+ &nbsp;|&nbsp;
430
+ <label>Resource: <select id="resource-picker">
431
+ ${resourceUris.map((u) => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join("")}
432
+ </select></label>
433
+ </header>
434
+ <main id="root"></main>
435
+ <script type="module" src="/@mock-host/entry.tsx"></script>
436
+ <script>
437
+ window.__MOCK_HOST_DEFAULT_URI__ = ${safeJsonForScript(defaultUri)};
438
+ window.__MOCK_HOST_RESOURCE_URIS__ = ${safeJsonForScript(resourceUris)};
439
+ </script>
440
+ </body>
441
+ </html>`;
442
+ }
443
+ function escapeHtml(s) {
444
+ return s.replace(/[&<>"']/g, (c) => ({
445
+ "&": "&amp;",
446
+ "<": "&lt;",
447
+ ">": "&gt;",
448
+ '"': "&quot;",
449
+ "'": "&#39;"
450
+ })[c]);
451
+ }
452
+ function safeJsonForScript(v) {
453
+ return JSON.stringify(v).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
454
+ }
455
+ function renderEntryModule(resources) {
456
+ const json = JSON.stringify(resources);
457
+ const primitives = JSON.stringify([...KNOWN_PRIMITIVES]);
458
+ return `
459
+ import { createRoot } from "react-dom/client";
460
+ import { createElement, useState } from "react";
461
+ import { DeclarativeMockHost } from "@ethisyscore/extension-runtime/mock-host";
462
+
463
+ const PRIMITIVES = ${primitives};
464
+
465
+ const registry = Object.fromEntries(PRIMITIVES.map(name => [
466
+ name,
467
+ ({ children }) => createElement("div", { "data-mock-primitive": name }, children),
468
+ ]));
469
+
470
+ const resources = ${json};
471
+ const uris = Object.keys(resources);
472
+
473
+ function App() {
474
+ const [uri, setUri] = useState(window.__MOCK_HOST_DEFAULT_URI__ || uris[0] || "");
475
+ // Wire the header picker to React state so authors can switch trees live.
476
+ const picker = document.getElementById("resource-picker");
477
+ if (picker && !picker.__wired) {
478
+ picker.__wired = true;
479
+ picker.value = uri;
480
+ picker.addEventListener("change", e => setUri(e.target.value));
481
+ }
482
+ return createElement(DeclarativeMockHost, {
483
+ resources,
484
+ registry,
485
+ defaultResourceUri: uri,
486
+ });
487
+ }
488
+
489
+ createRoot(document.getElementById("root")).render(createElement(App));
490
+ `;
491
+ }
492
+ var RENDER_MODES = /* @__PURE__ */ new Set([
493
+ "host-rendered",
494
+ "remote-runtime"
495
+ ]);
496
+ function parseArgs(args) {
497
+ let renderMode = "host-rendered";
498
+ const positional = [];
499
+ for (let i = 0; i < args.length; i++) {
500
+ const arg = args[i];
501
+ if (arg === "--render-mode") {
502
+ const next = args[i + 1];
503
+ if (next === void 0) {
504
+ throw new Error("Usage: mock-host [--render-mode <host-rendered|remote-runtime>] <path>");
505
+ }
506
+ if (!RENDER_MODES.has(next)) {
507
+ throw new Error(
508
+ `Invalid --render-mode '${next}'. Allowed: ${[...RENDER_MODES].join(", ")}.`
509
+ );
510
+ }
511
+ renderMode = next;
512
+ i++;
513
+ continue;
514
+ }
515
+ if (arg.startsWith("--render-mode=")) {
516
+ const value = arg.slice("--render-mode=".length);
517
+ if (!RENDER_MODES.has(value)) {
518
+ throw new Error(
519
+ `Invalid --render-mode '${value}'. Allowed: ${[...RENDER_MODES].join(", ")}.`
520
+ );
521
+ }
522
+ renderMode = value;
523
+ continue;
524
+ }
525
+ positional.push(arg);
526
+ }
527
+ if (positional.length === 0) {
528
+ throw new Error("Usage: mock-host [--render-mode <host-rendered|remote-runtime>] <path>");
529
+ }
530
+ return { renderMode, path: positional[0] };
531
+ }
532
+ function renderWorkerBundleIndexHtml(bundlePath) {
533
+ const safePath = escapeHtml(bundlePath);
534
+ return `<!doctype html>
535
+ <html lang="en">
536
+ <head>
537
+ <meta charset="utf-8" />
538
+ <title>mock-host (remote-runtime: ${safePath})</title>
539
+ <style>
540
+ body { font-family: system-ui, sans-serif; margin: 1rem; }
541
+ header { border-bottom: 1px solid #ccc; padding-bottom: 0.5rem; margin-bottom: 1rem; }
542
+ [data-mock-primitive] { padding: 0.5rem; margin: 0.25rem 0; border: 1px dashed #aaa; }
543
+ [data-mock-primitive]::before { content: attr(data-mock-primitive); font-size: 0.75rem; color: #666; display: block; }
544
+ </style>
545
+ </head>
546
+ <body>
547
+ <header><strong>mock-host</strong> &nbsp;|&nbsp; <em>remote-runtime: ${safePath}</em></header>
548
+ <main id="root"></main>
549
+ <script type="module" src="/@mock-host/worker-bundle-entry.tsx"></script>
550
+ </body>
551
+ </html>`;
552
+ }
553
+ function renderWorkerBundleEntryModule(bundleUrl) {
554
+ const safePrimitives = JSON.stringify([...CONTRACT_B_PRIMITIVES]);
555
+ const safeUrl = JSON.stringify(bundleUrl);
556
+ return `
557
+ import { createRoot } from "react-dom/client";
558
+ import { createElement } from "react";
559
+ import {
560
+ WorkerRemoteDomTransport,
561
+ SemanticComponentRegistry,
562
+ } from "@ethisyscore/extension-runtime/host";
563
+
564
+ const PRIMITIVES = ${safePrimitives};
565
+ const bundleUrl = ${safeUrl};
566
+
567
+ // Build the test-injectable component map \u2014 every Contract B primitive
568
+ // renders to a labelled placeholder so plugin authors can see what their
569
+ // worker is emitting before plugging in gogo-ui.
570
+ const componentMap = Object.fromEntries(PRIMITIVES.map(name => [
571
+ name,
572
+ ({ children }) => createElement("div", { "data-mock-primitive": name }, children),
573
+ ]));
574
+ const registry = SemanticComponentRegistry.fromMap(componentMap);
575
+
576
+ // Mock-host MCP client: no platform, so every tool/resource call resolves
577
+ // to a friendly placeholder. Authors who need real MCP traffic should run
578
+ // against the full platform host.
579
+ const mcpClient = {
580
+ fetch: async (req) => ({
581
+ ok: true,
582
+ data: { mockHost: true, kind: req.kind, name: req.name ?? req.uri },
583
+ }),
584
+ };
585
+
586
+ // The capability token never crosses the worker boundary \u2014 supply a marker
587
+ // here so the host-side fetcher can still attach an Authorization header.
588
+ const transport = new WorkerRemoteDomTransport({
589
+ bootstrapUrl: bundleUrl,
590
+ capabilityToken: async () => "mock-host-capability-token",
591
+ mcpClient,
592
+ });
593
+
594
+ const root = createRoot(document.getElementById("root"));
595
+ transport.onRemoteDom(() => {
596
+ // The full Remote DOM receiver belongs to a separate concern (E3.S2's
597
+ // WorkerSurfaceMount). For mock-host purposes the registry is the
598
+ // observable surface \u2014 log the receiver payload for now.
599
+ root.render(createElement("div", { "data-mock-host-primitives": PRIMITIVES.join(",") }));
600
+ });
601
+
602
+ // Expose the transport + registry on window so authors can poke at them from
603
+ // devtools (e.g., to terminate the worker between iteration cycles).
604
+ window.__MOCK_HOST_TRANSPORT__ = transport;
605
+ window.__MOCK_HOST_REGISTRY__ = registry;
606
+ `;
607
+ }
608
+ var defaultMockMcpClient = {
609
+ fetch: async (req) => {
610
+ const echo = req.kind === "invokeTool" ? { tool: req.name, args: req.args } : { resource: req.uri };
611
+ return { ok: true, data: { mockHost: true, ...echo } };
612
+ }
613
+ };
614
+ function bootContractBHost(options) {
615
+ return new WorkerRemoteDomTransport({
616
+ bootstrapUrl: options.bundleUrl,
617
+ capabilityToken: options.capabilityToken ?? (async () => "mock-host-capability-token"),
618
+ mcpClient: options.mcpClient ?? defaultMockMcpClient,
619
+ workerCtor: options.workerCtor
620
+ });
621
+ }
622
+ async function runContractBHost(bundlePathOrUrl) {
623
+ let viteMod;
624
+ try {
625
+ viteMod = await import('vite');
626
+ } catch {
627
+ throw new Error(
628
+ "vite is required to run `mock-host`. Install with: npm install --save-dev vite"
629
+ );
630
+ }
631
+ const isUrl = /^https?:\/\//i.test(bundlePathOrUrl);
632
+ const absBundle = isUrl ? bundlePathOrUrl : path.resolve(bundlePathOrUrl);
633
+ if (!isUrl) {
634
+ try {
635
+ await promises.access(absBundle);
636
+ } catch {
637
+ throw new Error(`mock-host: worker bundle not found at ${absBundle}`);
638
+ }
639
+ }
640
+ const bundleServePath = "/@mock-host/worker-bundle.js";
641
+ const indexHtml = renderWorkerBundleIndexHtml(isUrl ? bundlePathOrUrl : bundleServePath);
642
+ const entryModule = renderWorkerBundleEntryModule(isUrl ? bundlePathOrUrl : bundleServePath);
643
+ const server = await viteMod.createServer({
644
+ root: process.cwd(),
645
+ configFile: false,
646
+ server: { port: 0, host: "127.0.0.1" },
647
+ plugins: [
648
+ {
649
+ name: "mock-host-virtual-worker",
650
+ resolveId(id) {
651
+ if (id === "/@mock-host/worker-bundle-entry.tsx") {
652
+ return "\0mock-host:worker-bundle-entry";
653
+ }
654
+ return null;
655
+ },
656
+ load(id) {
657
+ if (id === "\0mock-host:worker-bundle-entry") {
658
+ return entryModule;
659
+ }
660
+ return null;
661
+ },
662
+ /* eslint-disable @typescript-eslint/no-explicit-any */
663
+ configureServer(devServer) {
664
+ devServer.middlewares.use(async (req, res, next) => {
665
+ if (req.url === "/" || req.url === "/index.html") {
666
+ res.setHeader("Content-Type", "text/html");
667
+ res.end(indexHtml);
668
+ return;
669
+ }
670
+ if (!isUrl && req.url === bundleServePath) {
671
+ try {
672
+ const bytes = await promises.readFile(absBundle);
673
+ res.setHeader("Content-Type", "text/javascript; charset=utf-8");
674
+ res.end(bytes);
675
+ } catch (e) {
676
+ res.statusCode = 500;
677
+ res.end(`mock-host: failed to read worker bundle: ${e.message}`);
678
+ }
679
+ return;
680
+ }
681
+ next();
682
+ });
683
+ }
684
+ /* eslint-enable @typescript-eslint/no-explicit-any */
685
+ }
686
+ ]
687
+ });
688
+ await server.listen();
689
+ const addr = server.httpServer?.address();
690
+ let port = 5173;
691
+ if (addr && typeof addr === "object") {
692
+ port = addr.port;
693
+ }
694
+ const url = `http://127.0.0.1:${port}/`;
695
+ process.stdout.write(`mock-host listening on ${url} (remote-runtime: ${absBundle})
696
+ `);
697
+ return { url, close: () => server.close() };
698
+ }
699
+ async function run(args) {
700
+ const parsed = parseArgs(args);
701
+ if (parsed.renderMode === "remote-runtime") {
702
+ return runContractBHost(parsed.path);
703
+ }
704
+ const dir = parsed.path;
705
+ const resources = await loadResources(dir);
706
+ const uris = Object.keys(resources).sort();
707
+ if (uris.length === 0) {
708
+ process.stderr.write(`mock-host: no resources found in ${dir} (HMR will pick up new files)
709
+ `);
710
+ }
711
+ let viteMod;
712
+ try {
713
+ viteMod = await import('vite');
714
+ } catch {
715
+ throw new Error(
716
+ "vite is required to run `mock-host`. Install with: npm install --save-dev vite"
717
+ );
718
+ }
719
+ const indexHtml = renderIndexHtml(uris);
720
+ const entryModule = renderEntryModule(resources);
721
+ const absDir = path.resolve(dir);
722
+ const server = await viteMod.createServer({
723
+ root: process.cwd(),
724
+ configFile: false,
725
+ server: { port: 0, host: "127.0.0.1" },
726
+ plugins: [
727
+ {
728
+ name: "mock-host-virtual",
729
+ resolveId(id) {
730
+ if (id === "/@mock-host/entry.tsx") {
731
+ return "\0mock-host:entry";
732
+ }
733
+ return null;
734
+ },
735
+ load(id) {
736
+ if (id === "\0mock-host:entry") {
737
+ return entryModule;
738
+ }
739
+ return null;
740
+ },
741
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
742
+ configureServer(devServer) {
743
+ devServer.watcher.add(absDir);
744
+ devServer.middlewares.use((req, res, next) => {
745
+ if (req.url === "/" || req.url === "/index.html") {
746
+ res.setHeader("Content-Type", "text/html");
747
+ res.end(indexHtml);
748
+ return;
749
+ }
750
+ next();
751
+ });
752
+ }
753
+ }
754
+ ]
755
+ });
756
+ await server.listen();
757
+ const addr = server.httpServer?.address();
758
+ let port = 5173;
759
+ if (addr && typeof addr === "object") {
760
+ port = addr.port;
761
+ }
762
+ const url = `http://127.0.0.1:${port}/`;
763
+ process.stdout.write(`mock-host listening on ${url} (resources: ${uris.length})
764
+ `);
765
+ return { url, close: () => server.close() };
766
+ }
767
+
768
+ export { bootContractBHost, loadResources, normaliseEntry, parseArgs, renderEntryModule, renderIndexHtml, renderWorkerBundleEntryModule, renderWorkerBundleIndexHtml, run };
769
+ //# sourceMappingURL=cli.js.map
770
+ //# sourceMappingURL=cli.js.map