@ekrooh/bare 0.4.0 → 0.5.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.
@@ -0,0 +1,849 @@
1
+ import { TextDecoder as TextDecoder$1, TextEncoder as TextEncoder$1 } from "bare-encoding";
2
+ // core/messages/constants.ts
3
+ const VERSION = 1;
4
+ const MessageType = { ENVELOPE: 1 };
5
+ /** Canonical error codes. Everything the framework throws uses these; peers
6
+ * may carry arbitrary codes over the wire, and consumers may return app-scoped
7
+ * codes (e.g. `app.photos/not-found`) which are preserved verbatim. Keep
8
+ * `android/src/main/.../ErrorCodes.kt` in sync with the canonical union. */
9
+ const ErrorCode = {
10
+ UNSUPPORTED_CAPABILITY: "UNSUPPORTED_CAPABILITY",
11
+ UNSUPPORTED_EVENT: "UNSUPPORTED_EVENT",
12
+ HOST_ERROR: "HOST_ERROR",
13
+ TRANSPORT_ERROR: "TRANSPORT_ERROR",
14
+ PLUGIN_ERROR: "PLUGIN_ERROR",
15
+ INVALID_RESPONSE: "INVALID_RESPONSE",
16
+ FRAME_TOO_LARGE: "FRAME_TOO_LARGE",
17
+ FRAME_INVALID: "FRAME_INVALID",
18
+ TIMEOUT: "TIMEOUT"
19
+ };
20
+ /** A JSON header must fit a 16-bit big-endian length field. */
21
+ const MAX_HEADER_BYTES = 65535;
22
+ /** Upper bound on one encoded frame. Large binary transfers are a plugin-level
23
+ * concern (out-of-band handles), not an in-band payload. */
24
+ const MAX_FRAME_BYTES = 16777216;
25
+ // core/messages/types.ts
26
+ var CoreError = class extends Error {
27
+ code;
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.name = "CoreError";
31
+ this.code = code;
32
+ }
33
+ };
34
+ // core/messages/errors.ts
35
+ /** Success half of an `Either` tuple. */
36
+ function ok(result) {
37
+ return [null, result];
38
+ }
39
+ /** Failure half of an `Either` tuple. Accepts a canonical error code or any
40
+ * app-scoped code (`app.photos/not-found`) — the code is preserved verbatim. */
41
+ function err(code, message) {
42
+ return [new CoreError(code, message), null];
43
+ }
44
+ /** Convenience for constructing a `CoreError` from a possibly-non-canonical
45
+ * wire code: preserves known codes, maps anything else to `PLUGIN_ERROR`.
46
+ * Framework callers that want app codes preserved construct the error
47
+ * directly instead. */
48
+ function coerceErrorCode(code) {
49
+ if (code && code in ErrorCode) return code;
50
+ return ErrorCode.PLUGIN_ERROR;
51
+ }
52
+ // core/messages/plugin-kernel.ts
53
+ function createPluginRegistry() {
54
+ const plugins = /* @__PURE__ */ new Map();
55
+ return {
56
+ register(plugin) {
57
+ if (!plugin.id.includes(".")) throw new Error(`plugin id must be namespaced (example: "vendor.plugin"), got "${plugin.id}"`);
58
+ if (plugins.has(plugin.id)) throw new Error(`plugin id "${plugin.id}" is already registered`);
59
+ plugins.set(plugin.id, plugin);
60
+ },
61
+ resolve(pluginId, runtime) {
62
+ return plugins.get(pluginId)?.runtimes[runtime];
63
+ },
64
+ get(pluginId) {
65
+ return plugins.get(pluginId);
66
+ },
67
+ listCapabilities(runtime) {
68
+ const rows = [];
69
+ for (const plugin of plugins.values()) {
70
+ const runtimes = Object.keys(plugin.runtimes);
71
+ if (runtime && !runtimes.includes(runtime)) continue;
72
+ rows.push({
73
+ pluginId: plugin.id,
74
+ capabilities: plugin.capabilities ?? [],
75
+ events: plugin.events ?? [],
76
+ runtimes
77
+ });
78
+ }
79
+ return rows;
80
+ }
81
+ };
82
+ }
83
+ const DEFAULT_MAX_INFLIGHT = 64;
84
+ const DEFAULT_HOST_CAPABILITIES_TTL_MS = 3e4;
85
+ /** An event the cached snapshot does not list triggers a fresh query only once
86
+ * the snapshot is at least this old, so a host that announces a capability
87
+ * late is honored quickly while a stream of calls to an unsupported event
88
+ * cannot turn into a stream of capability queries. */
89
+ const NEGATIVE_REFRESH_MS = 2e3;
90
+ /** Whether the router may synthesize an UNSUPPORTED_EVENT response. Events are
91
+ * only gated when the manifest declares them; a plugin with no manifest/owner
92
+ * declares nothing and is rejected. */
93
+ function declaresEvent(plugin, event) {
94
+ if (!plugin) return false;
95
+ if (!plugin.events) return false;
96
+ return plugin.events.includes(event);
97
+ }
98
+ function createPluginRouter(registry, runtime, options) {
99
+ const logger = options?.logger ?? console;
100
+ const maxInflight = options?.maxInflight ?? DEFAULT_MAX_INFLIGHT;
101
+ const serialPlugins = new Set(options?.serialPlugins);
102
+ const serialTails = /* @__PURE__ */ new Map();
103
+ let inflight = 0;
104
+ const capsTtlMs = options?.hostCapabilitiesTtlMs ?? DEFAULT_HOST_CAPABILITIES_TTL_MS;
105
+ let capsCache = null;
106
+ let capsInflight = null;
107
+ /** Asks the host for its capabilities. Concurrent callers share one query
108
+ * (calls now overlap, so each would otherwise send its own). A failed query
109
+ * rejects every waiter and is not cached. */
110
+ function queryHostCapabilities() {
111
+ if (capsInflight) return capsInflight;
112
+ const query = (async () => await options?.getHostCapabilities?.() ?? [])().then((caps) => {
113
+ capsCache = {
114
+ caps,
115
+ at: Date.now()
116
+ };
117
+ return caps;
118
+ }).finally(() => {
119
+ if (capsInflight === query) capsInflight = null;
120
+ });
121
+ capsInflight = query;
122
+ return query;
123
+ }
124
+ /** Whether the host currently announces `pluginId.event`. Served from the
125
+ * cached snapshot while it is fresh; re-queried when it has expired, or when
126
+ * it does not list the event and is old enough to have changed. */
127
+ async function hostAnnounces(pluginId, event) {
128
+ if (capsTtlMs > 0 && capsCache) {
129
+ const age = Date.now() - capsCache.at;
130
+ if (age < capsTtlMs) {
131
+ if (isHostRegistered(pluginId, event, capsCache.caps)) return true;
132
+ if (age < NEGATIVE_REFRESH_MS) return false;
133
+ }
134
+ }
135
+ return isHostRegistered(pluginId, event, await queryHostCapabilities());
136
+ }
137
+ /** Deny-by-default host-delegation allowlist: an event can only be delegated
138
+ * once the host has announced it via `HOST_CAPABILITIES_RESPONSE`. */
139
+ function isHostRegistered(pluginId, event, caps) {
140
+ const row = caps.find((c) => c.pluginId === pluginId);
141
+ return row ? row.events.includes(event) : false;
142
+ }
143
+ /** Runs `task` after the previous call to the same serial plugin settles, so
144
+ * a failing call never wedges the queue behind it. */
145
+ function runSerial(pluginId, task) {
146
+ const run = (serialTails.get(pluginId) ?? Promise.resolve()).then(task, task);
147
+ const next = run.catch(() => void 0);
148
+ serialTails.set(pluginId, next);
149
+ next.then(() => {
150
+ if (serialTails.get(pluginId) === next) serialTails.delete(pluginId);
151
+ });
152
+ return run;
153
+ }
154
+ async function routeNow(header, payload) {
155
+ const plugin = isPluginEnvelopeHeader(header) ? registry.get(header.pluginId) : void 0;
156
+ if (isPluginEnvelopeHeader(header)) {
157
+ if (!declaresEvent(plugin, header.event)) {
158
+ const error = new CoreError(ErrorCode.UNSUPPORTED_EVENT, `Unsupported event ${header.pluginId}.${header.event} on ${runtime}`);
159
+ if (isPluginDispatchHeader(header)) logger.warn(error.message);
160
+ return invokeErrorResponse(header, error);
161
+ }
162
+ }
163
+ if (isPluginDispatchHeader(header)) {
164
+ const runtimeAdapter = registry.resolve(header.pluginId, runtime);
165
+ if (!runtimeAdapter?.dispatch) {
166
+ const error = new CoreError(ErrorCode.UNSUPPORTED_CAPABILITY, `Unsupported capability ${header.pluginId}.${header.event} on ${runtime}`);
167
+ logger.warn(error.message);
168
+ return invokeErrorResponse(header, error);
169
+ }
170
+ try {
171
+ await runtimeAdapter.dispatch(header.event, header.args, {
172
+ runtime,
173
+ payload,
174
+ sender: { pluginId: header.pluginId }
175
+ });
176
+ return null;
177
+ } catch (e) {
178
+ const error = new CoreError(ErrorCode.PLUGIN_ERROR, e instanceof Error ? e.message : String(e));
179
+ logger.error(`dispatch ${header.pluginId}.${header.event} failed: ${error.message}`);
180
+ return invokeErrorResponse(header, error);
181
+ }
182
+ }
183
+ if (isPluginInvokeRequestHeader(header)) {
184
+ const runtimeAdapter = registry.resolve(header.pluginId, runtime);
185
+ if (runtimeAdapter?.invoke) try {
186
+ const [error, okResult] = await runtimeAdapter.invoke(header.event, header.args, {
187
+ runtime,
188
+ payload,
189
+ sender: { pluginId: header.pluginId }
190
+ });
191
+ return {
192
+ type: "INVOKE_RESPONSE",
193
+ pluginId: header.pluginId,
194
+ event: header.event,
195
+ requestId: header.requestId,
196
+ result: okResult ?? void 0,
197
+ error: error ? {
198
+ code: error.code,
199
+ message: error.message
200
+ } : void 0
201
+ };
202
+ } catch (e) {
203
+ const error = new CoreError(ErrorCode.PLUGIN_ERROR, e instanceof Error ? e.message : String(e));
204
+ logger.error(`invoke ${header.pluginId}.${header.event} failed: ${error.message}`);
205
+ return invokeErrorResponse(header, error);
206
+ }
207
+ if (header.requestId && options?.delegateToHost) {
208
+ if (await hostAnnounces(header.pluginId, header.event)) try {
209
+ const delegated = await options.delegateToHost(header, payload);
210
+ if (delegated) {
211
+ const code = delegated.error?.code;
212
+ if (code === ErrorCode.UNSUPPORTED_CAPABILITY || code === ErrorCode.UNSUPPORTED_EVENT) capsCache = null;
213
+ return delegated;
214
+ }
215
+ capsCache = null;
216
+ } catch (e) {
217
+ capsCache = null;
218
+ return invokeErrorResponse(header, new CoreError(ErrorCode.HOST_ERROR, e instanceof Error ? e.message : String(e)));
219
+ }
220
+ }
221
+ return invokeErrorResponse(header, new CoreError(ErrorCode.UNSUPPORTED_CAPABILITY, `Unsupported capability ${header.pluginId}.${header.event} on ${runtime}`));
222
+ }
223
+ return null;
224
+ }
225
+ return { async route(header, payload) {
226
+ if (!isPluginEnvelopeHeader(header)) return routeNow(header, payload);
227
+ if (inflight >= maxInflight) {
228
+ const error = new CoreError(ErrorCode.TRANSPORT_ERROR, `too many calls in flight (limit ${maxInflight}); retry later`);
229
+ logger.warn(`${header.pluginId}.${header.event}: ${error.message}`);
230
+ return invokeErrorResponse(header, error);
231
+ }
232
+ inflight++;
233
+ try {
234
+ return serialPlugins.has(header.pluginId) ? await runSerial(header.pluginId, () => routeNow(header, payload)) : await routeNow(header, payload);
235
+ } finally {
236
+ inflight--;
237
+ }
238
+ } };
239
+ }
240
+ function createPluginBus(messenger) {
241
+ return {
242
+ dispatch(envelope) {
243
+ return messenger.dispatch({
244
+ type: "DISPATCH",
245
+ pluginId: envelope.pluginId,
246
+ event: envelope.event,
247
+ args: envelope.args
248
+ });
249
+ },
250
+ async invoke(envelope) {
251
+ let response;
252
+ try {
253
+ response = await messenger.invoke({
254
+ type: "INVOKE_REQUEST",
255
+ pluginId: envelope.pluginId,
256
+ event: envelope.event,
257
+ args: envelope.args
258
+ }, envelope.payload ?? null, envelope.timeoutMs);
259
+ } catch (error) {
260
+ const message = error instanceof Error ? error.message : String(error);
261
+ return [new CoreError(message.startsWith("invoke timeout") ? ErrorCode.TIMEOUT : ErrorCode.INVALID_RESPONSE, message), null];
262
+ }
263
+ if (isPluginInvokeResponseHeader(response)) {
264
+ if (response.error) return [new CoreError(response.error.code, String(response.error.message ?? "Plugin invoke failed")), null];
265
+ return [null, response.result];
266
+ }
267
+ return [new CoreError(ErrorCode.INVALID_RESPONSE, `Unexpected response type ${response.type}`), null];
268
+ }
269
+ };
270
+ }
271
+ function invokeErrorResponse(header, error) {
272
+ return {
273
+ type: "INVOKE_RESPONSE",
274
+ pluginId: header.pluginId,
275
+ event: header.event,
276
+ requestId: header.requestId,
277
+ error: {
278
+ code: error.code,
279
+ message: error.message
280
+ }
281
+ };
282
+ }
283
+ function isPluginDispatchHeader(header) {
284
+ return header.type === "DISPATCH" && typeof header.pluginId === "string" && typeof header.event === "string";
285
+ }
286
+ function isPluginEnvelopeHeader(header) {
287
+ return isPluginDispatchHeader(header) || isPluginInvokeRequestHeader(header);
288
+ }
289
+ function isPluginInvokeRequestHeader(header) {
290
+ return header.type === "INVOKE_REQUEST" && typeof header.pluginId === "string" && typeof header.event === "string";
291
+ }
292
+ function isPluginInvokeResponseHeader(header) {
293
+ return header.type === "INVOKE_RESPONSE" && typeof header.pluginId === "string" && typeof header.event === "string";
294
+ }
295
+ // core/messages/request-id.ts
296
+ /**
297
+ * CSPRNG-backed correlation id for RPC requests.
298
+ *
299
+ * Shared by the production messenger (`rpc-messenger.ts`) and the mantaq
300
+ * prototype (`rpc-messenger.mantaq.ts`) so the unguessable,
301
+ * collision-resistant guarantee can never regress in a single copy. Request
302
+ * IDs must never be derived from `Math.random()` — a guessable id lets a peer
303
+ * spoof or collide with an in-flight request and corrupt correlation. See
304
+ * ekrooh#141.
305
+ */
306
+ function createRequestId() {
307
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
308
+ globalThis.crypto.getRandomValues(bytes);
309
+ const rand = bytes.reduce((acc, b) => acc + b.toString(36), "").slice(0, 22);
310
+ return `${Date.now().toString(36)}-${rand}`;
311
+ }
312
+ // core/messages/rpc-messenger.ts
313
+ /** Hard ceiling on concurrently in-flight invokes. Above this, the oldest call
314
+ * is dropped (its timer cleared and promise rejected) so a flood of requests
315
+ * cannot grow the pending map without bound. */
316
+ const MAX_CONCURRENT_INVOKES = 1024;
317
+ function createProtocolMessenger(send) {
318
+ const pending = /* @__PURE__ */ new Map();
319
+ const sharedLogTail = createSharedLogTail();
320
+ return {
321
+ dispatch(request, payload) {
322
+ const requestWithId = withRequestId(request);
323
+ send(requestWithId, payload);
324
+ return requestWithId.requestId;
325
+ },
326
+ invoke(request, payload, timeoutMs = 5e3) {
327
+ const requestWithId = withRequestId(request);
328
+ const { requestId, pluginId, event } = requestWithId;
329
+ return new Promise((resolvePromise, rejectPromise) => {
330
+ const fail = async (reason) => {
331
+ console.error(`[invoke] ${reason}`);
332
+ const tail = await sharedLogTail();
333
+ rejectPromise(new Error(tail ? `${reason}; recent logs: ${tail}` : reason));
334
+ };
335
+ const timer = setTimeout(() => {
336
+ pending.delete(requestId);
337
+ fail(`invoke timeout for ${request.type} (${requestId})`);
338
+ }, timeoutMs);
339
+ if (pending.size >= MAX_CONCURRENT_INVOKES) {
340
+ const oldest = pending.keys().next().value;
341
+ if (oldest !== void 0) {
342
+ const dropped = pending.get(oldest);
343
+ if (dropped) {
344
+ clearTimeout(dropped.timer);
345
+ dropped.reject(/* @__PURE__ */ new Error(`invoke dropped: too many concurrent invokes (${requestId})`));
346
+ }
347
+ pending.delete(oldest);
348
+ }
349
+ }
350
+ pending.set(requestId, {
351
+ pluginId,
352
+ event,
353
+ resolve: resolvePromise,
354
+ reject: rejectPromise,
355
+ timer
356
+ });
357
+ send(requestWithId, payload);
358
+ });
359
+ },
360
+ handleIncoming(header) {
361
+ if (!header.requestId) {
362
+ try {
363
+ console.debug(`[rpc] incoming ${header.type} without a requestId; uncorrelatable, dropping`);
364
+ } catch {}
365
+ return;
366
+ }
367
+ const pendingCall = pending.get(header.requestId);
368
+ if (!pendingCall) {
369
+ try {
370
+ console.debug(`[rpc] ${header.type} for unknown/stale requestId (${header.requestId}); dropping`);
371
+ } catch {}
372
+ return;
373
+ }
374
+ if (header.type === "INVOKE_RESPONSE" && (header.pluginId !== pendingCall.pluginId || header.event !== pendingCall.event)) {
375
+ try {
376
+ console.debug(`[rpc] RESPONSE for ${header.requestId} mismatches pending ${pendingCall.pluginId}.${pendingCall.event} (got ${header.pluginId}.${header.event}); dropping`);
377
+ } catch {}
378
+ return;
379
+ }
380
+ clearTimeout(pendingCall.timer);
381
+ pending.delete(header.requestId);
382
+ pendingCall.resolve(header);
383
+ }
384
+ };
385
+ }
386
+ function withRequestId(request) {
387
+ return {
388
+ ...request,
389
+ requestId: createRequestId()
390
+ };
391
+ }
392
+ const INVOKE_TAIL_TIMEOUT_MS = 2e3;
393
+ /** A tail fetched within this window is reused for later timeouts instead of
394
+ * fetching again. Timeouts cluster (a stalled peer times out every pending
395
+ * call at once), so without this each one would add its own `/logs` request to
396
+ * the server that is already struggling. */
397
+ const INVOKE_TAIL_MIN_INTERVAL_MS = 5e3;
398
+ /**
399
+ * Wraps {@link serverLogTail} so concurrent timeouts share one request and
400
+ * timeouts shortly after a fetch reuse its result. State is per messenger, so
401
+ * nothing leaks between instances.
402
+ */
403
+ function createSharedLogTail() {
404
+ let inflight = null;
405
+ let last = null;
406
+ return () => {
407
+ if (inflight) return inflight;
408
+ if (last && Date.now() - last.at < INVOKE_TAIL_MIN_INTERVAL_MS) return Promise.resolve(last.tail);
409
+ inflight = serverLogTail().then((tail) => {
410
+ last = {
411
+ at: Date.now(),
412
+ tail
413
+ };
414
+ return tail;
415
+ }).finally(() => {
416
+ inflight = null;
417
+ });
418
+ return inflight;
419
+ };
420
+ }
421
+ /**
422
+ * Fetches a short tail of the loopback server's log (worklet + webview console)
423
+ * to enrich an invoke timeout with whatever the peer just saw. Best-effort:
424
+ * only attempted in a browser/webview context where `fetch` and a same-origin
425
+ * `/logs` route exist; never throws into the caller. Returns null when nothing
426
+ * can be learned.
427
+ */
428
+ async function serverLogTail() {
429
+ if (typeof fetch !== "function" || typeof window === "undefined") return null;
430
+ try {
431
+ const response = await Promise.race([fetch("/logs?tail=50&format=text"), new Promise((resolve) => setTimeout(() => resolve(null), INVOKE_TAIL_TIMEOUT_MS))]);
432
+ if (response === null || !response.ok) return null;
433
+ const lines = (await response.text()).split("\n").map((line) => line.trim()).filter((line) => line.length > 0).slice(-20);
434
+ return lines.length > 0 ? lines.join(" | ") : null;
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+ // core/messages/wire-codec.ts
440
+ /** Smallest frame that can legally carry a maximum-size header. Below this the
441
+ * decoder could never extract a header, so it is the effective floor for the
442
+ * frame cap. The frame prefix is `[version][type][headerLenHi][headerLenLo]
443
+ * [payloadLenHi][payloadLenMid][payloadLenLo]`, so the floor is
444
+ * 7 + MAX_HEADER_BYTES. */
445
+ const MIN_FRAME_BYTES = 65542;
446
+ function clampFrameBytes(value) {
447
+ return Math.min(Math.max(Number.isFinite(value) ? Math.floor(value) : MAX_FRAME_BYTES, MIN_FRAME_BYTES), MAX_FRAME_BYTES);
448
+ }
449
+ let sharedEncoder;
450
+ let sharedDecoder;
451
+ function defaultEncode(str) {
452
+ sharedEncoder ??= new TextEncoder();
453
+ return sharedEncoder.encode(str);
454
+ }
455
+ function defaultDecode(bytes) {
456
+ sharedDecoder ??= new TextDecoder(void 0, { fatal: true });
457
+ return sharedDecoder.decode(bytes);
458
+ }
459
+ var MessageProtocol = class {
460
+ encodeStr;
461
+ decodeStr;
462
+ allowUnknownTypes;
463
+ /** Resolved, clamped frame cap. Public so callers/tests can inspect the
464
+ * effective limit after clamping. */
465
+ maxFrameBytes;
466
+ constructor(options) {
467
+ this.encodeStr = options?.encode || defaultEncode;
468
+ this.decodeStr = options?.decode || defaultDecode;
469
+ this.allowUnknownTypes = options?.allowUnknownTypes ?? false;
470
+ this.maxFrameBytes = clampFrameBytes(options?.maxFrameBytes ?? 16777216);
471
+ }
472
+ encode(type, header, payload) {
473
+ let payloadBytes;
474
+ if (payload == null) payloadBytes = /* @__PURE__ */ new Uint8Array(0);
475
+ else if (typeof payload === "string") payloadBytes = this.encodeStr(payload);
476
+ else if (payload instanceof ArrayBuffer) payloadBytes = new Uint8Array(payload);
477
+ else payloadBytes = payload;
478
+ const pLen = payloadBytes.byteLength;
479
+ const headerObj = { ...header };
480
+ if (pLen > 0) headerObj.payloadLength = pLen;
481
+ const headerJson = JSON.stringify(headerObj);
482
+ const headerBytes = this.encodeStr(headerJson);
483
+ if (headerBytes.byteLength > 65535) throw new Error(`Header too large: ${headerBytes.byteLength} bytes, maximum is ${MAX_HEADER_BYTES}`);
484
+ const hLen = headerBytes.byteLength;
485
+ const totalLength = 7 + hLen + pLen;
486
+ if (totalLength > this.maxFrameBytes) throw new Error(`Frame too large: ${totalLength} bytes, maximum is ${this.maxFrameBytes}`);
487
+ let buffer;
488
+ if (typeof Buffer !== "undefined" && typeof Buffer.allocUnsafe === "function") buffer = Buffer.allocUnsafe(totalLength);
489
+ else buffer = new Uint8Array(totalLength);
490
+ buffer[0] = 1;
491
+ buffer[1] = type;
492
+ buffer[2] = hLen >> 8 & 255;
493
+ buffer[3] = hLen & 255;
494
+ buffer[4] = pLen >> 16 & 255;
495
+ buffer[5] = pLen >> 8 & 255;
496
+ buffer[6] = pLen & 255;
497
+ buffer.set(headerBytes, 7);
498
+ if (pLen > 0) buffer.set(payloadBytes, 7 + hLen);
499
+ return buffer;
500
+ }
501
+ decode(data) {
502
+ const view = data instanceof Uint8Array ? data : new Uint8Array(data);
503
+ const byteLength = view.byteLength;
504
+ if (byteLength < 7) throw new Error(`Message too short: ${byteLength} bytes`);
505
+ if (byteLength > this.maxFrameBytes) throw new Error(`Frame too large: ${byteLength} bytes, maximum is ${this.maxFrameBytes}`);
506
+ const version = view[0];
507
+ if (version !== 1) throw new Error(`Unsupported version: ${version}. Expected 1`);
508
+ const type = view[1];
509
+ if (!this.allowUnknownTypes && !isKnownMessageType(type)) throw new Error(`Unsupported message type: ${type}`);
510
+ const headerLen = view[2] << 8 | view[3];
511
+ const payloadLen = view[4] << 16 | view[5] << 8 | view[6];
512
+ if (headerLen > 65535) throw new Error(`Header too large: ${headerLen} bytes, maximum is ${MAX_HEADER_BYTES}`);
513
+ if (7 + headerLen + payloadLen > this.maxFrameBytes) throw new Error(`Frame too large: 7 + ${headerLen} + ${payloadLen} bytes, maximum is ${this.maxFrameBytes}`);
514
+ if (byteLength < 7 + headerLen + payloadLen) throw new Error(`Message too short for frame: ${byteLength} bytes, expected at least ${7 + headerLen + payloadLen}`);
515
+ let headerStr;
516
+ try {
517
+ headerStr = this.decodeStr(view.subarray(7, 7 + headerLen));
518
+ } catch {
519
+ try {
520
+ console.debug("[wire] header frame is not valid UTF-8");
521
+ } catch {}
522
+ throw new CoreError(ErrorCode.FRAME_INVALID, "FRAME_INVALID: header frame is not valid UTF-8");
523
+ }
524
+ return {
525
+ type,
526
+ header: parseAndValidateHeader(headerStr),
527
+ payload: view.subarray(7 + headerLen, 7 + headerLen + payloadLen)
528
+ };
529
+ }
530
+ };
531
+ const KNOWN_MESSAGE_TYPES = new Set(Object.values(MessageType));
532
+ function isKnownMessageType(type) {
533
+ return KNOWN_MESSAGE_TYPES.has(type);
534
+ }
535
+ function parseAndValidateHeader(headerJson) {
536
+ const parsed = JSON.parse(headerJson);
537
+ if (!parsed || typeof parsed !== "object") throw new Error("Invalid header: expected object");
538
+ const header = parsed;
539
+ switch (header.type) {
540
+ case "DISPATCH":
541
+ if (isValidPluginHeaderFields(header)) return mergeHeader(header, {
542
+ type: "DISPATCH",
543
+ pluginId: header.pluginId,
544
+ event: header.event,
545
+ requestId: asOptionalString(header.requestId),
546
+ args: asOptionalRecord(header.args)
547
+ });
548
+ break;
549
+ case "INVOKE_REQUEST":
550
+ if (isValidPluginHeaderFields(header)) return mergeHeader(header, {
551
+ type: "INVOKE_REQUEST",
552
+ pluginId: header.pluginId,
553
+ event: header.event,
554
+ requestId: asOptionalString(header.requestId),
555
+ args: asOptionalRecord(header.args)
556
+ });
557
+ break;
558
+ case "INVOKE_RESPONSE":
559
+ if (isValidPluginHeaderFields(header)) return mergeHeader(header, {
560
+ type: "INVOKE_RESPONSE",
561
+ pluginId: header.pluginId,
562
+ event: header.event,
563
+ requestId: asOptionalString(header.requestId),
564
+ result: sanitizeValue(header.result, 0),
565
+ error: parseCoreErrorWire(header.error)
566
+ });
567
+ break;
568
+ case "HOST_CAPABILITIES_QUERY":
569
+ if (typeof header.requestId === "string") return mergeHeader(header, {
570
+ type: "HOST_CAPABILITIES_QUERY",
571
+ requestId: header.requestId
572
+ });
573
+ break;
574
+ case "HOST_CAPABILITIES_RESPONSE":
575
+ if (typeof header.requestId === "string") return mergeHeader(header, {
576
+ type: "HOST_CAPABILITIES_RESPONSE",
577
+ requestId: header.requestId,
578
+ capabilities: parseCapabilityDescriptors(header.capabilities)
579
+ });
580
+ break;
581
+ case "HOST_INVOKE_REQUEST":
582
+ if (typeof header.requestId === "string" && typeof header.pluginId === "string" && typeof header.event === "string") return mergeHeader(header, {
583
+ type: "HOST_INVOKE_REQUEST",
584
+ requestId: header.requestId,
585
+ pluginId: header.pluginId,
586
+ event: header.event,
587
+ args: asOptionalRecord(header.args)
588
+ });
589
+ break;
590
+ case "HOST_INVOKE_RESPONSE": if (typeof header.requestId === "string" && typeof header.pluginId === "string" && typeof header.event === "string") return mergeHeader(header, {
591
+ type: "HOST_INVOKE_RESPONSE",
592
+ requestId: header.requestId,
593
+ pluginId: header.pluginId,
594
+ event: header.event,
595
+ result: sanitizeValue(header.result, 0),
596
+ error: parseCoreErrorWire(header.error)
597
+ });
598
+ }
599
+ throw new Error(`Unsupported header type: ${String(header.type)}`);
600
+ }
601
+ /** Only allowlisted, schema-known fields are carried forward. Unknown wire
602
+ * fields are dropped so attacker-controlled keys (e.g. `__proto__` or an
603
+ * arbitrary `args` key) never ride into plugin args/result objects. */
604
+ function mergeHeader(parsed, known) {
605
+ return known;
606
+ }
607
+ function isValidPluginHeaderFields(header) {
608
+ return typeof header.pluginId === "string" && typeof header.event === "string";
609
+ }
610
+ function asOptionalString(value) {
611
+ return typeof value === "string" ? value : void 0;
612
+ }
613
+ function asOptionalRecord(value) {
614
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
615
+ return sanitizeValue(value, 0);
616
+ }
617
+ /** Recursively copy an attacker-controlled value onto a `null`-prototype object,
618
+ * dropping prototype-polluting keys (`__proto__`/`constructor`/`prototype`) and
619
+ * rejecting excessive nesting or breadth (depth/array bombs carried inside the
620
+ * 64KB header budget). Throws {@link ErrorCode.FRAME_INVALID} on over-deep
621
+ * structures. */
622
+ const MAX_VALUE_DEPTH = 16;
623
+ /** Ceiling on array elements / object keys at any one level of a sanitized
624
+ * value. Together with {@link MAX_VALUE_DEPTH} and the 64KB header budget this
625
+ * bounds total allocation: a wide-but-shallow structure can still be
626
+ * serialized into a legal header, so it must be rejected, not materialized. */
627
+ const MAX_VALUE_CARDINALITY = 16384;
628
+ function sanitizeValue(value, depth) {
629
+ if (depth > MAX_VALUE_DEPTH) throw new CoreError(ErrorCode.FRAME_INVALID, `FRAME_INVALID: value nested deeper than ${MAX_VALUE_DEPTH} levels`);
630
+ if (value === null || typeof value !== "object") return value;
631
+ if (Array.isArray(value)) {
632
+ if (value.length > MAX_VALUE_CARDINALITY) throw new CoreError(ErrorCode.FRAME_INVALID, `FRAME_INVALID: array longer than ${MAX_VALUE_CARDINALITY} elements`);
633
+ return value.map((item) => sanitizeValue(item, depth + 1));
634
+ }
635
+ const keys = Object.keys(value);
636
+ if (keys.length > MAX_VALUE_CARDINALITY) throw new CoreError(ErrorCode.FRAME_INVALID, `FRAME_INVALID: object with more than ${MAX_VALUE_CARDINALITY} keys`);
637
+ const out = Object.create(null);
638
+ for (const key of keys) {
639
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
640
+ out[key] = sanitizeValue(value[key], depth + 1);
641
+ }
642
+ return out;
643
+ }
644
+ function parseCoreErrorWire(value) {
645
+ if (!value || typeof value !== "object") return void 0;
646
+ const candidate = value;
647
+ if (typeof candidate.code !== "string" || typeof candidate.message !== "string") return void 0;
648
+ return {
649
+ code: candidate.code,
650
+ message: candidate.message
651
+ };
652
+ }
653
+ const RUNTIME_TARGETS = /* @__PURE__ */ new Set([
654
+ "web",
655
+ "android",
656
+ "ios",
657
+ "bare"
658
+ ]);
659
+ function parseRuntimeTarget(value) {
660
+ if (typeof value !== "string") return null;
661
+ return RUNTIME_TARGETS.has(value) ? value : null;
662
+ }
663
+ function parseCapabilityDescriptors(value) {
664
+ if (!Array.isArray(value)) return [];
665
+ const out = [];
666
+ for (const row of value) {
667
+ if (!row || typeof row !== "object") continue;
668
+ const r = row;
669
+ if (typeof r.pluginId !== "string") continue;
670
+ const events = Array.isArray(r.events) ? r.events.filter((e) => typeof e === "string") : [];
671
+ const caps = Array.isArray(r.capabilities) ? r.capabilities.filter((c) => typeof c === "string") : [];
672
+ const runtimes = [];
673
+ if (Array.isArray(r.runtimes)) for (const rt of r.runtimes) {
674
+ const parsed = parseRuntimeTarget(rt);
675
+ if (parsed) runtimes.push(parsed);
676
+ }
677
+ out.push({
678
+ pluginId: r.pluginId,
679
+ capabilities: caps,
680
+ events,
681
+ runtimes
682
+ });
683
+ }
684
+ return out;
685
+ }
686
+ // core/messages/define-plugin.ts
687
+ /**
688
+ * Builds a {@link PluginManifest} from an event spec table. The `events` list
689
+ * is derived from the specs and handlers are dispatched by event name — no
690
+ * hand-written if-chains. Handlers receive typed args. Plugins with no local
691
+ * handlers (host-delegated, e.g. `core.permissions`) pass no `invoke`/`dispatch`.
692
+ */
693
+ function definePlugin(id, specs, options = {}) {
694
+ const invokeByEvent = /* @__PURE__ */ new Map();
695
+ const dispatchByEvent = /* @__PURE__ */ new Map();
696
+ const events = [];
697
+ for (const key of Object.keys(specs)) {
698
+ const spec = specs[key];
699
+ if (spec.pluginId !== id) throw new Error(`event spec ${spec.name} belongs to ${spec.pluginId}, not ${id}`);
700
+ events.push(spec.name);
701
+ const invoker = options.invoke?.[key];
702
+ if (invoker) invokeByEvent.set(spec.name, invoker);
703
+ const dispatcher = options.dispatch?.[key];
704
+ if (dispatcher) dispatchByEvent.set(spec.name, dispatcher);
705
+ }
706
+ const adapter = {};
707
+ if (invokeByEvent.size > 0) adapter.invoke = (event, args, context) => {
708
+ const handler = invokeByEvent.get(event);
709
+ if (!handler) throw new Error(`Unhandled invoke event ${event}`);
710
+ return handler(args, context);
711
+ };
712
+ if (dispatchByEvent.size > 0) adapter.dispatch = (event, args, context) => {
713
+ const handler = dispatchByEvent.get(event);
714
+ if (!handler) throw new Error(`Unhandled dispatch event ${event}`);
715
+ return handler(args, context);
716
+ };
717
+ const runtimes = {};
718
+ if (invokeByEvent.size > 0 || dispatchByEvent.size > 0) runtimes.bare = adapter;
719
+ return {
720
+ id,
721
+ capabilities: options.capabilities,
722
+ events,
723
+ runtimes
724
+ };
725
+ }
726
+ /** Typed builder: an invoke envelope for a spec's event. */
727
+ function invokeEvent(spec, args, payload, timeoutMs) {
728
+ return {
729
+ kind: "invoke",
730
+ pluginId: spec.pluginId,
731
+ event: spec.name,
732
+ args,
733
+ payload,
734
+ timeoutMs
735
+ };
736
+ }
737
+ /** Typed builder: a dispatch envelope for a spec's event. */
738
+ function dispatchEvent(spec, args, payload) {
739
+ return {
740
+ kind: "dispatch",
741
+ pluginId: spec.pluginId,
742
+ event: spec.name,
743
+ args,
744
+ payload
745
+ };
746
+ }
747
+ // core/messages/create-bare-runtime-context.ts
748
+ function createBareRuntimeContext(plugins = [], routerOptions) {
749
+ const runtime = "bare";
750
+ const protocol = new MessageProtocol({
751
+ encode: (str) => new TextEncoder$1().encode(str),
752
+ decode: (bytes) => new TextDecoder$1().decode(bytes)
753
+ });
754
+ const pluginRegistry = createPluginRegistry();
755
+ for (const plugin of plugins) pluginRegistry.register(plugin);
756
+ return {
757
+ protocol,
758
+ pluginRegistry,
759
+ pluginRouter: createPluginRouter(pluginRegistry, runtime, routerOptions)
760
+ };
761
+ }
762
+ // core/logs/capture-web.ts
763
+ function safeStringify(value) {
764
+ if (typeof value === "string") return value;
765
+ if (value instanceof Error) return value.stack ?? value.message;
766
+ try {
767
+ return JSON.stringify(value);
768
+ } catch {
769
+ return String(value);
770
+ }
771
+ }
772
+ function formatArgs(args) {
773
+ if (args.length === 0) return "";
774
+ return args.map((a) => safeStringify(a)).join(" ");
775
+ }
776
+ function isFetchAvailable() {
777
+ return typeof fetch === "function";
778
+ }
779
+ /**
780
+ * Web-side console capture: wraps the console methods, buffers formatted
781
+ * entries, and flushes them as a batch to the loopback `POST /logs` ingest
782
+ * route with `source: 'web'` (same-origin fetch). Every step is try/catch-
783
+ * wrapped so the seam can never break the app. Skip installing in mock mode,
784
+ * where there is no loopback backend to ingest into.
785
+ */
786
+ function installWebConsoleCapture(options = {}) {
787
+ const { endpoint = "/logs", flushThreshold = 25, flushIntervalMs = 1e3, ingest } = options;
788
+ const original = {
789
+ debug: console.debug,
790
+ log: console.log,
791
+ info: console.info,
792
+ warn: console.warn,
793
+ error: console.error
794
+ };
795
+ const pending = [];
796
+ function flush() {
797
+ if (pending.length === 0) return;
798
+ const batch = pending.splice(0, pending.length);
799
+ let flushed;
800
+ try {
801
+ flushed = ingest ? ingest({
802
+ source: "web",
803
+ entries: batch
804
+ }) : isFetchAvailable() ? fetch(endpoint, {
805
+ method: "POST",
806
+ headers: { "Content-Type": "application/json" },
807
+ body: JSON.stringify({
808
+ source: "web",
809
+ entries: batch
810
+ })
811
+ }).catch(() => void 0) : void 0;
812
+ } catch {
813
+ return;
814
+ }
815
+ if (flushed) Promise.resolve(flushed).catch(() => void 0);
816
+ }
817
+ const wrap = (method, level) => {
818
+ return (...args) => {
819
+ try {
820
+ pending.push({
821
+ level,
822
+ message: formatArgs(args)
823
+ });
824
+ if (pending.length >= flushThreshold) flush();
825
+ } catch {}
826
+ try {
827
+ original[method].apply(console, args);
828
+ } catch {}
829
+ };
830
+ };
831
+ console.debug = wrap("debug", "debug");
832
+ console.log = wrap("log", "info");
833
+ console.info = wrap("info", "info");
834
+ console.warn = wrap("warn", "warn");
835
+ console.error = wrap("error", "error");
836
+ const timer = flushIntervalMs > 0 ? setInterval(flush, flushIntervalMs) : void 0;
837
+ return {
838
+ flush,
839
+ restore() {
840
+ if (timer !== void 0) clearInterval(timer);
841
+ console.debug = original.debug;
842
+ console.log = original.log;
843
+ console.info = original.info;
844
+ console.warn = original.warn;
845
+ console.error = original.error;
846
+ }
847
+ };
848
+ }
849
+ export { MAX_FRAME_BYTES as _, invokeEvent as a, VERSION as b, createRequestId as c, createPluginRouter as d, coerceErrorCode as f, ErrorCode as g, CoreError as h, dispatchEvent as i, createPluginBus as l, ok as m, createBareRuntimeContext as n, MessageProtocol as o, err as p, definePlugin as r, createProtocolMessenger as s, installWebConsoleCapture as t, createPluginRegistry as u, MAX_HEADER_BYTES as v, MessageType as y };