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