@johnhenry/andbox 0.1.3 → 0.3.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 +183 -18
- package/package.json +9 -3
- package/src/bridge-client.mjs +476 -0
- package/src/bridge-host.mjs +870 -0
- package/src/bridges/chrome-ai.d.ts +53 -0
- package/src/bridges/chrome-ai.mjs +424 -0
- package/src/iframe-host.mjs +5 -4
- package/src/index.d.ts +187 -13
- package/src/index.mjs +1 -0
- package/src/network-policy.mjs +206 -21
- package/src/sandbox.mjs +72 -5
- package/src/worker-source.mjs +27 -6
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges -- the host side of `createSandbox({ bridges })` (andbox#46).
|
|
3
|
+
*
|
|
4
|
+
* A bridge shares a host API surface with sandboxed code without sharing a
|
|
5
|
+
* single host object: the sandbox gets a global (built by
|
|
6
|
+
* `src/bridge-client.mjs` from a JSON manifest) whose methods send
|
|
7
|
+
* structured-clone messages to the functions in the bridge definition. Host
|
|
8
|
+
* objects stay on the host and are referenced by opaque handle ids; streams
|
|
9
|
+
* are pulled chunk by chunk; sandbox functions in arguments become async
|
|
10
|
+
* host-side proxies that call back into the sandbox.
|
|
11
|
+
*
|
|
12
|
+
* `normalizeBridges()` validates the definitions (before a Worker starts);
|
|
13
|
+
* `createBridgeHost()` is created once per sandbox (state and budgets live as
|
|
14
|
+
* long as the sandbox); `host.openSession(post)` once per Worker/frame: a
|
|
15
|
+
* session owns that runtime's handles, streams and callbacks and destroys all
|
|
16
|
+
* of them when `close()` is called (dispose, timeout, abort, crash).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const MARK = '__andbox_bridge__';
|
|
20
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
21
|
+
const MAX_DEPTH = 64;
|
|
22
|
+
const MAX_ID_LENGTH = 128;
|
|
23
|
+
|
|
24
|
+
/** Default per-sandbox limits of one bridge (0 = unlimited). */
|
|
25
|
+
export const DEFAULT_BRIDGE_LIMITS = Object.freeze({
|
|
26
|
+
/** Live handles at a time. */
|
|
27
|
+
maxHandles: 32,
|
|
28
|
+
/** Open streams at a time. */
|
|
29
|
+
maxStreams: 8,
|
|
30
|
+
/** Approximate size of one result or stream chunk, in bytes. */
|
|
31
|
+
maxResultBytes: 0,
|
|
32
|
+
/** Wall-clock time for one call; for a stream method, until the stream ends. */
|
|
33
|
+
timeoutMs: 0,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Names a bridge or alias global may not take. */
|
|
37
|
+
const RESERVED_GLOBALS = new Set([
|
|
38
|
+
'host', 'sandboxImport', 'console', 'globalThis', 'self', 'window', 'document', 'fetch', 'postMessage',
|
|
39
|
+
'undefined', 'NaN', 'Infinity', 'eval', 'arguments', 'Function', 'Object', 'Array', 'Promise',
|
|
40
|
+
'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource', 'Worker', 'SharedWorker',
|
|
41
|
+
'importScripts', 'indexedDB', 'caches', 'BroadcastChannel', 'crypto', 'location', 'navigator',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Names a method or namespace may not take (would break `await`, prototypes or the handle API). */
|
|
45
|
+
const RESERVED_MEMBERS = new Set(['then', 'constructor', 'prototype', '__proto__', 'destroy', 'toString', 'valueOf']);
|
|
46
|
+
|
|
47
|
+
/** `requiresUserActivation` kinds: transient (a gesture in the last few seconds) or sticky (any gesture since load). */
|
|
48
|
+
const ACTIVATION_KINDS = ['transient', 'sticky'];
|
|
49
|
+
|
|
50
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
51
|
+
|
|
52
|
+
function makeDomError(message, name) {
|
|
53
|
+
if (typeof DOMException === 'function') return new DOMException(message, name);
|
|
54
|
+
const e = new Error(message);
|
|
55
|
+
e.name = name;
|
|
56
|
+
return e;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What crosses to the sandbox for an error: name, message, a few known fields. Never the stack. */
|
|
60
|
+
function serializeError(e) {
|
|
61
|
+
const out = {
|
|
62
|
+
name: e && typeof e.name === 'string' ? e.name : 'Error',
|
|
63
|
+
message: e && e.message !== undefined ? String(e.message) : String(e),
|
|
64
|
+
};
|
|
65
|
+
if (e && typeof e.code === 'string') out.code = e.code;
|
|
66
|
+
for (const k of ['requested', 'quota']) if (e && typeof e[k] === 'number') out[k] = e[k];
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isPlainObject(v) {
|
|
71
|
+
if (v === null || typeof v !== 'object') return false;
|
|
72
|
+
const proto = Object.getPrototypeOf(v);
|
|
73
|
+
return proto === Object.prototype || proto === null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validId(v) {
|
|
77
|
+
return typeof v === 'string' && v.length > 0 && v.length <= MAX_ID_LENGTH;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkLimitValue(where, key, value) {
|
|
81
|
+
if (!(typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
|
|
82
|
+
throw new TypeError(`${where}.${key} must be a non-negative number (0 = unlimited); got ${String(value)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Definitions ──
|
|
87
|
+
|
|
88
|
+
function normalizeMethod(where, spec) {
|
|
89
|
+
if (typeof spec === 'function') {
|
|
90
|
+
return { call: spec, stream: false, handle: false, requiresUserActivation: false };
|
|
91
|
+
}
|
|
92
|
+
if (spec === null || typeof spec !== 'object' || typeof spec.call !== 'function') {
|
|
93
|
+
throw new TypeError(`${where} must be a function or { call, stream?, handle?, requiresUserActivation? }`);
|
|
94
|
+
}
|
|
95
|
+
for (const k of Object.keys(spec)) {
|
|
96
|
+
if (!['call', 'stream', 'handle', 'requiresUserActivation'].includes(k)) {
|
|
97
|
+
throw new TypeError(`${where}.${k} is not a known method option (expected call, stream, handle, requiresUserActivation)`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const ua = spec.requiresUserActivation ?? false;
|
|
101
|
+
if (typeof ua !== 'boolean' && typeof ua !== 'function' && !ACTIVATION_KINDS.includes(ua)) {
|
|
102
|
+
throw new TypeError(
|
|
103
|
+
`${where}.requiresUserActivation must be true, 'transient', 'sticky', or a function (ctx, ...args) => boolean | 'transient' | 'sticky'`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return { call: spec.call, stream: spec.stream === true, handle: spec.handle === true, requiresUserActivation: ua };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkMember(where, name) {
|
|
110
|
+
if (!IDENTIFIER.test(name) || RESERVED_MEMBERS.has(name)) {
|
|
111
|
+
throw new TypeError(`${where}: '${name}' is not allowed as a bridge member name`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Walk the `api` tree into a flat path -> method map. */
|
|
116
|
+
function flattenApi(where, tree, prefix, out) {
|
|
117
|
+
if (tree === null || typeof tree !== 'object' || Array.isArray(tree)) {
|
|
118
|
+
throw new TypeError(`${where} must be an object of methods and namespaces`);
|
|
119
|
+
}
|
|
120
|
+
for (const key of Object.keys(tree)) {
|
|
121
|
+
checkMember(where, key);
|
|
122
|
+
const value = tree[key];
|
|
123
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
124
|
+
if (typeof value === 'function' || (value && typeof value === 'object' && typeof value.call === 'function')) {
|
|
125
|
+
out.set(path, normalizeMethod(`${where}.${key}`, value));
|
|
126
|
+
} else {
|
|
127
|
+
flattenApi(`${where}.${key}`, value, path, out);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate one bridge definition. See `defineBridge()`.
|
|
135
|
+
* @returns {object} the normalized definition
|
|
136
|
+
*/
|
|
137
|
+
function normalizeDefinition(name, def) {
|
|
138
|
+
const where = `bridges.${name}`;
|
|
139
|
+
if (def === null || typeof def !== 'object') throw new TypeError(`${where} must be a bridge definition object`);
|
|
140
|
+
for (const k of Object.keys(def)) {
|
|
141
|
+
if (!['api', 'handles', 'limits', 'onRequest', 'createState', 'stats', 'client', 'clientOptions', 'globals'].includes(k)) {
|
|
142
|
+
throw new TypeError(`${where}.${k} is not a known bridge option`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const methods = flattenApi(`${where}.api`, def.api ?? {}, '', new Map());
|
|
146
|
+
|
|
147
|
+
const handles = new Map();
|
|
148
|
+
const handleDefs = def.handles ?? {};
|
|
149
|
+
if (handleDefs === null || typeof handleDefs !== 'object') throw new TypeError(`${where}.handles must be an object`);
|
|
150
|
+
for (const type of Object.keys(handleDefs)) {
|
|
151
|
+
checkMember(`${where}.handles`, type);
|
|
152
|
+
if (methods.has(type) || [...methods.keys()].some((p) => p.startsWith(`${type}.`))) {
|
|
153
|
+
throw new TypeError(`${where}.handles.${type}: a handle type cannot share its name with an api member`);
|
|
154
|
+
}
|
|
155
|
+
const h = handleDefs[type];
|
|
156
|
+
if (h === null || typeof h !== 'object') throw new TypeError(`${where}.handles.${type} must be an object`);
|
|
157
|
+
const hm = new Map();
|
|
158
|
+
for (const m of Object.keys(h.methods ?? {})) {
|
|
159
|
+
checkMember(`${where}.handles.${type}.methods`, m);
|
|
160
|
+
hm.set(m, normalizeMethod(`${where}.handles.${type}.methods.${m}`, h.methods[m]));
|
|
161
|
+
}
|
|
162
|
+
const props = h.props ?? [];
|
|
163
|
+
if (!Array.isArray(props) || !props.every((p) => typeof p === 'string' && IDENTIFIER.test(p) && !RESERVED_MEMBERS.has(p) && !hm.has(p))) {
|
|
164
|
+
throw new TypeError(`${where}.handles.${type}.props must be an array of property names (not method names)`);
|
|
165
|
+
}
|
|
166
|
+
if (h.destroy !== undefined && typeof h.destroy !== 'function') {
|
|
167
|
+
throw new TypeError(`${where}.handles.${type}.destroy must be a function (target) => void`);
|
|
168
|
+
}
|
|
169
|
+
handles.set(type, { methods: hm, props: [...props], destroy: h.destroy ?? null });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const limits = { ...DEFAULT_BRIDGE_LIMITS };
|
|
173
|
+
if (def.limits !== undefined) {
|
|
174
|
+
if (def.limits === null || typeof def.limits !== 'object') throw new TypeError(`${where}.limits must be an object`);
|
|
175
|
+
for (const k of Object.keys(def.limits)) {
|
|
176
|
+
if (!hasOwn(DEFAULT_BRIDGE_LIMITS, k)) throw new TypeError(`${where}.limits.${k} is not a known limit`);
|
|
177
|
+
checkLimitValue(`${where}.limits`, k, def.limits[k]);
|
|
178
|
+
limits[k] = def.limits[k];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const k of ['onRequest', 'createState', 'stats']) {
|
|
182
|
+
if (def[k] !== undefined && typeof def[k] !== 'function') throw new TypeError(`${where}.${k} must be a function`);
|
|
183
|
+
}
|
|
184
|
+
let client = null;
|
|
185
|
+
if (def.client !== undefined) {
|
|
186
|
+
if (typeof def.client === 'function') client = def.client.toString();
|
|
187
|
+
else if (typeof def.client === 'string') client = def.client;
|
|
188
|
+
else throw new TypeError(`${where}.client must be a self-contained function (api, options) => api`);
|
|
189
|
+
}
|
|
190
|
+
const globals = {};
|
|
191
|
+
for (const [alias, path] of Object.entries(def.globals ?? {})) {
|
|
192
|
+
if (!IDENTIFIER.test(alias) || RESERVED_GLOBALS.has(alias)) throw new TypeError(`${where}.globals: '${alias}' is not allowed as a global name`);
|
|
193
|
+
if (typeof path !== 'string' || !path.split('.').every((p) => IDENTIFIER.test(p))) {
|
|
194
|
+
throw new TypeError(`${where}.globals.${alias} must be a dotted path into the bridge's api`);
|
|
195
|
+
}
|
|
196
|
+
globals[alias] = path;
|
|
197
|
+
}
|
|
198
|
+
if (def.clientOptions !== undefined) {
|
|
199
|
+
try {
|
|
200
|
+
JSON.stringify(def.clientOptions);
|
|
201
|
+
} catch {
|
|
202
|
+
throw new TypeError(`${where}.clientOptions must be JSON-serializable`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
name,
|
|
208
|
+
methods,
|
|
209
|
+
handles,
|
|
210
|
+
limits,
|
|
211
|
+
onRequest: def.onRequest ?? null,
|
|
212
|
+
createState: def.createState ?? null,
|
|
213
|
+
stats: def.stats ?? null,
|
|
214
|
+
client,
|
|
215
|
+
clientOptions: def.clientOptions,
|
|
216
|
+
globals,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Validate `createSandbox({ bridges })`. Throws a TypeError describing the
|
|
222
|
+
* first problem; returns a Map of normalized definitions.
|
|
223
|
+
*/
|
|
224
|
+
export function normalizeBridges(bridges) {
|
|
225
|
+
if (bridges === null || typeof bridges !== 'object' || Array.isArray(bridges)) {
|
|
226
|
+
throw new TypeError('bridges must be an object: { name: bridgeDefinition }');
|
|
227
|
+
}
|
|
228
|
+
const out = new Map();
|
|
229
|
+
const globalNames = new Set();
|
|
230
|
+
for (const name of Object.keys(bridges)) {
|
|
231
|
+
if (!IDENTIFIER.test(name) || RESERVED_GLOBALS.has(name)) {
|
|
232
|
+
throw new TypeError(`bridges: '${name}' is not allowed as a bridge name (it becomes a global in the sandbox)`);
|
|
233
|
+
}
|
|
234
|
+
const def = normalizeDefinition(name, bridges[name]);
|
|
235
|
+
for (const g of [name, ...Object.keys(def.globals)]) {
|
|
236
|
+
if (globalNames.has(g)) throw new TypeError(`bridges: more than one bridge defines the global '${g}'`);
|
|
237
|
+
globalNames.add(g);
|
|
238
|
+
}
|
|
239
|
+
out.set(name, def);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check a bridge definition and return it unchanged. Optional: `createSandbox`
|
|
246
|
+
* validates `bridges` itself; this lets a bridge author fail early (and gives
|
|
247
|
+
* TypeScript the definition type).
|
|
248
|
+
*/
|
|
249
|
+
export function defineBridge(definition) {
|
|
250
|
+
normalizeDefinition('definition', definition);
|
|
251
|
+
return definition;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** What the sandbox needs to build the global: JSON only. */
|
|
255
|
+
function manifestOf(def) {
|
|
256
|
+
const api = {};
|
|
257
|
+
for (const [path, spec] of def.methods) api[path] = spec.stream ? 'stream' : 'call';
|
|
258
|
+
const handles = {};
|
|
259
|
+
for (const [type, h] of def.handles) {
|
|
260
|
+
const methods = {};
|
|
261
|
+
for (const [m, spec] of h.methods) methods[m] = spec.stream ? 'stream' : 'call';
|
|
262
|
+
handles[type] = { methods, props: h.props };
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
api,
|
|
266
|
+
handles,
|
|
267
|
+
globals: def.globals,
|
|
268
|
+
...(def.client ? { client: def.client } : {}),
|
|
269
|
+
...(def.clientOptions !== undefined ? { clientOptions: def.clientOptions } : {}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Gate (capability) name of a bridge method: `ai.languageModel.create`, `ai.LanguageModel.prompt`. */
|
|
274
|
+
function gateName(bridge, method) {
|
|
275
|
+
return `${bridge}.${method}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A rough byte count for `limits.maxResultBytes` (strings as UTF-8, binary by byteLength). */
|
|
279
|
+
export function estimateBytes(value, depth = 0) {
|
|
280
|
+
if (value === null || value === undefined) return 0;
|
|
281
|
+
switch (typeof value) {
|
|
282
|
+
case 'string': return new TextEncoder().encode(value).byteLength;
|
|
283
|
+
case 'number': case 'bigint': return 8;
|
|
284
|
+
case 'boolean': return 4;
|
|
285
|
+
case 'object': break;
|
|
286
|
+
default: return 0;
|
|
287
|
+
}
|
|
288
|
+
if (depth > MAX_DEPTH) return 0;
|
|
289
|
+
if (value instanceof ArrayBuffer) return value.byteLength;
|
|
290
|
+
if (ArrayBuffer.isView(value)) return value.byteLength;
|
|
291
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) return value.size;
|
|
292
|
+
let n = 0;
|
|
293
|
+
if (value instanceof Map) {
|
|
294
|
+
for (const [k, v] of value) n += estimateBytes(k, depth + 1) + estimateBytes(v, depth + 1);
|
|
295
|
+
return n;
|
|
296
|
+
}
|
|
297
|
+
if (value instanceof Set) {
|
|
298
|
+
for (const v of value) n += estimateBytes(v, depth + 1);
|
|
299
|
+
return n;
|
|
300
|
+
}
|
|
301
|
+
if (Array.isArray(value)) {
|
|
302
|
+
for (const v of value) n += estimateBytes(v, depth + 1);
|
|
303
|
+
return n;
|
|
304
|
+
}
|
|
305
|
+
for (const k of Object.keys(value)) n += estimateBytes(k, depth + 1) + estimateBytes(value[k], depth + 1);
|
|
306
|
+
return n;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Returned by `ctx.handle(type, target)`: the result encoder registers it. */
|
|
310
|
+
class HandleRef {
|
|
311
|
+
constructor(type, target) {
|
|
312
|
+
this.type = type;
|
|
313
|
+
this.target = target;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Snapshot of a handle's listed properties: primitives and arrays of primitives only. */
|
|
318
|
+
function readProps(rec) {
|
|
319
|
+
const props = {};
|
|
320
|
+
for (const p of rec.typeDef.props) {
|
|
321
|
+
let v;
|
|
322
|
+
try { v = rec.target[p]; } catch { continue; }
|
|
323
|
+
if (v === null || ['string', 'number', 'boolean', 'undefined'].includes(typeof v)) props[p] = v;
|
|
324
|
+
else if (Array.isArray(v) && v.every((x) => x === null || ['string', 'number', 'boolean'].includes(typeof x))) props[p] = [...v];
|
|
325
|
+
}
|
|
326
|
+
return props;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Make a ReadableStream / async iterable / iterable pullable. */
|
|
330
|
+
function toPuller(value) {
|
|
331
|
+
if (value && typeof value.getReader === 'function') {
|
|
332
|
+
const reader = value.getReader();
|
|
333
|
+
return {
|
|
334
|
+
next: () => reader.read(),
|
|
335
|
+
close: (reason) => { try { return Promise.resolve(reader.cancel(reason)).catch(() => {}); } catch { return undefined; } },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (value && typeof value[Symbol.asyncIterator] === 'function') {
|
|
339
|
+
const it = value[Symbol.asyncIterator]();
|
|
340
|
+
return {
|
|
341
|
+
next: () => it.next(),
|
|
342
|
+
close: () => { try { return Promise.resolve(it.return?.()).catch(() => {}); } catch { return undefined; } },
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (value && typeof value !== 'string' && typeof value[Symbol.iterator] === 'function') {
|
|
346
|
+
const it = value[Symbol.iterator]();
|
|
347
|
+
return {
|
|
348
|
+
next: async () => it.next(),
|
|
349
|
+
close: () => { try { it.return?.(); } catch {} },
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
throw new TypeError('A stream method must return a ReadableStream or an (async) iterable');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The per-sandbox bridge host.
|
|
357
|
+
*
|
|
358
|
+
* @param {Map<string, object>} defs from normalizeBridges()
|
|
359
|
+
*/
|
|
360
|
+
export function createBridgeHost(defs) {
|
|
361
|
+
const instances = new Map();
|
|
362
|
+
const gateEntries = {};
|
|
363
|
+
for (const [name, def] of defs) {
|
|
364
|
+
const state = def.createState ? def.createState() : {};
|
|
365
|
+
instances.set(name, { name, def, state, gated: new Map() });
|
|
366
|
+
const names = [...def.methods.keys()];
|
|
367
|
+
for (const [type, h] of def.handles) for (const m of h.methods.keys()) names.push(`${type}.${m}`);
|
|
368
|
+
for (const method of names) {
|
|
369
|
+
// The gate counts and limits the call; `this.run` is the actual work.
|
|
370
|
+
gateEntries[gateName(name, method)] = function bridgeMethod() { return this.run(); };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const gateNames = new Set(Object.keys(gateEntries));
|
|
374
|
+
let gateLookup = null;
|
|
375
|
+
let current = null; // the open session, for stats()
|
|
376
|
+
|
|
377
|
+
const manifests = {};
|
|
378
|
+
for (const [name, def] of defs) manifests[name] = manifestOf(def);
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* One session per Worker/frame.
|
|
382
|
+
* @param {(message: object) => void} post posts to that runtime; throws DataCloneError for uncloneable values
|
|
383
|
+
*/
|
|
384
|
+
function openSession(post) {
|
|
385
|
+
let closed = false;
|
|
386
|
+
const calls = new Map(); // call id -> rec
|
|
387
|
+
const streams = new Map(); // stream id -> rec
|
|
388
|
+
const handles = new Map(); // handle id -> rec
|
|
389
|
+
const pendingCallbacks = new Map(); // reverse-call id -> { resolve, reject }
|
|
390
|
+
const releasedCallbacks = new Set();
|
|
391
|
+
|
|
392
|
+
function send(message) {
|
|
393
|
+
if (closed) return;
|
|
394
|
+
post(message);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function forget(ids) {
|
|
398
|
+
if (!ids.length) return;
|
|
399
|
+
for (const id of ids) releasedCallbacks.add(id);
|
|
400
|
+
try { send({ type: 'bridgeForget', callbacks: ids }); } catch {}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function makeCallbackProxy(inst, id) {
|
|
404
|
+
const proxy = (...args) => new Promise((resolve, reject) => {
|
|
405
|
+
if (closed) { reject(makeDomError('The sandbox is gone', 'InvalidStateError')); return; }
|
|
406
|
+
if (releasedCallbacks.has(id)) { reject(makeDomError('The sandbox function was released', 'InvalidStateError')); return; }
|
|
407
|
+
const rid = crypto.randomUUID();
|
|
408
|
+
pendingCallbacks.set(rid, { resolve, reject });
|
|
409
|
+
try {
|
|
410
|
+
send({ type: 'bridgeCallback', id: rid, bridge: inst.name, callback: id, args });
|
|
411
|
+
} catch (e) {
|
|
412
|
+
pendingCallbacks.delete(rid);
|
|
413
|
+
reject(new TypeError(`Arguments to a sandbox function must be structured-cloneable: ${e?.message ?? e}`));
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
return proxy;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Markers from the sandbox -> host values. Copies plain objects/arrays. */
|
|
420
|
+
function decodeArgs(inst, rec, value, depth) {
|
|
421
|
+
if (value === null || typeof value !== 'object') return value;
|
|
422
|
+
if (depth > MAX_DEPTH) throw new TypeError('Bridge call arguments are nested too deeply');
|
|
423
|
+
if (hasOwn(value, MARK)) {
|
|
424
|
+
const kind = value[MARK];
|
|
425
|
+
if (kind === 'cb') {
|
|
426
|
+
if (!validId(value.id)) throw new TypeError('Invalid function reference');
|
|
427
|
+
if (!rec.callbacks.includes(value.id)) rec.callbacks.push(value.id);
|
|
428
|
+
return makeCallbackProxy(inst, value.id);
|
|
429
|
+
}
|
|
430
|
+
if (kind === 'signal') return rec.controller.signal;
|
|
431
|
+
if (kind === 'handle') {
|
|
432
|
+
const h = validId(value.id) ? handles.get(value.id) : undefined;
|
|
433
|
+
if (!h || h.inst !== inst) throw makeDomError('Unknown or destroyed object passed to a bridge call', 'InvalidStateError');
|
|
434
|
+
return h.target;
|
|
435
|
+
}
|
|
436
|
+
throw new TypeError('Invalid bridge argument marker');
|
|
437
|
+
}
|
|
438
|
+
if (Array.isArray(value)) return value.map((v) => decodeArgs(inst, rec, v, depth + 1));
|
|
439
|
+
if (isPlainObject(value)) {
|
|
440
|
+
const out = {};
|
|
441
|
+
for (const k of Object.keys(value)) {
|
|
442
|
+
if (k === '__proto__') continue;
|
|
443
|
+
out[k] = decodeArgs(inst, rec, value[k], depth + 1);
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function destroyTarget(h) {
|
|
451
|
+
if (!h.typeDef.destroy) return;
|
|
452
|
+
try {
|
|
453
|
+
const r = h.typeDef.destroy(h.target);
|
|
454
|
+
if (r && typeof r.catch === 'function') r.catch(() => {});
|
|
455
|
+
} catch {
|
|
456
|
+
// the host object is gone either way
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function registerHandle(inst, ref, owner) {
|
|
461
|
+
const typeDef = inst.def.handles.get(ref.type);
|
|
462
|
+
const fake = { typeDef, target: ref.target };
|
|
463
|
+
if (closed) {
|
|
464
|
+
destroyTarget(fake);
|
|
465
|
+
throw makeDomError('The sandbox is gone', 'InvalidStateError');
|
|
466
|
+
}
|
|
467
|
+
const max = inst.def.limits.maxHandles;
|
|
468
|
+
if (max > 0 && liveHandles(inst) >= max) {
|
|
469
|
+
destroyTarget(fake);
|
|
470
|
+
throw makeDomError(`Bridge '${inst.name}': handle limit reached (${max}); destroy() one first`, 'QuotaExceededError');
|
|
471
|
+
}
|
|
472
|
+
const id = crypto.randomUUID();
|
|
473
|
+
const h = { id, inst, type: ref.type, typeDef, target: ref.target, callbacks: [], calls: new Set() };
|
|
474
|
+
handles.set(id, h);
|
|
475
|
+
owner?.created?.push(id);
|
|
476
|
+
if (owner && owner.callbacks.length) {
|
|
477
|
+
// Functions passed to the call that created this object live as long
|
|
478
|
+
// as it does (e.g. Prompt API tools called by later prompts).
|
|
479
|
+
h.callbacks.push(...owner.callbacks);
|
|
480
|
+
owner.callbacks = [];
|
|
481
|
+
}
|
|
482
|
+
return { [MARK]: 'handle', id, type: ref.type, props: readProps(h) };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function liveHandles(inst) {
|
|
486
|
+
let n = 0;
|
|
487
|
+
for (const h of handles.values()) if (h.inst === inst) n++;
|
|
488
|
+
return n;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function openStreams(inst) {
|
|
492
|
+
let n = 0;
|
|
493
|
+
for (const s of streams.values()) if (s.inst === inst) n++;
|
|
494
|
+
return n;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Host values -> wire: HandleRefs become handle markers. */
|
|
498
|
+
function encodeResult(inst, owner, value, depth) {
|
|
499
|
+
if (value instanceof HandleRef) return registerHandle(inst, value, owner);
|
|
500
|
+
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value;
|
|
501
|
+
if (Array.isArray(value)) return value.map((v) => encodeResult(inst, owner, v, depth + 1));
|
|
502
|
+
if (isPlainObject(value)) {
|
|
503
|
+
const out = {};
|
|
504
|
+
for (const k of Object.keys(value)) out[k] = encodeResult(inst, owner, value[k], depth + 1);
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
return value;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Destroy HandleRefs in a result nobody will receive. */
|
|
511
|
+
function discardResult(inst, value, depth) {
|
|
512
|
+
if (value instanceof HandleRef) {
|
|
513
|
+
const typeDef = inst.def.handles.get(value.type);
|
|
514
|
+
if (typeDef) destroyTarget({ typeDef, target: value.target });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return;
|
|
518
|
+
if (Array.isArray(value)) for (const v of value) discardResult(inst, v, depth + 1);
|
|
519
|
+
else if (isPlainObject(value)) for (const k of Object.keys(value)) discardResult(inst, value[k], depth + 1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function checkSize(inst, value, what) {
|
|
523
|
+
const max = inst.def.limits.maxResultBytes;
|
|
524
|
+
if (max > 0) {
|
|
525
|
+
const n = estimateBytes(value);
|
|
526
|
+
if (n > max) throw makeDomError(`Bridge '${inst.name}': ${what} is ${n} bytes, over limits.maxResultBytes (${max})`, 'QuotaExceededError');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function endCall(rec) {
|
|
531
|
+
if (rec.timer) clearTimeout(rec.timer);
|
|
532
|
+
rec.timer = null;
|
|
533
|
+
calls.delete(rec.id);
|
|
534
|
+
rec.handle?.calls.delete(rec);
|
|
535
|
+
forget(rec.callbacks);
|
|
536
|
+
rec.callbacks = [];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function replyError(id, error) {
|
|
540
|
+
try { send({ type: 'bridgeResult', id, success: false, error: serializeError(error) }); } catch {}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function makeContext(inst, rec, method) {
|
|
544
|
+
return {
|
|
545
|
+
bridge: inst.name,
|
|
546
|
+
method,
|
|
547
|
+
signal: rec.controller.signal,
|
|
548
|
+
state: inst.state,
|
|
549
|
+
target: rec.handle ? rec.handle.target : undefined,
|
|
550
|
+
handle(type, target) {
|
|
551
|
+
if (!inst.def.handles.has(type)) throw new TypeError(`Bridge '${inst.name}' has no handle type '${type}'`);
|
|
552
|
+
return new HandleRef(type, target);
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function onCall(msg) {
|
|
558
|
+
const { id, bridge, handle: hid, method, args } = msg;
|
|
559
|
+
if (!validId(id) || calls.has(id)) return;
|
|
560
|
+
const inst = typeof bridge === 'string' ? instances.get(bridge) : undefined;
|
|
561
|
+
if (!inst) { replyError(id, new TypeError(`Unknown bridge: ${String(bridge)}`)); return; }
|
|
562
|
+
if (typeof method !== 'string') { replyError(id, new TypeError('Invalid bridge method')); return; }
|
|
563
|
+
let spec;
|
|
564
|
+
let handle = null;
|
|
565
|
+
let methodName;
|
|
566
|
+
if (hid !== null && hid !== undefined) {
|
|
567
|
+
handle = validId(hid) ? handles.get(hid) : undefined;
|
|
568
|
+
if (!handle || handle.inst !== inst) {
|
|
569
|
+
replyError(id, makeDomError('The object has been destroyed', 'InvalidStateError'));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
spec = handle.typeDef.methods.get(method);
|
|
573
|
+
methodName = `${handle.type}.${method}`;
|
|
574
|
+
} else {
|
|
575
|
+
spec = inst.def.methods.get(method);
|
|
576
|
+
methodName = method;
|
|
577
|
+
}
|
|
578
|
+
if (!spec) { replyError(id, new TypeError(`Unknown bridge method: ${bridge}.${method}`)); return; }
|
|
579
|
+
if (!Array.isArray(args)) { replyError(id, new TypeError('Bridge call arguments must be an array')); return; }
|
|
580
|
+
|
|
581
|
+
const rec = { id, inst, handle, controller: new AbortController(), callbacks: [], created: [], timer: null, stream: null };
|
|
582
|
+
calls.set(id, rec);
|
|
583
|
+
handle?.calls.add(rec);
|
|
584
|
+
const { limits } = inst.def;
|
|
585
|
+
if (limits.timeoutMs > 0) {
|
|
586
|
+
rec.timer = setTimeout(() => {
|
|
587
|
+
const err = makeDomError(`Bridge call ${bridge}.${methodName} timed out after ${limits.timeoutMs}ms`, 'TimeoutError');
|
|
588
|
+
rec.controller.abort(err);
|
|
589
|
+
if (rec.stream) closeStream(rec.stream, err, true);
|
|
590
|
+
}, limits.timeoutMs);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let value;
|
|
594
|
+
try {
|
|
595
|
+
const decoded = decodeArgs(inst, rec, args, 0);
|
|
596
|
+
if (spec.handle && limits.maxHandles > 0 && liveHandles(inst) >= limits.maxHandles) {
|
|
597
|
+
throw makeDomError(`Bridge '${inst.name}': handle limit reached (${limits.maxHandles}); destroy() one first`, 'QuotaExceededError');
|
|
598
|
+
}
|
|
599
|
+
if (spec.stream && limits.maxStreams > 0 && openStreams(inst) >= limits.maxStreams) {
|
|
600
|
+
throw makeDomError(`Bridge '${inst.name}': open stream limit reached (${limits.maxStreams})`, 'QuotaExceededError');
|
|
601
|
+
}
|
|
602
|
+
const gated = gateLookup?.(gateName(bridge, methodName));
|
|
603
|
+
if (!gated) throw new TypeError(`Unknown bridge method: ${bridge}.${methodName}`);
|
|
604
|
+
const ctx = makeContext(inst, rec, methodName);
|
|
605
|
+
value = await gated.call({ run: () => runMethod(inst, rec, spec, ctx, methodName, decoded) }, ...args);
|
|
606
|
+
} catch (e) {
|
|
607
|
+
if (calls.get(id) === rec) {
|
|
608
|
+
replyError(id, e);
|
|
609
|
+
endCall(rec);
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (calls.get(id) !== rec || closed || rec.controller.signal.aborted) {
|
|
614
|
+
// Aborted, timed out, destroyed or the session closed while the host
|
|
615
|
+
// was working: release what the method produced.
|
|
616
|
+
if (spec.stream) { try { toPuller(value).close(); } catch {} }
|
|
617
|
+
else discardResult(inst, value, 0);
|
|
618
|
+
if (calls.get(id) === rec) {
|
|
619
|
+
replyError(id, rec.controller.signal.reason ?? makeDomError('The operation was aborted', 'AbortError'));
|
|
620
|
+
endCall(rec);
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (spec.stream) {
|
|
626
|
+
let puller;
|
|
627
|
+
try {
|
|
628
|
+
puller = toPuller(value);
|
|
629
|
+
} catch (e) {
|
|
630
|
+
replyError(id, e);
|
|
631
|
+
endCall(rec);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const sid = crypto.randomUUID();
|
|
635
|
+
const s = { id: sid, inst, rec, puller, reading: false, closed: false };
|
|
636
|
+
rec.stream = s;
|
|
637
|
+
streams.set(sid, s);
|
|
638
|
+
try {
|
|
639
|
+
send({ type: 'bridgeResult', id, success: true, value: { stream: sid } });
|
|
640
|
+
} catch (e) {
|
|
641
|
+
closeStream(s, e, false);
|
|
642
|
+
replyError(id, e);
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
const wire = encodeResult(inst, rec, value, 0);
|
|
649
|
+
checkSize(inst, wire, 'the result');
|
|
650
|
+
send({
|
|
651
|
+
type: 'bridgeResult', id, success: true, value: wire,
|
|
652
|
+
...(handle && handles.has(handle.id) ? { props: readProps(handle) } : {}),
|
|
653
|
+
});
|
|
654
|
+
} catch (e) {
|
|
655
|
+
// The sandbox never learns about handles this result registered.
|
|
656
|
+
for (const hid of rec.created) {
|
|
657
|
+
const h = handles.get(hid);
|
|
658
|
+
if (h) destroyHandleRecord(h, e);
|
|
659
|
+
}
|
|
660
|
+
replyError(id, e.name === 'DataCloneError' || /clone/i.test(String(e?.message))
|
|
661
|
+
? makeDomError(`${bridge}.${methodName} returned a value that cannot be structured-cloned into the sandbox: ${e.message}`, 'DataCloneError')
|
|
662
|
+
: e);
|
|
663
|
+
}
|
|
664
|
+
endCall(rec);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function runMethod(inst, rec, spec, ctx, methodName, args) {
|
|
668
|
+
let needed = spec.requiresUserActivation;
|
|
669
|
+
if (typeof needed === 'function') needed = await needed(ctx, ...args);
|
|
670
|
+
// true means transient; anything that is not a known kind means "not needed".
|
|
671
|
+
const activation = needed === true ? 'transient' : ACTIVATION_KINDS.includes(needed) ? needed : null;
|
|
672
|
+
const needsActivation = activation !== null;
|
|
673
|
+
const ua = globalThis.navigator?.userActivation;
|
|
674
|
+
const hasActivation = () => (activation === 'sticky' ? ua.hasBeenActive || ua.isActive : ua.isActive);
|
|
675
|
+
const onRequest = inst.def.onRequest;
|
|
676
|
+
if (onRequest) {
|
|
677
|
+
let verdict;
|
|
678
|
+
try {
|
|
679
|
+
verdict = await onRequest({
|
|
680
|
+
bridge: inst.name,
|
|
681
|
+
method: methodName,
|
|
682
|
+
args,
|
|
683
|
+
handle: rec.handle ? { id: rec.handle.id, type: rec.handle.type } : null,
|
|
684
|
+
requiresUserActivation: activation ?? false,
|
|
685
|
+
userActivation: ua ? { isActive: ua.isActive, hasBeenActive: ua.hasBeenActive } : null,
|
|
686
|
+
signal: rec.controller.signal,
|
|
687
|
+
});
|
|
688
|
+
} catch (e) {
|
|
689
|
+
throw makeDomError(e?.message ? String(e.message) : `${inst.name}.${methodName} was denied by onRequest`, 'NotAllowedError');
|
|
690
|
+
}
|
|
691
|
+
if (verdict !== true) throw makeDomError(`${inst.name}.${methodName} was denied by onRequest`, 'NotAllowedError');
|
|
692
|
+
}
|
|
693
|
+
if (needsActivation && ua && !hasActivation()) {
|
|
694
|
+
throw makeDomError(
|
|
695
|
+
`${inst.name}.${methodName} requires ${activation} user activation (a click or key press on the page). ` +
|
|
696
|
+
'Resolve onRequest from a click handler (for example a consent button) so the call runs while the page has activation.',
|
|
697
|
+
'NotAllowedError'
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
if (rec.controller.signal.aborted) throw rec.controller.signal.reason;
|
|
701
|
+
return spec.call(ctx, ...args);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function closeStream(s, reason, notify) {
|
|
705
|
+
if (s.closed) return;
|
|
706
|
+
s.closed = true;
|
|
707
|
+
streams.delete(s.id);
|
|
708
|
+
if (!s.rec.controller.signal.aborted) {
|
|
709
|
+
s.rec.controller.abort(reason ?? makeDomError('The stream was cancelled', 'AbortError'));
|
|
710
|
+
}
|
|
711
|
+
try { s.puller.close(reason); } catch {}
|
|
712
|
+
if (notify) {
|
|
713
|
+
try { send({ type: 'bridgeChunk', stream: s.id, error: serializeError(reason) }); } catch {}
|
|
714
|
+
}
|
|
715
|
+
endCall(s.rec);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function onPull(msg) {
|
|
719
|
+
const s = validId(msg.stream) ? streams.get(msg.stream) : undefined;
|
|
720
|
+
if (!s || s.closed || s.reading) return;
|
|
721
|
+
s.reading = true;
|
|
722
|
+
let r;
|
|
723
|
+
try {
|
|
724
|
+
r = await s.puller.next();
|
|
725
|
+
} catch (e) {
|
|
726
|
+
if (!s.closed) closeStream(s, e, true);
|
|
727
|
+
return;
|
|
728
|
+
} finally {
|
|
729
|
+
s.reading = false;
|
|
730
|
+
}
|
|
731
|
+
if (s.closed) return;
|
|
732
|
+
const handle = s.rec.handle;
|
|
733
|
+
if (r.done) {
|
|
734
|
+
s.closed = true;
|
|
735
|
+
streams.delete(s.id);
|
|
736
|
+
try {
|
|
737
|
+
send({ type: 'bridgeChunk', stream: s.id, done: true, ...(handle && handles.has(handle.id) ? { props: readProps(handle) } : {}) });
|
|
738
|
+
} catch {}
|
|
739
|
+
endCall(s.rec);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
const wire = encodeResult(s.inst, s.rec, r.value, 0);
|
|
744
|
+
checkSize(s.inst, wire, 'a stream chunk');
|
|
745
|
+
send({ type: 'bridgeChunk', stream: s.id, value: wire });
|
|
746
|
+
} catch (e) {
|
|
747
|
+
closeStream(s, e.name === 'DataCloneError' || /clone/i.test(String(e?.message))
|
|
748
|
+
? makeDomError(`A stream chunk cannot be structured-cloned into the sandbox: ${e.message}`, 'DataCloneError')
|
|
749
|
+
: e, true);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function destroyHandleRecord(h, reason) {
|
|
754
|
+
if (!handles.has(h.id)) return;
|
|
755
|
+
handles.delete(h.id);
|
|
756
|
+
for (const rec of [...h.calls]) {
|
|
757
|
+
if (rec.stream) closeStream(rec.stream, reason, false);
|
|
758
|
+
else if (!rec.controller.signal.aborted) rec.controller.abort(reason);
|
|
759
|
+
}
|
|
760
|
+
destroyTarget(h);
|
|
761
|
+
forget(h.callbacks);
|
|
762
|
+
h.callbacks = [];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function receive(msg) {
|
|
766
|
+
if (closed || !msg || typeof msg.type !== 'string') return;
|
|
767
|
+
switch (msg.type) {
|
|
768
|
+
case 'bridgeCall':
|
|
769
|
+
onCall(msg);
|
|
770
|
+
break;
|
|
771
|
+
case 'bridgeAbort': {
|
|
772
|
+
const rec = validId(msg.id) ? calls.get(msg.id) : undefined;
|
|
773
|
+
if (!rec) break;
|
|
774
|
+
const reason = makeDomError('The operation was aborted by the sandbox', 'AbortError');
|
|
775
|
+
if (rec.stream) closeStream(rec.stream, reason, false);
|
|
776
|
+
else {
|
|
777
|
+
rec.controller.abort(reason);
|
|
778
|
+
endCall(rec);
|
|
779
|
+
}
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
case 'bridgePull':
|
|
783
|
+
onPull(msg);
|
|
784
|
+
break;
|
|
785
|
+
case 'bridgeCancel': {
|
|
786
|
+
const s = validId(msg.stream) ? streams.get(msg.stream) : undefined;
|
|
787
|
+
if (s) closeStream(s, makeDomError('The stream was cancelled by the sandbox', 'AbortError'), false);
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
case 'bridgeRelease': {
|
|
791
|
+
const h = validId(msg.handle) ? handles.get(msg.handle) : undefined;
|
|
792
|
+
if (h && h.inst.name === msg.bridge) destroyHandleRecord(h, makeDomError(`The ${h.type} has been destroyed`, 'AbortError'));
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
case 'bridgeCallbackResult': {
|
|
796
|
+
const p = validId(msg.id) ? pendingCallbacks.get(msg.id) : undefined;
|
|
797
|
+
if (!p) break;
|
|
798
|
+
pendingCallbacks.delete(msg.id);
|
|
799
|
+
if (msg.success) p.resolve(msg.value);
|
|
800
|
+
else {
|
|
801
|
+
const e = msg.error?.name === 'Error' || !msg.error?.name
|
|
802
|
+
? new Error(String(msg.error?.message ?? 'Sandbox function failed'))
|
|
803
|
+
: makeDomError(String(msg.error?.message ?? 'Sandbox function failed'), String(msg.error.name));
|
|
804
|
+
p.reject(e);
|
|
805
|
+
}
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** The runtime is gone (dispose, timeout, abort, crash): release everything it held. */
|
|
812
|
+
function close(reason = makeDomError('The sandbox was terminated', 'AbortError')) {
|
|
813
|
+
if (closed) return;
|
|
814
|
+
closed = true;
|
|
815
|
+
for (const rec of [...calls.values()]) {
|
|
816
|
+
if (rec.timer) clearTimeout(rec.timer);
|
|
817
|
+
if (!rec.controller.signal.aborted) rec.controller.abort(reason);
|
|
818
|
+
}
|
|
819
|
+
for (const s of [...streams.values()]) {
|
|
820
|
+
s.closed = true;
|
|
821
|
+
try { s.puller.close(reason); } catch {}
|
|
822
|
+
}
|
|
823
|
+
for (const h of [...handles.values()]) destroyTarget(h);
|
|
824
|
+
for (const p of pendingCallbacks.values()) p.reject(reason);
|
|
825
|
+
calls.clear();
|
|
826
|
+
streams.clear();
|
|
827
|
+
handles.clear();
|
|
828
|
+
pendingCallbacks.clear();
|
|
829
|
+
if (current === session) current = null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function sessionStats() {
|
|
833
|
+
const out = {};
|
|
834
|
+
for (const [name] of instances) out[name] = { handles: 0, streams: 0, calls: 0 };
|
|
835
|
+
for (const h of handles.values()) out[h.inst.name].handles++;
|
|
836
|
+
for (const s of streams.values()) out[s.inst.name].streams++;
|
|
837
|
+
for (const c of calls.values()) out[c.inst.name].calls++;
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const session = { receive, close, stats: sessionStats, isClosed: () => closed };
|
|
842
|
+
current = session;
|
|
843
|
+
return session;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function stats() {
|
|
847
|
+
const live = current ? current.stats() : null;
|
|
848
|
+
const out = {};
|
|
849
|
+
for (const [name, inst] of instances) {
|
|
850
|
+
out[name] = {
|
|
851
|
+
handles: live ? live[name].handles : 0,
|
|
852
|
+
streams: live ? live[name].streams : 0,
|
|
853
|
+
pendingCalls: live ? live[name].calls : 0,
|
|
854
|
+
...(inst.def.stats ? inst.def.stats(inst.state) : {}),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
manifests,
|
|
862
|
+
gateEntries,
|
|
863
|
+
/** Give the host the gate's lookup (gated functions are found by name). */
|
|
864
|
+
useGate(lookup) { gateLookup = lookup; },
|
|
865
|
+
/** True for the gate names of bridge methods, which `host.call()` must not reach. */
|
|
866
|
+
isGateName: (name) => gateNames.has(name),
|
|
867
|
+
openSession,
|
|
868
|
+
stats,
|
|
869
|
+
};
|
|
870
|
+
}
|