@johnhenry/andbox 0.2.0 → 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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * The in-sandbox half of `createSandbox({ bridges })` (andbox#46).
3
+ *
4
+ * `installBridges(post)` runs inside the sandbox runtime (Worker, worker
5
+ * thread or iframe), before any evaluated code. It returns
6
+ * `{ install(manifests), receive(message) }`: `install` builds one global per
7
+ * bridge from a JSON manifest the host sends with `configure`, and `receive`
8
+ * handles the host's `bridge*` replies.
9
+ *
10
+ * Stringified into the runtime with `toString()`, so it must not reference
11
+ * anything outside its own body.
12
+ *
13
+ * Wire protocol (sandbox -> host):
14
+ * bridgeCall { id, bridge, handle, method, args } one method call
15
+ * bridgeAbort { id } the call's AbortSignal fired
16
+ * bridgePull { stream } send the next chunk
17
+ * bridgeCancel { stream } the stream was cancelled
18
+ * bridgeRelease { bridge, handle } handle.destroy()
19
+ * bridgeCallbackResult { id, success, value, error } reply to a reverse call
20
+ * (host -> sandbox):
21
+ * bridgeResult { id, success, value, error, props }
22
+ * bridgeChunk { stream, done, value, error, props }
23
+ * bridgeCallback { id, callback, args } call a sandbox function
24
+ * bridgeForget { callbacks } the host dropped these functions
25
+ *
26
+ * Values cross by structured clone. Functions, AbortSignals and handle objects
27
+ * in arguments are replaced by markers `{ __andbox_bridge__: kind, id }`.
28
+ *
29
+ * @param {(message: object) => void} post
30
+ */
31
+ export function installBridges(post) {
32
+ const G = globalThis;
33
+ const MARK = '__andbox_bridge__';
34
+ // Captured before evaluated code runs, so changes to these globals later do
35
+ // not change what the bridge does.
36
+ const ReadableStreamCtor = G.ReadableStream;
37
+ const AbortSignalCtor = G.AbortSignal;
38
+ const DOMExceptionCtor = G.DOMException;
39
+ const PromiseCtor = Promise;
40
+ const MapCtor = Map;
41
+ const WeakMapCtor = WeakMap;
42
+ const ErrorCtor = Error;
43
+ const NATIVE_ERRORS = { TypeError, RangeError, SyntaxError, ReferenceError, EvalError, URIError };
44
+ const randomUUID = G.crypto.randomUUID.bind(G.crypto);
45
+ const defineProperty = Object.defineProperty;
46
+ const getPrototypeOf = Object.getPrototypeOf;
47
+ const keys = Object.keys;
48
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
49
+ const isArray = Array.isArray;
50
+ const ObjectProto = Object.prototype;
51
+ const asyncIteratorSymbol = Symbol.asyncIterator;
52
+ const disposeSymbol = Symbol.dispose;
53
+ const toStringTagSymbol = Symbol.toStringTag;
54
+ const MAX_DEPTH = 64;
55
+
56
+ let manifests = Object.create(null);
57
+ const calls = new MapCtor(); // call id -> record
58
+ const streams = new MapCtor(); // stream id -> record
59
+ const callbacks = new MapCtor(); // callback id -> function
60
+ const handleState = new WeakMapCtor(); // handle object -> state
61
+ const liveHandles = new MapCtor(); // bridge + '\0' + id -> state
62
+
63
+ function domError(message, name) {
64
+ if (typeof DOMExceptionCtor === 'function') return new DOMExceptionCtor(message, name);
65
+ const e = new ErrorCtor(message);
66
+ e.name = name;
67
+ return e;
68
+ }
69
+
70
+ function abortReason(signal) {
71
+ return signal.reason !== undefined ? signal.reason : domError('This operation was aborted', 'AbortError');
72
+ }
73
+
74
+ /** Rebuild an error the host sent as `{ name, message, ... }`. */
75
+ function toError(data) {
76
+ const name = data && typeof data.name === 'string' ? data.name : 'Error';
77
+ const message = data && typeof data.message === 'string' ? data.message : 'Bridge call failed';
78
+ let e;
79
+ if (hasOwn(NATIVE_ERRORS, name)) e = new NATIVE_ERRORS[name](message);
80
+ else if (name !== 'Error' && typeof DOMExceptionCtor === 'function') e = new DOMExceptionCtor(message, name);
81
+ else { e = new ErrorCtor(message); if (name !== 'Error') e.name = name; }
82
+ if (data && typeof data.code === 'string') { try { e.code = data.code; } catch {} }
83
+ for (const k of ['requested', 'quota']) {
84
+ if (data && typeof data[k] === 'number') { try { defineProperty(e, k, { value: data[k], enumerable: true, configurable: true }); } catch {} }
85
+ }
86
+ return e;
87
+ }
88
+
89
+ function isPlainObject(v) {
90
+ const proto = getPrototypeOf(v);
91
+ return proto === ObjectProto || proto === null;
92
+ }
93
+
94
+ /**
95
+ * Replace functions, AbortSignals and handle objects in `value` by markers.
96
+ * `acc` collects the signals and callback ids of one call.
97
+ */
98
+ function encode(value, acc, depth, seen) {
99
+ if (typeof value === 'function') {
100
+ let id = acc.fnIds.get(value);
101
+ if (id === undefined) {
102
+ id = randomUUID();
103
+ acc.fnIds.set(value, id);
104
+ callbacks.set(id, value);
105
+ acc.callbacks.push(id);
106
+ }
107
+ return { [MARK]: 'cb', id };
108
+ }
109
+ if (value === null || typeof value !== 'object') return value;
110
+ const hs = handleState.get(value);
111
+ if (hs) {
112
+ if (hs.destroyed) throw domError('A destroyed object was passed to a bridge call', 'InvalidStateError');
113
+ return { [MARK]: 'handle', id: hs.id };
114
+ }
115
+ if (typeof AbortSignalCtor === 'function' && value instanceof AbortSignalCtor) {
116
+ if (!acc.signals.includes(value)) acc.signals.push(value);
117
+ return { [MARK]: 'signal' };
118
+ }
119
+ if (depth > MAX_DEPTH) throw new TypeError('Bridge call arguments are nested too deeply');
120
+ if (isArray(value) || isPlainObject(value)) {
121
+ if (seen.includes(value)) throw new TypeError('Bridge call arguments cannot contain cycles');
122
+ seen.push(value);
123
+ let out;
124
+ if (isArray(value)) {
125
+ out = [];
126
+ for (let i = 0; i < value.length; i++) out.push(encode(value[i], acc, depth + 1, seen));
127
+ } else {
128
+ out = {};
129
+ for (const k of keys(value)) {
130
+ if (k === '__proto__') continue;
131
+ out[k] = encode(value[k], acc, depth + 1, seen);
132
+ }
133
+ }
134
+ seen.pop();
135
+ return out;
136
+ }
137
+ // Dates, Maps, typed arrays, Blobs, ImageBitmaps...: structured clone.
138
+ return value;
139
+ }
140
+
141
+ /** Turn handle markers from the host into handle objects. */
142
+ function decode(bridge, value, depth) {
143
+ if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value;
144
+ if (hasOwn(value, MARK)) {
145
+ if (value[MARK] === 'handle') return makeHandle(bridge, value);
146
+ return value;
147
+ }
148
+ if (isArray(value)) {
149
+ for (let i = 0; i < value.length; i++) value[i] = decode(bridge, value[i], depth + 1);
150
+ } else if (isPlainObject(value)) {
151
+ for (const k of keys(value)) value[k] = decode(bridge, value[k], depth + 1);
152
+ }
153
+ return value;
154
+ }
155
+
156
+ function forgetCallbacks(ids) {
157
+ for (const id of ids) callbacks.delete(id);
158
+ }
159
+
160
+ /**
161
+ * Start one call. Returns the call record; `record.promise` settles with the
162
+ * host's reply (a value, or `{ stream }` for stream methods).
163
+ */
164
+ function startCall(bridge, hs, method, args, isStream) {
165
+ const record = { id: randomUUID(), bridge, hs, isStream, settled: false, signals: [], onAbort: null, stream: null };
166
+ record.promise = new PromiseCtor((resolve, reject) => {
167
+ record.resolve = resolve;
168
+ record.reject = reject;
169
+ });
170
+ const early = (error) => {
171
+ record.settled = true;
172
+ record.reject(error);
173
+ return record;
174
+ };
175
+ if (hs && hs.destroyed) return early(domError(`The ${hs.type} has been destroyed`, 'InvalidStateError'));
176
+ const acc = { signals: record.signals, callbacks: [], fnIds: new MapCtor() };
177
+ let wire;
178
+ try {
179
+ wire = encode(args, acc, 0, []);
180
+ } catch (e) {
181
+ forgetCallbacks(acc.callbacks);
182
+ return early(e);
183
+ }
184
+ for (const s of record.signals) {
185
+ if (s.aborted) {
186
+ forgetCallbacks(acc.callbacks);
187
+ return early(abortReason(s));
188
+ }
189
+ }
190
+ record.onAbort = (event) => {
191
+ const signal = event && event.target ? event.target : record.signals.find((s) => s.aborted);
192
+ const reason = signal ? abortReason(signal) : domError('This operation was aborted', 'AbortError');
193
+ try { post({ type: 'bridgeAbort', id: record.id }); } catch {}
194
+ fail(record, reason);
195
+ };
196
+ for (const s of record.signals) s.addEventListener('abort', record.onAbort, { once: true });
197
+ calls.set(record.id, record);
198
+ try {
199
+ post({ type: 'bridgeCall', id: record.id, bridge, handle: hs ? hs.id : null, method, args: wire });
200
+ } catch (e) {
201
+ forgetCallbacks(acc.callbacks);
202
+ fail(record, e);
203
+ }
204
+ return record;
205
+ }
206
+
207
+ /** Done with a call: drop it and its abort listeners. */
208
+ function finish(record) {
209
+ if (record.settled) return false;
210
+ record.settled = true;
211
+ calls.delete(record.id);
212
+ for (const s of record.signals) s.removeEventListener('abort', record.onAbort);
213
+ return true;
214
+ }
215
+
216
+ function fail(record, error) {
217
+ const st = record.stream;
218
+ if (!finish(record)) return;
219
+ if (st) failStream(st, error);
220
+ record.reject(error);
221
+ }
222
+
223
+ function invoke(bridge, hs, method, args) {
224
+ const record = startCall(bridge, hs, method, args, false);
225
+ return record.promise;
226
+ }
227
+
228
+ function failStream(st, error) {
229
+ if (st.closed) return;
230
+ st.closed = true;
231
+ streams.delete(st.id);
232
+ try { st.controller.error(error); } catch {}
233
+ if (st.waiter) { st.waiter.resolve(); st.waiter = null; }
234
+ }
235
+
236
+ function addAsyncIterator(stream) {
237
+ if (asyncIteratorSymbol in stream) return stream;
238
+ const values = function ({ preventCancel = false } = {}) {
239
+ const reader = stream.getReader();
240
+ return {
241
+ next() { return reader.read(); },
242
+ async return(value) {
243
+ if (!preventCancel) await reader.cancel(value);
244
+ reader.releaseLock();
245
+ return { done: true, value };
246
+ },
247
+ [asyncIteratorSymbol]() { return this; },
248
+ };
249
+ };
250
+ defineProperty(stream, 'values', { value: values, configurable: true, writable: true });
251
+ defineProperty(stream, asyncIteratorSymbol, { value: values, configurable: true, writable: true });
252
+ return stream;
253
+ }
254
+
255
+ /** A stream method: returns a ReadableStream at once, like the platform's. */
256
+ function invokeStream(bridge, hs, method, args) {
257
+ let record;
258
+ const st = { id: null, controller: null, waiter: null, closed: false, hs, record: null };
259
+ const stream = new ReadableStreamCtor({
260
+ start(controller) {
261
+ st.controller = controller;
262
+ record = startCall(bridge, hs, method, args, true);
263
+ st.record = record;
264
+ if (!record.settled) record.stream = st;
265
+ return record.promise.then((reply) => {
266
+ if (st.closed) return;
267
+ st.id = reply && typeof reply.stream === 'string' ? reply.stream : null;
268
+ if (!st.id) throw new TypeError('Bridge stream method returned no stream');
269
+ streams.set(st.id, st);
270
+ });
271
+ },
272
+ pull() {
273
+ if (st.closed || !st.id) return undefined;
274
+ return new PromiseCtor((resolve, reject) => {
275
+ st.waiter = { resolve, reject };
276
+ try { post({ type: 'bridgePull', stream: st.id }); } catch (e) { st.waiter = null; reject(e); }
277
+ });
278
+ },
279
+ cancel() {
280
+ if (st.closed) return;
281
+ st.closed = true;
282
+ if (st.id) {
283
+ streams.delete(st.id);
284
+ try { post({ type: 'bridgeCancel', stream: st.id }); } catch {}
285
+ } else if (record && !record.settled) {
286
+ try { post({ type: 'bridgeAbort', id: record.id }); } catch {}
287
+ }
288
+ if (record) finish(record);
289
+ },
290
+ });
291
+ return addAsyncIterator(stream);
292
+ }
293
+
294
+ function destroyHandle(hs, notifyHost) {
295
+ if (hs.destroyed) return;
296
+ hs.destroyed = true;
297
+ liveHandles.delete(hs.bridge + '\0' + hs.id);
298
+ if (notifyHost) {
299
+ try { post({ type: 'bridgeRelease', bridge: hs.bridge, handle: hs.id }); } catch {}
300
+ }
301
+ // Like the platform: pending calls on a destroyed object reject.
302
+ const reason = domError(`The ${hs.type} has been destroyed`, 'AbortError');
303
+ for (const record of [...calls.values()]) {
304
+ if (record.hs === hs) fail(record, reason);
305
+ }
306
+ for (const st of [...streams.values()]) {
307
+ if (st.hs === hs) {
308
+ failStream(st, reason);
309
+ if (st.record) finish(st.record);
310
+ }
311
+ }
312
+ }
313
+
314
+ function makeHandle(bridge, desc) {
315
+ const manifest = manifests[bridge];
316
+ const type = manifest && typeof desc.type === 'string' && hasOwn(manifest.handles, desc.type) ? manifest.handles[desc.type] : null;
317
+ if (!type || typeof desc.id !== 'string') return desc;
318
+ const key = bridge + '\0' + desc.id;
319
+ const existing = liveHandles.get(key);
320
+ if (existing) {
321
+ if (desc.props) existing.props = desc.props;
322
+ return existing.object;
323
+ }
324
+ const hs = { bridge, id: desc.id, type: desc.type, destroyed: false, props: desc.props || {}, object: null };
325
+ const obj = {};
326
+ for (const name of keys(type.methods)) {
327
+ const fn = type.methods[name] === 'stream'
328
+ ? function (...args) { return invokeStream(bridge, hs, name, args); }
329
+ : function (...args) { return invoke(bridge, hs, name, args); };
330
+ defineProperty(fn, 'name', { value: name });
331
+ defineProperty(obj, name, { value: fn, writable: true, configurable: true });
332
+ }
333
+ for (const prop of type.props) {
334
+ defineProperty(obj, prop, { get() { return hs.props[prop]; }, enumerable: true, configurable: true });
335
+ }
336
+ const destroy = function destroy() { destroyHandle(hs, true); };
337
+ defineProperty(obj, 'destroy', { value: destroy, writable: true, configurable: true });
338
+ if (disposeSymbol) defineProperty(obj, disposeSymbol, { value: destroy, writable: true, configurable: true });
339
+ defineProperty(obj, toStringTagSymbol, { value: desc.type, configurable: true });
340
+ hs.object = obj;
341
+ handleState.set(obj, hs);
342
+ liveHandles.set(key, hs);
343
+ return obj;
344
+ }
345
+
346
+ function buildApi(bridge, manifest) {
347
+ const root = {};
348
+ for (const path of keys(manifest.api)) {
349
+ const parts = path.split('.');
350
+ let node = root;
351
+ for (let i = 0; i < parts.length - 1; i++) {
352
+ if (!hasOwn(node, parts[i])) node[parts[i]] = {};
353
+ node = node[parts[i]];
354
+ }
355
+ const name = parts[parts.length - 1];
356
+ const fn = manifest.api[path] === 'stream'
357
+ ? function (...args) { return invokeStream(bridge, null, path, args); }
358
+ : function (...args) { return invoke(bridge, null, path, args); };
359
+ defineProperty(fn, 'name', { value: name });
360
+ node[name] = fn;
361
+ }
362
+ return root;
363
+ }
364
+
365
+ function lookupPath(root, path) {
366
+ let node = root;
367
+ for (const part of path.split('.')) {
368
+ if (node === null || typeof node !== 'object' || !hasOwn(node, part)) return undefined;
369
+ node = node[part];
370
+ }
371
+ return node;
372
+ }
373
+
374
+ function defineGlobal(name, value) {
375
+ try { delete G[name]; } catch {}
376
+ defineProperty(G, name, { value, writable: true, configurable: true, enumerable: false });
377
+ }
378
+
379
+ function install(list) {
380
+ manifests = Object.create(null);
381
+ for (const name of keys(list)) manifests[name] = list[name];
382
+ for (const name of keys(list)) {
383
+ const manifest = list[name];
384
+ let api = buildApi(name, manifest);
385
+ if (typeof manifest.client === 'string') {
386
+ // Host-authored adapter for platform-specific shapes (e.g. Chrome AI's
387
+ // `monitor`); it runs here, with no more authority than the sandbox.
388
+ const client = new Function(`return (${manifest.client});`)();
389
+ const adapted = client(api, manifest.clientOptions === undefined ? {} : manifest.clientOptions);
390
+ if (adapted !== undefined) api = adapted;
391
+ }
392
+ defineGlobal(name, api);
393
+ for (const alias of keys(manifest.globals || {})) {
394
+ const target = lookupPath(api, manifest.globals[alias]);
395
+ if (target !== undefined) defineGlobal(alias, target);
396
+ }
397
+ }
398
+ }
399
+
400
+ async function runCallback(msg) {
401
+ const fn = callbacks.get(msg.callback);
402
+ if (!fn) {
403
+ post({ type: 'bridgeCallbackResult', id: msg.id, success: false, error: { name: 'InvalidStateError', message: 'The sandbox function was released' } });
404
+ return;
405
+ }
406
+ let reply;
407
+ try {
408
+ const args = isArray(msg.args) ? msg.args.map((a) => decode(msg.bridge, a, 0)) : [];
409
+ const value = await fn(...args);
410
+ reply = { type: 'bridgeCallbackResult', id: msg.id, success: true, value };
411
+ } catch (e) {
412
+ reply = {
413
+ type: 'bridgeCallbackResult', id: msg.id, success: false,
414
+ error: { name: e && typeof e.name === 'string' ? e.name : 'Error', message: e && e.message ? String(e.message) : String(e) },
415
+ };
416
+ }
417
+ try {
418
+ post(reply);
419
+ } catch (e) {
420
+ post({ type: 'bridgeCallbackResult', id: msg.id, success: false, error: { name: 'DataCloneError', message: `The sandbox function's result cannot be sent to the host: ${e && e.message ? e.message : e}` } });
421
+ }
422
+ }
423
+
424
+ function receive(msg) {
425
+ switch (msg.type) {
426
+ case 'bridgeResult': {
427
+ const record = calls.get(msg.id);
428
+ if (!record) return true;
429
+ if (record.hs && msg.props && !record.hs.destroyed) record.hs.props = msg.props;
430
+ if (msg.success) {
431
+ let value;
432
+ try { value = record.isStream ? msg.value : decode(record.bridge, msg.value, 0); } catch (e) { fail(record, e); return true; }
433
+ if (record.isStream) {
434
+ // Keep the record (and its abort listeners) until the stream ends.
435
+ record.resolve(value);
436
+ } else if (finish(record)) {
437
+ record.resolve(value);
438
+ }
439
+ } else {
440
+ fail(record, toError(msg.error));
441
+ }
442
+ return true;
443
+ }
444
+ case 'bridgeChunk': {
445
+ const st = streams.get(msg.stream);
446
+ if (!st || st.closed) return true;
447
+ const waiter = st.waiter;
448
+ st.waiter = null;
449
+ if (st.hs && msg.props && !st.hs.destroyed) st.hs.props = msg.props;
450
+ if (msg.error) {
451
+ failStream(st, toError(msg.error));
452
+ if (st.record) finish(st.record);
453
+ } else if (msg.done) {
454
+ st.closed = true;
455
+ streams.delete(st.id);
456
+ try { st.controller.close(); } catch {}
457
+ if (st.record) finish(st.record);
458
+ } else {
459
+ try { st.controller.enqueue(decode(st.record ? st.record.bridge : '', msg.value, 0)); } catch {}
460
+ }
461
+ if (waiter) waiter.resolve();
462
+ return true;
463
+ }
464
+ case 'bridgeCallback':
465
+ runCallback(msg);
466
+ return true;
467
+ case 'bridgeForget':
468
+ if (isArray(msg.callbacks)) forgetCallbacks(msg.callbacks);
469
+ return true;
470
+ default:
471
+ return false;
472
+ }
473
+ }
474
+
475
+ return { install, receive };
476
+ }