@ekrooh/bare 0.4.0 → 0.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 +139 -195
- package/dist/{index-DjulYFJL.d.mts → index-BkwUzwVt.d.mts} +94 -20
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -512
- package/dist/messages-CevD8qkV.mjs +851 -0
- package/dist/{plugins-1KweEBct.mjs → plugins-Ddp2AgOe.mjs} +51 -20
- package/dist/plugins-discovery-events.d.mts +5 -8
- package/dist/plugins-discovery-events.mjs +2 -3
- package/dist/plugins-health-events.d.mts +4 -7
- package/dist/plugins-health-events.mjs +2 -3
- package/dist/plugins-logs-events.d.mts +34 -0
- package/dist/plugins-logs-events.mjs +25 -0
- package/dist/plugins-media-events.d.mts +6 -11
- package/dist/plugins-media-events.mjs +2 -3
- package/dist/plugins-permissions-events.d.mts +7 -10
- package/dist/plugins-permissions-events.mjs +2 -3
- package/dist/plugins.d.mts +9 -11
- package/dist/plugins.mjs +1 -2
- package/dist/runtime.d.mts +75 -46
- package/dist/runtime.mjs +1314 -324
- package/dist/static-file-server-COxd-PFR.d.mts +120 -0
- package/dist/store-DTg1jN5E.mjs +49 -0
- package/dist/transports.d.mts +15 -14
- package/dist/transports.mjs +169 -82
- package/package.json +12 -8
- package/dist/static-file-server-DY9EKEVf.d.mts +0 -88
|
@@ -0,0 +1,851 @@
|
|
|
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 RPC messenger (`rpc-messenger.ts`) and the host IPC bridge
|
|
300
|
+
* (`host-ipc.ts`) so the unguessable, collision-resistant guarantee can never
|
|
301
|
+
* 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 textEncoder = new TextEncoder$1();
|
|
751
|
+
const textDecoder = new TextDecoder$1();
|
|
752
|
+
const protocol = new MessageProtocol({
|
|
753
|
+
encode: (str) => textEncoder.encode(str),
|
|
754
|
+
decode: (bytes) => textDecoder.decode(bytes)
|
|
755
|
+
});
|
|
756
|
+
const pluginRegistry = createPluginRegistry();
|
|
757
|
+
for (const plugin of plugins) pluginRegistry.register(plugin);
|
|
758
|
+
return {
|
|
759
|
+
protocol,
|
|
760
|
+
pluginRegistry,
|
|
761
|
+
pluginRouter: createPluginRouter(pluginRegistry, runtime, routerOptions)
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
// core/logs/capture-web.ts
|
|
765
|
+
function safeStringify(value) {
|
|
766
|
+
if (typeof value === "string") return value;
|
|
767
|
+
if (value instanceof Error) return value.stack ?? value.message;
|
|
768
|
+
try {
|
|
769
|
+
return JSON.stringify(value);
|
|
770
|
+
} catch {
|
|
771
|
+
return String(value);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function formatArgs(args) {
|
|
775
|
+
if (args.length === 0) return "";
|
|
776
|
+
return args.map((a) => safeStringify(a)).join(" ");
|
|
777
|
+
}
|
|
778
|
+
function isFetchAvailable() {
|
|
779
|
+
return typeof fetch === "function";
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Web-side console capture: wraps the console methods, buffers formatted
|
|
783
|
+
* entries, and flushes them as a batch to the loopback `POST /logs` ingest
|
|
784
|
+
* route with `source: 'web'` (same-origin fetch). Every step is try/catch-
|
|
785
|
+
* wrapped so the seam can never break the app. Skip installing in mock mode,
|
|
786
|
+
* where there is no loopback backend to ingest into.
|
|
787
|
+
*/
|
|
788
|
+
function installWebConsoleCapture(options = {}) {
|
|
789
|
+
const { endpoint = "/logs", flushThreshold = 25, flushIntervalMs = 1e3, ingest } = options;
|
|
790
|
+
const original = {
|
|
791
|
+
debug: console.debug,
|
|
792
|
+
log: console.log,
|
|
793
|
+
info: console.info,
|
|
794
|
+
warn: console.warn,
|
|
795
|
+
error: console.error
|
|
796
|
+
};
|
|
797
|
+
const pending = [];
|
|
798
|
+
function flush() {
|
|
799
|
+
if (pending.length === 0) return;
|
|
800
|
+
const batch = pending.splice(0, pending.length);
|
|
801
|
+
let flushed;
|
|
802
|
+
try {
|
|
803
|
+
flushed = ingest ? ingest({
|
|
804
|
+
source: "web",
|
|
805
|
+
entries: batch
|
|
806
|
+
}) : isFetchAvailable() ? fetch(endpoint, {
|
|
807
|
+
method: "POST",
|
|
808
|
+
headers: { "Content-Type": "application/json" },
|
|
809
|
+
body: JSON.stringify({
|
|
810
|
+
source: "web",
|
|
811
|
+
entries: batch
|
|
812
|
+
})
|
|
813
|
+
}).catch(() => void 0) : void 0;
|
|
814
|
+
} catch {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (flushed) Promise.resolve(flushed).catch(() => void 0);
|
|
818
|
+
}
|
|
819
|
+
const wrap = (method, level) => {
|
|
820
|
+
return (...args) => {
|
|
821
|
+
try {
|
|
822
|
+
pending.push({
|
|
823
|
+
level,
|
|
824
|
+
message: formatArgs(args)
|
|
825
|
+
});
|
|
826
|
+
if (pending.length >= flushThreshold) flush();
|
|
827
|
+
} catch {}
|
|
828
|
+
try {
|
|
829
|
+
original[method].apply(console, args);
|
|
830
|
+
} catch {}
|
|
831
|
+
};
|
|
832
|
+
};
|
|
833
|
+
console.debug = wrap("debug", "debug");
|
|
834
|
+
console.log = wrap("log", "info");
|
|
835
|
+
console.info = wrap("info", "info");
|
|
836
|
+
console.warn = wrap("warn", "warn");
|
|
837
|
+
console.error = wrap("error", "error");
|
|
838
|
+
const timer = flushIntervalMs > 0 ? setInterval(flush, flushIntervalMs) : void 0;
|
|
839
|
+
return {
|
|
840
|
+
flush,
|
|
841
|
+
restore() {
|
|
842
|
+
if (timer !== void 0) clearInterval(timer);
|
|
843
|
+
console.debug = original.debug;
|
|
844
|
+
console.log = original.log;
|
|
845
|
+
console.info = original.info;
|
|
846
|
+
console.warn = original.warn;
|
|
847
|
+
console.error = original.error;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
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 };
|