@johnhenry/andbox 0.0.1
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/LICENSE +21 -0
- package/README.md +207 -0
- package/package.json +40 -0
- package/src/capability-gate.mjs +98 -0
- package/src/constants.mjs +24 -0
- package/src/deferred.mjs +23 -0
- package/src/import-map-resolver.mjs +71 -0
- package/src/index.d.ts +268 -0
- package/src/index.mjs +14 -0
- package/src/network-policy.mjs +48 -0
- package/src/sandbox.mjs +394 -0
- package/src/stdio.mjs +58 -0
- package/src/worker-source.mjs +216 -0
package/src/sandbox.mjs
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* andbox — Sandboxed JavaScript runtime.
|
|
3
|
+
*
|
|
4
|
+
* Creates an isolated Web Worker sandbox with:
|
|
5
|
+
* - RPC-based capability calls (host.call)
|
|
6
|
+
* - Import map resolution
|
|
7
|
+
* - Virtual module definitions
|
|
8
|
+
* - Timeout + hard kill + restart
|
|
9
|
+
* - Console forwarding
|
|
10
|
+
* - Capability gating with rate limits
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { makeWorkerSource } from './worker-source.mjs';
|
|
14
|
+
import { gateCapabilities } from './capability-gate.mjs';
|
|
15
|
+
import { makeDeferred, makeTimeoutError, makeAbortError } from './deferred.mjs';
|
|
16
|
+
import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
|
|
17
|
+
|
|
18
|
+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
19
|
+
|
|
20
|
+
// ── Inline sandbox (same-thread, AsyncFunction-based) ──
|
|
21
|
+
|
|
22
|
+
function createInlineSandbox(opts = {}) {
|
|
23
|
+
const globals = opts.globals || {};
|
|
24
|
+
return {
|
|
25
|
+
async execute(code, execOpts = {}) {
|
|
26
|
+
const timeout = execOpts.timeout || opts.defaultTimeoutMs || 30000;
|
|
27
|
+
const globalKeys = Object.keys(globals);
|
|
28
|
+
const globalValues = globalKeys.map(k => globals[k]);
|
|
29
|
+
const output = [];
|
|
30
|
+
const print = (...args) => {
|
|
31
|
+
output.push(args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '));
|
|
32
|
+
};
|
|
33
|
+
const fn = new AsyncFunction(...globalKeys, 'print', `"use strict";\n${code}`);
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
36
|
+
try {
|
|
37
|
+
const result = await Promise.race([
|
|
38
|
+
fn(...globalValues, print),
|
|
39
|
+
new Promise((_, reject) => {
|
|
40
|
+
controller.signal.addEventListener('abort', () =>
|
|
41
|
+
reject(new Error('Execution timed out')));
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
return { success: true, output: output.join('\n'), returnValue: result };
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return { success: false, output: output.join('\n'), error: e.message || String(e) };
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
terminate() {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Data-URI sandbox (dynamic import-based isolation) ──
|
|
56
|
+
|
|
57
|
+
function createDataUriSandbox(opts = {}) {
|
|
58
|
+
const globals = opts.globals || {};
|
|
59
|
+
return {
|
|
60
|
+
async execute(code, execOpts = {}) {
|
|
61
|
+
const timeout = execOpts.timeout || opts.defaultTimeoutMs || 30000;
|
|
62
|
+
const output = [];
|
|
63
|
+
const globalEntries = Object.entries(globals);
|
|
64
|
+
const preamble = globalEntries.length > 0
|
|
65
|
+
? `const { ${globalEntries.map(([k]) => k).join(', ')} } = globalThis.__andbox_globals__;\n`
|
|
66
|
+
: '';
|
|
67
|
+
const wrappedCode = `
|
|
68
|
+
const __globals__ = globalThis.__andbox_globals__;
|
|
69
|
+
const print = globalThis.__andbox_print__;
|
|
70
|
+
delete globalThis.__andbox_globals__;
|
|
71
|
+
delete globalThis.__andbox_print__;
|
|
72
|
+
${preamble}${code}`;
|
|
73
|
+
const blob = new Blob([wrappedCode], { type: 'text/javascript' });
|
|
74
|
+
const url = URL.createObjectURL(blob);
|
|
75
|
+
const print = (...args) => {
|
|
76
|
+
output.push(args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '));
|
|
77
|
+
};
|
|
78
|
+
globalThis.__andbox_globals__ = globals;
|
|
79
|
+
globalThis.__andbox_print__ = print;
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
82
|
+
try {
|
|
83
|
+
const result = await Promise.race([
|
|
84
|
+
import(url),
|
|
85
|
+
new Promise((_, reject) => {
|
|
86
|
+
controller.signal.addEventListener('abort', () =>
|
|
87
|
+
reject(new Error('Execution timed out')));
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
return { success: true, output: output.join('\n'), returnValue: result?.default };
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return { success: false, output: output.join('\n'), error: e.message || String(e) };
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
URL.revokeObjectURL(url);
|
|
96
|
+
delete globalThis.__andbox_globals__;
|
|
97
|
+
delete globalThis.__andbox_print__;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
terminate() {},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {Object} SandboxOptions
|
|
106
|
+
* @property {{ imports?: Record<string,string>, scopes?: Record<string,Record<string,string>> }} [importMap]
|
|
107
|
+
* @property {Record<string, Function>} [capabilities] - Host functions callable via host.call()
|
|
108
|
+
* @property {number} [defaultTimeoutMs] - Default timeout for evaluate()
|
|
109
|
+
* @property {string} [baseURL] - Base URL for relative imports
|
|
110
|
+
* @property {import('./capability-gate.mjs').GatePolicy} [policy] - Rate limiting policy
|
|
111
|
+
* @property {(level: string, ...args: string[]) => void} [onConsole] - Console output handler
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create a new sandboxed runtime.
|
|
116
|
+
*
|
|
117
|
+
* @param {SandboxOptions} [options]
|
|
118
|
+
* @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean }>}
|
|
119
|
+
*/
|
|
120
|
+
export function createSandbox(options = {}) {
|
|
121
|
+
const mode = options.mode || 'worker';
|
|
122
|
+
if (mode === 'inline') return createInlineSandbox(options);
|
|
123
|
+
if (mode === 'data-uri') return createDataUriSandbox(options);
|
|
124
|
+
return createWorkerSandbox(options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function createWorkerSandbox(options = {}) {
|
|
128
|
+
const {
|
|
129
|
+
importMap = { imports: {}, scopes: {} },
|
|
130
|
+
capabilities = {},
|
|
131
|
+
defaultTimeoutMs = DEFAULT_TIMEOUT_MS,
|
|
132
|
+
baseURL = typeof location !== 'undefined' ? location.href : 'https://andbox.local/',
|
|
133
|
+
policy,
|
|
134
|
+
onConsole,
|
|
135
|
+
} = options;
|
|
136
|
+
|
|
137
|
+
// Gate capabilities with rate limits
|
|
138
|
+
const { gated: gatedCaps, stats: gateStats } = gateCapabilities(capabilities, policy);
|
|
139
|
+
|
|
140
|
+
// Console handler — mutable so evaluate() can swap per-call
|
|
141
|
+
let activeConsoleHandler = onConsole || null;
|
|
142
|
+
|
|
143
|
+
// Track virtual modules for re-creation on restart
|
|
144
|
+
const virtualModules = new Map();
|
|
145
|
+
let disposed = false;
|
|
146
|
+
let worker = null;
|
|
147
|
+
let workerBlobURL = null;
|
|
148
|
+
|
|
149
|
+
// Pending evaluations
|
|
150
|
+
const pending = new Map(); // id -> { resolve, reject, timer }
|
|
151
|
+
|
|
152
|
+
// ── Worker lifecycle ──
|
|
153
|
+
|
|
154
|
+
function createWorker() {
|
|
155
|
+
const source = makeWorkerSource();
|
|
156
|
+
const blob = new Blob([source], { type: 'application/javascript' });
|
|
157
|
+
workerBlobURL = URL.createObjectURL(blob);
|
|
158
|
+
worker = new Worker(workerBlobURL, { type: 'classic' });
|
|
159
|
+
|
|
160
|
+
worker.onmessage = ({ data: msg }) => {
|
|
161
|
+
switch (msg.type) {
|
|
162
|
+
case 'configured':
|
|
163
|
+
case 'moduleDefined':
|
|
164
|
+
// Handled by configure/defineModule promises below
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'result': {
|
|
168
|
+
const entry = pending.get(msg.id);
|
|
169
|
+
if (entry) {
|
|
170
|
+
pending.delete(msg.id);
|
|
171
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
172
|
+
if (msg.success) {
|
|
173
|
+
entry.resolve(msg.value);
|
|
174
|
+
} else {
|
|
175
|
+
const err = new Error(msg.error?.message || 'Evaluation failed');
|
|
176
|
+
err.name = msg.error?.name || 'Error';
|
|
177
|
+
entry.reject(err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case 'capabilityCall': {
|
|
184
|
+
handleCapabilityCall(msg.id, msg.name, msg.args);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'console': {
|
|
189
|
+
if (activeConsoleHandler) {
|
|
190
|
+
activeConsoleHandler(msg.level, ...msg.args);
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
worker.onerror = (e) => {
|
|
198
|
+
// Reject all pending on Worker error
|
|
199
|
+
for (const [id, entry] of pending) {
|
|
200
|
+
pending.delete(id);
|
|
201
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
202
|
+
entry.reject(new Error(`Worker error: ${e.message}`));
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function configureWorker() {
|
|
208
|
+
const { promise, resolve } = makeDeferred();
|
|
209
|
+
const handler = ({ data }) => {
|
|
210
|
+
if (data.type === 'configured') {
|
|
211
|
+
worker.removeEventListener('message', handler);
|
|
212
|
+
resolve();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
worker.addEventListener('message', handler);
|
|
216
|
+
worker.postMessage({
|
|
217
|
+
type: 'configure',
|
|
218
|
+
importMap,
|
|
219
|
+
baseURL,
|
|
220
|
+
virtualModules: Object.fromEntries(virtualModules),
|
|
221
|
+
});
|
|
222
|
+
await promise;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function handleCapabilityCall(rpcId, name, args) {
|
|
226
|
+
const fn = gatedCaps[name];
|
|
227
|
+
if (!fn) {
|
|
228
|
+
worker.postMessage({
|
|
229
|
+
type: 'capabilityResult',
|
|
230
|
+
id: rpcId,
|
|
231
|
+
success: false,
|
|
232
|
+
error: `Unknown capability: ${name}`,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const value = await fn(...args);
|
|
238
|
+
worker.postMessage({ type: 'capabilityResult', id: rpcId, success: true, value });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
worker.postMessage({
|
|
241
|
+
type: 'capabilityResult',
|
|
242
|
+
id: rpcId,
|
|
243
|
+
success: false,
|
|
244
|
+
error: e.message || String(e),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function terminateWorker() {
|
|
250
|
+
if (worker) {
|
|
251
|
+
worker.terminate();
|
|
252
|
+
worker = null;
|
|
253
|
+
}
|
|
254
|
+
if (workerBlobURL) {
|
|
255
|
+
URL.revokeObjectURL(workerBlobURL);
|
|
256
|
+
workerBlobURL = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function restartWorker() {
|
|
261
|
+
terminateWorker();
|
|
262
|
+
createWorker();
|
|
263
|
+
await configureWorker();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Public API ──
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Evaluate JavaScript code in the sandbox.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} code - JavaScript code to execute (wrapped in async IIFE).
|
|
272
|
+
* @param {{ timeoutMs?: number, signal?: AbortSignal, onConsole?: (level: string, ...args: string[]) => void }} [opts]
|
|
273
|
+
* @returns {Promise<any>} The return value of the code.
|
|
274
|
+
*/
|
|
275
|
+
async function evaluate(code, opts = {}) {
|
|
276
|
+
if (disposed) throw new Error('Sandbox is disposed');
|
|
277
|
+
if (!worker) await restartWorker();
|
|
278
|
+
|
|
279
|
+
// Random (not sequential) id so code running during one evaluate() call
|
|
280
|
+
// can't guess the id of a concurrent evaluate() on the same worker and
|
|
281
|
+
// forge a matching message to interfere with it.
|
|
282
|
+
const id = crypto.randomUUID();
|
|
283
|
+
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
|
|
284
|
+
const { promise, resolve, reject } = makeDeferred();
|
|
285
|
+
|
|
286
|
+
// Swap console handler for this evaluation if provided
|
|
287
|
+
const prevConsoleHandler = activeConsoleHandler;
|
|
288
|
+
if (opts.onConsole) {
|
|
289
|
+
activeConsoleHandler = opts.onConsole;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let timer = null;
|
|
293
|
+
if (timeoutMs > 0) {
|
|
294
|
+
timer = setTimeout(() => {
|
|
295
|
+
pending.delete(id);
|
|
296
|
+
reject(makeTimeoutError(timeoutMs));
|
|
297
|
+
// Hard kill and restart — only reliable way to stop infinite loops
|
|
298
|
+
restartWorker().catch(() => {});
|
|
299
|
+
}, timeoutMs);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// AbortSignal support
|
|
303
|
+
if (opts.signal) {
|
|
304
|
+
if (opts.signal.aborted) {
|
|
305
|
+
if (timer) clearTimeout(timer);
|
|
306
|
+
throw makeAbortError();
|
|
307
|
+
}
|
|
308
|
+
opts.signal.addEventListener('abort', () => {
|
|
309
|
+
const entry = pending.get(id);
|
|
310
|
+
if (entry) {
|
|
311
|
+
pending.delete(id);
|
|
312
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
313
|
+
entry.reject(makeAbortError());
|
|
314
|
+
restartWorker().catch(() => {});
|
|
315
|
+
}
|
|
316
|
+
}, { once: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
pending.set(id, { resolve, reject, timer });
|
|
320
|
+
worker.postMessage({ type: 'evaluate', id, code });
|
|
321
|
+
|
|
322
|
+
// Restore console handler when evaluation completes
|
|
323
|
+
if (opts.onConsole) {
|
|
324
|
+
return promise.finally(() => { activeConsoleHandler = prevConsoleHandler; });
|
|
325
|
+
}
|
|
326
|
+
return promise;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Define a virtual module accessible via sandboxImport().
|
|
331
|
+
*
|
|
332
|
+
* @param {string} name - Module specifier (e.g. 'std/hello').
|
|
333
|
+
* @param {string} source - Module source code.
|
|
334
|
+
*/
|
|
335
|
+
async function defineModule(name, source) {
|
|
336
|
+
if (disposed) throw new Error('Sandbox is disposed');
|
|
337
|
+
|
|
338
|
+
virtualModules.set(name, source);
|
|
339
|
+
|
|
340
|
+
if (worker) {
|
|
341
|
+
const { promise, resolve } = makeDeferred();
|
|
342
|
+
const handler = ({ data }) => {
|
|
343
|
+
if (data.type === 'moduleDefined' && data.name === name) {
|
|
344
|
+
worker.removeEventListener('message', handler);
|
|
345
|
+
resolve();
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
worker.addEventListener('message', handler);
|
|
349
|
+
worker.postMessage({ type: 'defineModule', name, source });
|
|
350
|
+
await promise;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Terminate the sandbox. Rejects all pending evaluations.
|
|
356
|
+
*/
|
|
357
|
+
async function dispose() {
|
|
358
|
+
if (disposed) return;
|
|
359
|
+
disposed = true;
|
|
360
|
+
|
|
361
|
+
// Reject all pending
|
|
362
|
+
for (const [id, entry] of pending) {
|
|
363
|
+
pending.delete(id);
|
|
364
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
365
|
+
entry.reject(new Error('Sandbox disposed'));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
terminateWorker();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get sandbox stats (gate stats, pending count, etc.).
|
|
373
|
+
*/
|
|
374
|
+
function stats() {
|
|
375
|
+
return {
|
|
376
|
+
disposed,
|
|
377
|
+
pendingEvaluations: pending.size,
|
|
378
|
+
virtualModules: [...virtualModules.keys()],
|
|
379
|
+
gate: gateStats(),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Initialize ──
|
|
384
|
+
createWorker();
|
|
385
|
+
await configureWorker();
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
evaluate,
|
|
389
|
+
defineModule,
|
|
390
|
+
dispose,
|
|
391
|
+
stats,
|
|
392
|
+
isDisposed: () => disposed,
|
|
393
|
+
};
|
|
394
|
+
}
|
package/src/stdio.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async iterable stdout/stderr streams for sandbox console output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create an async iterable stdio stream.
|
|
7
|
+
* Push messages via `push()`, close via `end()`.
|
|
8
|
+
*
|
|
9
|
+
* @returns {{ push: (msg: string) => void, end: () => void, stream: AsyncIterable<string> }}
|
|
10
|
+
*/
|
|
11
|
+
export function createStdio() {
|
|
12
|
+
const buffer = [];
|
|
13
|
+
let resolve = null;
|
|
14
|
+
let done = false;
|
|
15
|
+
|
|
16
|
+
function push(msg) {
|
|
17
|
+
if (done) return;
|
|
18
|
+
if (resolve) {
|
|
19
|
+
const r = resolve;
|
|
20
|
+
resolve = null;
|
|
21
|
+
r({ value: msg, done: false });
|
|
22
|
+
} else {
|
|
23
|
+
buffer.push(msg);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function end() {
|
|
28
|
+
done = true;
|
|
29
|
+
if (resolve) {
|
|
30
|
+
const r = resolve;
|
|
31
|
+
resolve = null;
|
|
32
|
+
r({ value: undefined, done: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const stream = {
|
|
37
|
+
[Symbol.asyncIterator]() {
|
|
38
|
+
return {
|
|
39
|
+
next() {
|
|
40
|
+
if (buffer.length > 0) {
|
|
41
|
+
return Promise.resolve({ value: buffer.shift(), done: false });
|
|
42
|
+
}
|
|
43
|
+
if (done) {
|
|
44
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
45
|
+
}
|
|
46
|
+
return new Promise(r => { resolve = r; });
|
|
47
|
+
},
|
|
48
|
+
return() {
|
|
49
|
+
done = true;
|
|
50
|
+
buffer.length = 0;
|
|
51
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return { push, end, stream };
|
|
58
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker source code template for andbox.
|
|
3
|
+
*
|
|
4
|
+
* Returns a string containing the entire Worker script. Zero file dependencies
|
|
5
|
+
* at runtime — critical for CDN usage. The Worker receives its import map and
|
|
6
|
+
* virtual modules via messages, then evaluates user code with RPC access to
|
|
7
|
+
* host capabilities.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate the Worker source code as a string.
|
|
12
|
+
*
|
|
13
|
+
* The Worker supports the following message types from the host:
|
|
14
|
+
* - `configure`: Set import map, base URL, virtual modules
|
|
15
|
+
* - `defineModule`: Register a virtual module
|
|
16
|
+
* - `evaluate`: Execute user code with timeout + RPC
|
|
17
|
+
* - `dispose`: Clean up and close
|
|
18
|
+
*
|
|
19
|
+
* From the Worker to the host:
|
|
20
|
+
* - `configured`: Ack after configure
|
|
21
|
+
* - `moduleDefined`: Ack after defineModule
|
|
22
|
+
* - `result`: Evaluation result (success or error)
|
|
23
|
+
* - `capabilityCall`: RPC request to host capability
|
|
24
|
+
* - `console`: Forwarded console output
|
|
25
|
+
*
|
|
26
|
+
* @returns {string} The Worker script source code.
|
|
27
|
+
*/
|
|
28
|
+
export function makeWorkerSource() {
|
|
29
|
+
return `
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
// ── State ──
|
|
33
|
+
let importMap = { imports: {}, scopes: {} };
|
|
34
|
+
let baseURL = 'https://andbox.local/';
|
|
35
|
+
const virtualModules = new Map();
|
|
36
|
+
|
|
37
|
+
// ── Import Map Resolver (inlined) ──
|
|
38
|
+
function resolveWithImportMap(specifier, map, parentURL) {
|
|
39
|
+
if (!map) return null;
|
|
40
|
+
if (parentURL && map.scopes) {
|
|
41
|
+
const scopeKeys = Object.keys(map.scopes)
|
|
42
|
+
.filter(scope => parentURL.startsWith(scope))
|
|
43
|
+
.sort((a, b) => b.length - a.length);
|
|
44
|
+
for (const scope of scopeKeys) {
|
|
45
|
+
const r = matchSpec(specifier, map.scopes[scope]);
|
|
46
|
+
if (r !== null) return r;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (map.imports) {
|
|
50
|
+
const r = matchSpec(specifier, map.imports);
|
|
51
|
+
if (r !== null) return r;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchSpec(specifier, mapping) {
|
|
57
|
+
if (mapping[specifier] !== undefined) return mapping[specifier];
|
|
58
|
+
let bestKey = null;
|
|
59
|
+
for (const key of Object.keys(mapping)) {
|
|
60
|
+
if (!key.endsWith('/')) continue;
|
|
61
|
+
if (!specifier.startsWith(key)) continue;
|
|
62
|
+
if (bestKey === null || key.length > bestKey.length) bestKey = key;
|
|
63
|
+
}
|
|
64
|
+
if (bestKey !== null) return mapping[bestKey] + specifier.slice(bestKey.length);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── sandboxImport() — module loader available to user code ──
|
|
69
|
+
async function sandboxImport(specifier) {
|
|
70
|
+
// 1. Virtual module
|
|
71
|
+
if (virtualModules.has(specifier)) {
|
|
72
|
+
const src = virtualModules.get(specifier);
|
|
73
|
+
const blob = new Blob([src], { type: 'application/javascript' });
|
|
74
|
+
const url = URL.createObjectURL(blob);
|
|
75
|
+
try {
|
|
76
|
+
return await import(url);
|
|
77
|
+
} finally {
|
|
78
|
+
URL.revokeObjectURL(url);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. Import map resolution
|
|
83
|
+
const mapped = resolveWithImportMap(specifier, importMap);
|
|
84
|
+
if (mapped) {
|
|
85
|
+
return await import(mapped);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Relative/absolute URL — resolve against baseURL
|
|
89
|
+
if (specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')) {
|
|
90
|
+
const resolved = new URL(specifier, baseURL).href;
|
|
91
|
+
return await import(resolved);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. Absolute URL passthrough
|
|
95
|
+
if (specifier.startsWith('http://') || specifier.startsWith('https://')) {
|
|
96
|
+
return await import(specifier);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(\`Cannot resolve module: \${specifier}. Add it to importMap or defineModule().\`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Capability RPC ──
|
|
103
|
+
const pendingRpc = new Map();
|
|
104
|
+
|
|
105
|
+
function callCapability(name, args) {
|
|
106
|
+
// Random (not sequential) id -- see host-side evaluate() for rationale:
|
|
107
|
+
// predictable ids let concurrent code guess and forge matching messages.
|
|
108
|
+
const id = crypto.randomUUID();
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
pendingRpc.set(id, { resolve, reject });
|
|
111
|
+
self.postMessage({ type: 'capabilityCall', id, name, args });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// host object exposed to user code
|
|
116
|
+
const host = {
|
|
117
|
+
call(name, ...args) {
|
|
118
|
+
return callCapability(name, args);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ── Console Forwarding ──
|
|
123
|
+
const originalConsole = { ...console };
|
|
124
|
+
function makeForwardingConsole(evalId) {
|
|
125
|
+
return new Proxy(console, {
|
|
126
|
+
get(target, prop) {
|
|
127
|
+
if (['log', 'warn', 'error', 'info', 'debug'].includes(prop)) {
|
|
128
|
+
return (...args) => {
|
|
129
|
+
const serialized = args.map(a => {
|
|
130
|
+
try { return typeof a === 'object' ? JSON.stringify(a) : String(a); }
|
|
131
|
+
catch { return String(a); }
|
|
132
|
+
});
|
|
133
|
+
self.postMessage({ type: 'console', evalId, level: prop, args: serialized });
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return target[prop];
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Message Handler ──
|
|
142
|
+
self.onmessage = async ({ data: msg }) => {
|
|
143
|
+
switch (msg.type) {
|
|
144
|
+
case 'configure': {
|
|
145
|
+
if (msg.importMap) importMap = msg.importMap;
|
|
146
|
+
if (msg.baseURL) baseURL = msg.baseURL;
|
|
147
|
+
if (msg.virtualModules) {
|
|
148
|
+
for (const [name, src] of Object.entries(msg.virtualModules)) {
|
|
149
|
+
virtualModules.set(name, src);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
self.postMessage({ type: 'configured' });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'defineModule': {
|
|
157
|
+
virtualModules.set(msg.name, msg.source);
|
|
158
|
+
self.postMessage({ type: 'moduleDefined', name: msg.name });
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'evaluate': {
|
|
163
|
+
const fwdConsole = makeForwardingConsole(msg.id);
|
|
164
|
+
try {
|
|
165
|
+
// Wrap in async function for top-level await
|
|
166
|
+
const asyncFn = new Function(
|
|
167
|
+
'sandboxImport', 'host', 'console',
|
|
168
|
+
\`return (async () => { \${msg.code} })();\`
|
|
169
|
+
);
|
|
170
|
+
const result = await asyncFn(sandboxImport, host, fwdConsole);
|
|
171
|
+
self.postMessage({ type: 'result', id: msg.id, success: true, value: serialize(result) });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
self.postMessage({
|
|
174
|
+
type: 'result',
|
|
175
|
+
id: msg.id,
|
|
176
|
+
success: false,
|
|
177
|
+
error: { message: e.message || String(e), name: e.name || 'Error', stack: e.stack },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case 'capabilityResult': {
|
|
184
|
+
const pending = pendingRpc.get(msg.id);
|
|
185
|
+
if (pending) {
|
|
186
|
+
pendingRpc.delete(msg.id);
|
|
187
|
+
if (msg.success) {
|
|
188
|
+
pending.resolve(msg.value);
|
|
189
|
+
} else {
|
|
190
|
+
pending.reject(new Error(msg.error || 'Capability call failed'));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'dispose': {
|
|
197
|
+
// Reject all pending RPCs
|
|
198
|
+
for (const [id, { reject }] of pendingRpc) {
|
|
199
|
+
reject(new Error('Sandbox disposed'));
|
|
200
|
+
}
|
|
201
|
+
pendingRpc.clear();
|
|
202
|
+
self.close();
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function serialize(value) {
|
|
209
|
+
if (value === undefined) return undefined;
|
|
210
|
+
if (value === null) return null;
|
|
211
|
+
if (typeof value === 'function') return '[Function]';
|
|
212
|
+
try { JSON.stringify(value); return value; }
|
|
213
|
+
catch { return String(value); }
|
|
214
|
+
}
|
|
215
|
+
`;
|
|
216
|
+
}
|