@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,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
|
+
|
|
|
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
|
+
"&": "&",
|
|
446
|
+
"<": "<",
|
|
447
|
+
">": ">",
|
|
448
|
+
'"': """,
|
|
449
|
+
"'": "'"
|
|
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> | <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
|