@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
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @ethisyscore/extension-runtime
2
+
3
+ Host + plugin runtime for EthisysCore extension contracts:
4
+
5
+ - **Contract A** — host-rendered SDUI extensions. Plugins return a closed-vocabulary `SduiNode` tree from a `ResourceHandler`; the host's declarative interpreter mounts it directly.
6
+ - **Contract B** — worker remote-runtime extensions. Plugin code runs in a sandboxed Web Worker; UI mutations flow via Remote DOM into the host's semantic primitive registry. The capability token never crosses the worker boundary.
7
+
8
+ Built on top of `@ethisyscore/protocol` (canonical bridge / manifest / SDUI / capability / theme types).
9
+
10
+ ## Subpath exports
11
+
12
+ | Entry | Use | Surface |
13
+ | ----- | --- | ------- |
14
+ | `@ethisyscore/extension-runtime` | Root re-exports | Stable consumer-facing types and helpers. |
15
+ | `@ethisyscore/extension-runtime/host` | Host integration | `WorkerRemoteDomTransport`, declarative interpreter, semantic component registry, offscreen-canvas helpers. |
16
+ | `@ethisyscore/extension-runtime/plugin` | Plugin side | `ExtensionRuntimeProvider`, `useMcpResource`, `useMcpTool`, transport abstraction. |
17
+ | `@ethisyscore/extension-runtime/mock-host` | Local dev | `DeclarativeMockHost` and the `mock-host` CLI entry. |
18
+
19
+ The `mock-host` bin (`npx mock-host <dir>`) is shipped — boots a Vite dev page that mounts the declarative interpreter against a directory of SDUI JSON resources. Run `--render-mode remote-runtime <bundle>` for the Contract B variant.
20
+
21
+ ## Local development
22
+
23
+ This package consumes `@ethisyscore/protocol` via `npm link`. The protocol package is registered globally in E1.S8:
24
+
25
+ ```powershell
26
+ cd <repo>/sdk/extension-runtime
27
+ npm install
28
+ npm link @ethisyscore/protocol
29
+ ```
30
+
31
+ ## Scripts
32
+
33
+ | Script | Purpose |
34
+ | ---------------- | ---------------------------------- |
35
+ | `npm run build` | Build ESM + CJS + `.d.ts` via tsup |
36
+ | `npm test` | Run unit tests (Vitest, jsdom) |
37
+ | `npm run lint` | Type-check only (`tsc --noEmit`) |
38
+
39
+ ## Targets
40
+
41
+ - Node.js >= 20
42
+ - React >= 18 (peer dep, required by `./host` and `./plugin` subpaths in later tasks)
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // Thin CJS shim for the `mock-host` bin. Requires the tsup-built CJS bundle
3
+ // and dispatches to `run(argv)` so the dual ESM/CJS package layout doesn't
4
+ // fight with `#!/usr/bin/env node`. Hand-written and committed; tsup is
5
+ // configured to produce `dist/mock-host/cli.cjs` which this loads.
6
+ "use strict";
7
+
8
+ const { run } = require("../dist/mock-host/cli.cjs");
9
+
10
+ run(process.argv.slice(2)).catch(err =>
11
+ {
12
+ process.stderr.write(`mock-host: ${err && err.message ? err.message : err}\n`);
13
+ process.exit(1);
14
+ });
@@ -0,0 +1,600 @@
1
+ 'use strict';
2
+
3
+ var protocol = require('@ethisyscore/protocol');
4
+ var react = require('react');
5
+ var receivers = require('@remote-dom/core/receivers');
6
+ var host = require('@remote-dom/react/host');
7
+
8
+ // src/host/declarative/evaluator.ts
9
+ var KNOWN_OPERATOR_SET = new Set(protocol.KNOWN_OPERATORS);
10
+ function evaluate(expr, context) {
11
+ if (expr === null) {
12
+ return null;
13
+ }
14
+ const t = typeof expr;
15
+ if (t === "string" || t === "number" || t === "boolean") {
16
+ return expr;
17
+ }
18
+ if (t === "function") {
19
+ throw new Error("Function-typed values are not allowed in expressions.");
20
+ }
21
+ if (Array.isArray(expr)) {
22
+ return expr.map((e) => evaluate(e, context));
23
+ }
24
+ if (t !== "object") {
25
+ throw new Error(`Unsupported expression type: ${t}.`);
26
+ }
27
+ const obj = expr;
28
+ const keys = Object.keys(obj);
29
+ if (keys.length !== 1) {
30
+ throw new Error(
31
+ `Expression object must have exactly one operator key (got ${keys.length}).`
32
+ );
33
+ }
34
+ const op = keys[0];
35
+ if (!KNOWN_OPERATOR_SET.has(op)) {
36
+ throw new Error(`Unknown operator: ${op}.`);
37
+ }
38
+ const rawArgs = obj[op];
39
+ if (op === "var") {
40
+ const path = typeof rawArgs === "string" ? rawArgs : Array.isArray(rawArgs) && typeof rawArgs[0] === "string" ? rawArgs[0] : "";
41
+ return resolveVar(path, context);
42
+ }
43
+ const evaluatedArgs = Array.isArray(rawArgs) ? rawArgs.map((a) => evaluate(a, context)) : [evaluate(rawArgs, context)];
44
+ switch (op) {
45
+ case "==":
46
+ return evaluatedArgs[0] === evaluatedArgs[1];
47
+ case "!=":
48
+ return evaluatedArgs[0] !== evaluatedArgs[1];
49
+ case ">":
50
+ return evaluatedArgs[0] > evaluatedArgs[1];
51
+ case "<":
52
+ return evaluatedArgs[0] < evaluatedArgs[1];
53
+ case ">=":
54
+ return evaluatedArgs[0] >= evaluatedArgs[1];
55
+ case "<=":
56
+ return evaluatedArgs[0] <= evaluatedArgs[1];
57
+ case "and":
58
+ return evaluatedArgs.every(Boolean);
59
+ case "or":
60
+ return evaluatedArgs.some(Boolean);
61
+ case "not":
62
+ return !evaluatedArgs[0];
63
+ case "+":
64
+ return evaluatedArgs.reduce((acc, n) => acc + n, 0);
65
+ case "-":
66
+ return evaluatedArgs[0] - evaluatedArgs[1];
67
+ case "*":
68
+ return evaluatedArgs.reduce((acc, n) => acc * n, 1);
69
+ case "/":
70
+ return safeDivide(evaluatedArgs[0], evaluatedArgs[1]);
71
+ case "%":
72
+ return safeModulo(evaluatedArgs[0], evaluatedArgs[1]);
73
+ default:
74
+ throw new Error(`Unknown operator: ${op}.`);
75
+ }
76
+ }
77
+ function safeDivide(a, b) {
78
+ if (b === 0 || !Number.isFinite(b)) {
79
+ throw new Error(
80
+ `Division by ${b === 0 ? "zero" : String(b)} is not allowed in the closed evaluator.`
81
+ );
82
+ }
83
+ return a / b;
84
+ }
85
+ function safeModulo(a, b) {
86
+ if (b === 0 || !Number.isFinite(b)) {
87
+ throw new Error(
88
+ `Modulo by ${b === 0 ? "zero" : String(b)} is not allowed in the closed evaluator.`
89
+ );
90
+ }
91
+ return a % b;
92
+ }
93
+ function resolveVar(path, context) {
94
+ if (path === "") {
95
+ return void 0;
96
+ }
97
+ const parts = path.split(".");
98
+ let curr = context;
99
+ for (const part of parts) {
100
+ if (curr === null || typeof curr !== "object") {
101
+ return void 0;
102
+ }
103
+ curr = curr[part];
104
+ }
105
+ return curr;
106
+ }
107
+ function interpret(node, registry) {
108
+ const Component = registry[node.type];
109
+ if (!Component) {
110
+ throw new Error(`Unknown SDUI primitive: ${String(node.type)}`);
111
+ }
112
+ const children = node.children?.map(
113
+ (child, index) => interpretChild(child, registry, index)
114
+ );
115
+ return react.createElement(Component, { props: node.props }, children);
116
+ }
117
+ function interpretChild(node, registry, index) {
118
+ const Component = registry[node.type];
119
+ if (!Component) {
120
+ throw new Error(`Unknown SDUI primitive: ${String(node.type)}`);
121
+ }
122
+ const children = node.children?.map(
123
+ (child, childIndex) => interpretChild(child, registry, childIndex)
124
+ );
125
+ return react.createElement(Component, { props: node.props, key: index }, children);
126
+ }
127
+
128
+ // src/host/worker/component-registry.ts
129
+ var CONTRACT_B_PRIMITIVES = [
130
+ "Button",
131
+ "DataTable",
132
+ "Form",
133
+ "EntityPicker",
134
+ "CommandBar",
135
+ "Drawer",
136
+ "Modal",
137
+ "CanvasSurface",
138
+ "WebGLSurface"
139
+ ];
140
+ var KNOWN_PRIMITIVE_SET = new Set(CONTRACT_B_PRIMITIVES);
141
+ var SemanticComponentRegistry = class _SemanticComponentRegistry {
142
+ entries = /* @__PURE__ */ new Map();
143
+ /**
144
+ * Register a primitive → component binding.
145
+ *
146
+ * Throws if `name` is not in {@link CONTRACT_B_PRIMITIVES} (drift prevention)
147
+ * or if `name` is already registered (silent-override prevention).
148
+ */
149
+ register(name, component) {
150
+ if (!KNOWN_PRIMITIVE_SET.has(name)) {
151
+ throw new Error(
152
+ `[component-registry] unknown semantic primitive '${name}'. Allowed: ${CONTRACT_B_PRIMITIVES.join(", ")}.`
153
+ );
154
+ }
155
+ if (this.entries.has(name)) {
156
+ throw new Error(
157
+ `[component-registry] primitive '${name}' is already registered. Construct a new registry instead of overriding a binding.`
158
+ );
159
+ }
160
+ this.entries.set(name, component);
161
+ }
162
+ /**
163
+ * Resolve a primitive name to its concrete component. Throws if the name is
164
+ * outside the Contract B vocabulary OR if the registry has no binding —
165
+ * silent fallthrough would let typos render blank components, which is
166
+ * worse than a fast failure for plugin authors.
167
+ */
168
+ resolve(name) {
169
+ if (!KNOWN_PRIMITIVE_SET.has(name)) {
170
+ throw new Error(
171
+ `[component-registry] unknown semantic primitive '${name}'. Allowed: ${CONTRACT_B_PRIMITIVES.join(", ")}.`
172
+ );
173
+ }
174
+ const found = this.entries.get(name);
175
+ if (found === void 0) {
176
+ const registered = this.listRegistered().join(", ") || "<none>";
177
+ throw new Error(
178
+ `[component-registry] primitive '${name}' is not registered. Registered primitives: ${registered}.`
179
+ );
180
+ }
181
+ return found;
182
+ }
183
+ /**
184
+ * Cheap presence probe — never throws. Use this when callers want to
185
+ * fall back to a default component instead of erroring.
186
+ */
187
+ has(name) {
188
+ return this.entries.has(name);
189
+ }
190
+ /**
191
+ * Sorted list of registered primitive names. Stable output makes registry
192
+ * diagnostics and snapshot tests deterministic.
193
+ */
194
+ listRegistered() {
195
+ return [...this.entries.keys()].sort();
196
+ }
197
+ /**
198
+ * Build a registry from a complete `Record<SemanticPrimitiveName, T>` map.
199
+ *
200
+ * The `Record` type forces TypeScript to refuse incomplete literals at
201
+ * compile time, AND we re-check at runtime so callers who type-erase the
202
+ * record (e.g., via `as any`) still get a fast failure.
203
+ */
204
+ static fromMap(map) {
205
+ const registry = new _SemanticComponentRegistry();
206
+ const missing = [];
207
+ for (const name of CONTRACT_B_PRIMITIVES) {
208
+ const component = map[name];
209
+ if (component === void 0) {
210
+ missing.push(name);
211
+ continue;
212
+ }
213
+ registry.register(name, component);
214
+ }
215
+ if (missing.length > 0) {
216
+ throw new Error(
217
+ `[component-registry] fromMap is missing semantic primitive(s): ${missing.join(", ")}.`
218
+ );
219
+ }
220
+ return registry;
221
+ }
222
+ };
223
+
224
+ // src/host/worker/offscreen.ts
225
+ var DEFAULT_OFFSCREEN_COALESCE_MS = 16;
226
+ var transferredCanvases = /* @__PURE__ */ new WeakSet();
227
+ function createOffscreenCanvasTransfer(options) {
228
+ const { canvas, surfaceId, postMessage } = options;
229
+ const proto = HTMLCanvasElement.prototype;
230
+ if (typeof proto.transferControlToOffscreen !== "function") {
231
+ throw new Error(
232
+ "[offscreen-canvas] OffscreenCanvas is not supported in this environment. Contract B canvas surfaces require a browser with HTMLCanvasElement.transferControlToOffscreen."
233
+ );
234
+ }
235
+ if (transferredCanvases.has(canvas)) {
236
+ throw new Error(
237
+ `[offscreen-canvas] canvas already transferred (surfaceId='${surfaceId}'). Create a fresh <canvas> for each surface instead of re-transferring.`
238
+ );
239
+ }
240
+ const offscreen = canvas.transferControlToOffscreen();
241
+ transferredCanvases.add(canvas);
242
+ const envelope = {
243
+ type: "ethisys:offscreen:transfer",
244
+ surfaceId,
245
+ width: canvas.width,
246
+ height: canvas.height,
247
+ offscreen
248
+ };
249
+ postMessage(envelope, [offscreen]);
250
+ return { offscreen };
251
+ }
252
+ function createInputEventCoalescer(sink, options = {}) {
253
+ if (options.discrete === true) {
254
+ return (payload) => {
255
+ sink(payload);
256
+ };
257
+ }
258
+ const coalesceMs = options.coalesceMs ?? DEFAULT_OFFSCREEN_COALESCE_MS;
259
+ let pending;
260
+ let pendingSet = false;
261
+ let timer;
262
+ const flush = () => {
263
+ timer = void 0;
264
+ if (!pendingSet) {
265
+ return;
266
+ }
267
+ const value = pending;
268
+ pending = void 0;
269
+ pendingSet = false;
270
+ sink(value);
271
+ };
272
+ return (payload) => {
273
+ pending = payload;
274
+ pendingSet = true;
275
+ if (timer === void 0) {
276
+ timer = setTimeout(flush, coalesceMs);
277
+ }
278
+ };
279
+ }
280
+
281
+ // src/host/worker/transport.ts
282
+ var WORKER_TRANSPORT_PROTOCOL = "ethisys.worker.remotedom.v1";
283
+ var DEFAULT_MAX_CONCURRENT_MCP_REQUESTS = 8;
284
+ var WorkerRemoteDomTransport = class {
285
+ worker;
286
+ hostPort;
287
+ workerPort;
288
+ mcpClient;
289
+ capabilityTokenProvider;
290
+ coalesceMs;
291
+ maxConcurrentMcpRequests;
292
+ abortController;
293
+ inFlightMcpRequests = 0;
294
+ remoteDomConsumer;
295
+ disposed = false;
296
+ connected = false;
297
+ constructor(options) {
298
+ const WorkerCtor = options.workerCtor ?? globalThis.Worker;
299
+ if (!WorkerCtor) {
300
+ throw new Error("WorkerRemoteDomTransport: no Worker constructor available");
301
+ }
302
+ this.mcpClient = options.mcpClient;
303
+ this.capabilityTokenProvider = options.capabilityToken;
304
+ this.coalesceMs = options.coalesceMs ?? 16;
305
+ this.maxConcurrentMcpRequests = Math.max(
306
+ 1,
307
+ options.maxConcurrentMcpRequests ?? DEFAULT_MAX_CONCURRENT_MCP_REQUESTS
308
+ );
309
+ this.abortController = new AbortController();
310
+ this.worker = new WorkerCtor(options.bootstrapUrl, { type: "module" });
311
+ const channel = new MessageChannel();
312
+ this.hostPort = channel.port1;
313
+ this.workerPort = channel.port2;
314
+ this.hostPort.onmessage = (ev) => this.handlePortMessage(ev.data);
315
+ this.hostPort.start();
316
+ this.worker.onerror = (ev) => {
317
+ };
318
+ }
319
+ /**
320
+ * Post the handshake to the worker. Idempotent — only the FIRST call
321
+ * transfers the {@link MessagePort} and posts the handshake payload;
322
+ * subsequent calls are silent no-ops. Splitting this off from the
323
+ * constructor lets the host mount fetch the per-plugin
324
+ * `worker-bundle.import-map.json` (and resolve the bundle's module URL)
325
+ * before the bootstrap script consumes them.
326
+ *
327
+ * Wire shape: {@link WorkerHandshakePayload}. The capability token NEVER
328
+ * appears in the payload — it's bound on the host side via the
329
+ * {@link WorkerRemoteDomTransportOptions.capabilityToken} provider.
330
+ *
331
+ * The `moduleUrl` and `importMap` are forwarded to the worker so the
332
+ * bootstrap script (served from the host-pinned
333
+ * `/extensions/runtime/worker-bootstrap.js`) can:
334
+ * 1. Compose the frozen, same-origin-validated `IMPORT_MAP` from the
335
+ * handshake payload (rejecting any cross-origin entries).
336
+ * 2. `safeImport(moduleUrl)` the plugin's entry module.
337
+ *
338
+ * Same-origin enforcement is the bootstrap's job — this transport is
339
+ * structurally agnostic to the host origin and only forwards what it's
340
+ * handed.
341
+ */
342
+ connect(moduleUrl, importMap) {
343
+ if (this.connected) {
344
+ return;
345
+ }
346
+ this.connected = true;
347
+ const handshake = Object.freeze({
348
+ type: "ethisys:worker:handshake",
349
+ protocol: WORKER_TRANSPORT_PROTOCOL,
350
+ moduleUrl,
351
+ importMap: Object.freeze({ ...importMap })
352
+ });
353
+ this.worker.postMessage(handshake, [this.workerPort]);
354
+ }
355
+ /**
356
+ * Bind a consumer for Remote DOM mutation payloads emitted by the worker.
357
+ * The Remote DOM receiver wiring is owned by the caller (typically a host
358
+ * React component); this method exposes the raw stream so the receiver
359
+ * can integrate cleanly without a circular package import.
360
+ */
361
+ onRemoteDom(consumer) {
362
+ this.remoteDomConsumer = consumer;
363
+ }
364
+ /**
365
+ * Transfer control of a host-owned `<canvas>` to the worker so plugin code
366
+ * can render via `OffscreenCanvas`. The host retains the `<canvas>` for
367
+ * layout / accessibility purposes only — every pixel is produced inside the
368
+ * worker, so the main thread is never blocked per frame.
369
+ *
370
+ * The transfer rides the established MessagePort (not the worker global
371
+ * `postMessage`) so it is multiplexed with the rest of the host ↔ worker
372
+ * traffic on the same channel. The `OffscreenCanvas` handle is the sole
373
+ * `Transferable` in the envelope; no capability token, MCP context, or
374
+ * other host-only data crosses the boundary.
375
+ *
376
+ * Throws if the environment lacks `transferControlToOffscreen()` or if the
377
+ * canvas has already been transferred — see {@link createOffscreenCanvasTransfer}
378
+ * for the exact diagnostics.
379
+ */
380
+ transferCanvas(canvas, options) {
381
+ return createOffscreenCanvasTransfer({
382
+ canvas,
383
+ surfaceId: options.surfaceId,
384
+ postMessage: (message, transfer) => {
385
+ this.hostPort.postMessage(message, transfer);
386
+ }
387
+ });
388
+ }
389
+ /**
390
+ * Forward a host-sourced input event to the worker over the port. The
391
+ * payload shape is opaque to the transport — pointer / wheel / keyboard
392
+ * envelopes share the same wire type. Callers are expected to wrap
393
+ * high-frequency (pointer-move, wheel, scroll) events in
394
+ * {@link createCoalescer} or {@link createInputEventCoalescer} BEFORE
395
+ * calling this method so the port never sees the un-coalesced flood.
396
+ *
397
+ * Keyboard / pointer-down / pointer-up events are discrete and should be
398
+ * delivered without coalescing — pass them straight through.
399
+ */
400
+ postInputEvent(payload) {
401
+ this.hostPort.postMessage({
402
+ type: "ethisys:input:event",
403
+ payload
404
+ });
405
+ }
406
+ /**
407
+ * Build a trailing-edge coalescer keyed to {@link WorkerRemoteDomTransportOptions.coalesceMs}.
408
+ *
409
+ * Use it to wrap pointer-move / scroll / resize callbacks **before**
410
+ * they cross the port. The last payload in any coalescing window is the
411
+ * one that wins.
412
+ */
413
+ createCoalescer(consumer) {
414
+ let pending;
415
+ let timer;
416
+ const flush = () => {
417
+ timer = void 0;
418
+ const value = pending;
419
+ pending = void 0;
420
+ if (value !== void 0) {
421
+ consumer(value);
422
+ }
423
+ };
424
+ return (payload) => {
425
+ pending = payload;
426
+ if (timer === void 0) {
427
+ timer = setTimeout(flush, this.coalesceMs);
428
+ }
429
+ };
430
+ }
431
+ /**
432
+ * Tear down the worker and port. Idempotent.
433
+ *
434
+ * Order matters: we abort BEFORE closing the port so any in-flight
435
+ * `mcpClient.fetch` that observes the signal short-circuits and the
436
+ * subsequent attempt to post a reply lands in the disposed-guard branch
437
+ * (which silently drops the post) instead of throwing on a closed port.
438
+ */
439
+ dispose() {
440
+ if (this.disposed) {
441
+ return;
442
+ }
443
+ this.disposed = true;
444
+ try {
445
+ this.abortController.abort();
446
+ } catch {
447
+ }
448
+ try {
449
+ this.hostPort.close();
450
+ } catch {
451
+ }
452
+ try {
453
+ this.worker.terminate();
454
+ } catch {
455
+ }
456
+ }
457
+ /**
458
+ * Best-effort wrapper around `hostPort.postMessage` that tolerates posts
459
+ * arriving after {@link dispose}. The browser throws on a closed port and
460
+ * the async handlers below can race dispose, so any post initiated by an
461
+ * awaited continuation must be guarded.
462
+ */
463
+ safePostMessage(message) {
464
+ if (this.disposed) {
465
+ return;
466
+ }
467
+ try {
468
+ this.hostPort.postMessage(message);
469
+ } catch {
470
+ }
471
+ }
472
+ // ─── Port message handling ──────────────────────────────────────────────
473
+ handlePortMessage(message) {
474
+ if (this.disposed) {
475
+ return;
476
+ }
477
+ switch (message.type) {
478
+ case "ethisys:mcp:invokeTool":
479
+ this.dispatchMcp(message, "ethisys:mcp:invokeTool:result", (m) => this.handleInvokeTool(m));
480
+ return;
481
+ case "ethisys:mcp:getResource":
482
+ this.dispatchMcp(message, "ethisys:mcp:getResource:result", (m) => this.handleGetResource(m));
483
+ return;
484
+ case "ethisys:remotedom":
485
+ this.remoteDomConsumer?.(message.payload);
486
+ return;
487
+ default:
488
+ return;
489
+ }
490
+ }
491
+ /**
492
+ * Gate inbound MCP requests through the bounded concurrency window,
493
+ * reject with a deterministic error reply when the cap is exceeded, and
494
+ * decrement the counter unconditionally when the underlying handler
495
+ * settles (regardless of resolution/rejection shape).
496
+ */
497
+ dispatchMcp(message, resultType, handler) {
498
+ if (this.inFlightMcpRequests >= this.maxConcurrentMcpRequests) {
499
+ this.safePostMessage({
500
+ id: message.id,
501
+ type: resultType,
502
+ ok: false,
503
+ error: `MCP back-pressure: in-flight cap of ${this.maxConcurrentMcpRequests} reached.`
504
+ });
505
+ return;
506
+ }
507
+ this.inFlightMcpRequests++;
508
+ handler(message).finally(() => {
509
+ this.inFlightMcpRequests = Math.max(0, this.inFlightMcpRequests - 1);
510
+ });
511
+ }
512
+ async handleInvokeTool(message) {
513
+ let token;
514
+ try {
515
+ token = await this.capabilityTokenProvider();
516
+ } catch (err) {
517
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
518
+ return;
519
+ }
520
+ if (this.disposed) {
521
+ return;
522
+ }
523
+ try {
524
+ const result = await this.mcpClient.fetch({
525
+ kind: "invokeTool",
526
+ name: message.name,
527
+ args: message.args,
528
+ capabilityToken: token,
529
+ signal: this.abortController.signal
530
+ });
531
+ this.safePostMessage({
532
+ id: message.id,
533
+ type: "ethisys:mcp:invokeTool:result",
534
+ ok: result.ok,
535
+ data: result.data,
536
+ error: result.error
537
+ });
538
+ } catch (err) {
539
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
540
+ }
541
+ }
542
+ async handleGetResource(message) {
543
+ let token;
544
+ try {
545
+ token = await this.capabilityTokenProvider();
546
+ } catch (err) {
547
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
548
+ return;
549
+ }
550
+ if (this.disposed) {
551
+ return;
552
+ }
553
+ try {
554
+ const result = await this.mcpClient.fetch({
555
+ kind: "getResource",
556
+ uri: message.uri,
557
+ capabilityToken: token,
558
+ signal: this.abortController.signal
559
+ });
560
+ this.safePostMessage({
561
+ id: message.id,
562
+ type: "ethisys:mcp:getResource:result",
563
+ ok: result.ok,
564
+ data: result.data,
565
+ error: result.error
566
+ });
567
+ } catch (err) {
568
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
569
+ }
570
+ }
571
+ replyError(id, type, err) {
572
+ const message = err instanceof Error ? err.message : "MCP request failed";
573
+ this.safePostMessage({ id, type, ok: false, error: message });
574
+ }
575
+ };
576
+
577
+ Object.defineProperty(exports, "RemoteReceiver", {
578
+ enumerable: true,
579
+ get: function () { return receivers.RemoteReceiver; }
580
+ });
581
+ Object.defineProperty(exports, "RemoteRootRenderer", {
582
+ enumerable: true,
583
+ get: function () { return host.RemoteRootRenderer; }
584
+ });
585
+ Object.defineProperty(exports, "createRemoteComponentRenderer", {
586
+ enumerable: true,
587
+ get: function () { return host.createRemoteComponentRenderer; }
588
+ });
589
+ exports.CONTRACT_B_PRIMITIVES = CONTRACT_B_PRIMITIVES;
590
+ exports.DEFAULT_MAX_CONCURRENT_MCP_REQUESTS = DEFAULT_MAX_CONCURRENT_MCP_REQUESTS;
591
+ exports.DEFAULT_OFFSCREEN_COALESCE_MS = DEFAULT_OFFSCREEN_COALESCE_MS;
592
+ exports.SemanticComponentRegistry = SemanticComponentRegistry;
593
+ exports.WORKER_TRANSPORT_PROTOCOL = WORKER_TRANSPORT_PROTOCOL;
594
+ exports.WorkerRemoteDomTransport = WorkerRemoteDomTransport;
595
+ exports.createInputEventCoalescer = createInputEventCoalescer;
596
+ exports.createOffscreenCanvasTransfer = createOffscreenCanvasTransfer;
597
+ exports.evaluate = evaluate;
598
+ exports.interpret = interpret;
599
+ //# sourceMappingURL=index.cjs.map
600
+ //# sourceMappingURL=index.cjs.map