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