@johnhenry/andbox 0.0.2 → 0.0.4
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 +67 -7
- package/package.json +7 -3
- package/src/index.d.ts +28 -0
- package/src/index.mjs +1 -0
- package/src/node-worker.mjs +90 -0
- package/src/sandbox.mjs +26 -6
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# andbox
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@johnhenry/andbox)
|
|
4
|
+
[](https://github.com/johnhenry/andbox/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Full documentation: [opensource.johnhenry.me/andbox](https://opensource.johnhenry.me/andbox/)
|
|
8
|
+
|
|
3
9
|
A separate-context JavaScript runtime with Worker isolation, RPC capabilities, import maps, and timeouts.
|
|
4
10
|
|
|
5
11
|
andbox runs JavaScript in a dedicated Web Worker with a structured bridge back to the host. Code in that Worker can call host-provided "capabilities" via RPC, use import-mapped packages, and define virtual modules -- all with configurable rate limits, timeouts, and hard-kill semantics.
|
|
@@ -8,6 +14,17 @@ andbox runs JavaScript in a dedicated Web Worker with a structured bridge back t
|
|
|
8
14
|
|
|
9
15
|
Zero dependencies. Uses only Web Workers and standard browser APIs.
|
|
10
16
|
|
|
17
|
+
## Contents
|
|
18
|
+
|
|
19
|
+
- [Install](#install)
|
|
20
|
+
- [Quick Start](#quick-start)
|
|
21
|
+
- [Sandbox Modes](#sandbox-modes)
|
|
22
|
+
- [API](#api)
|
|
23
|
+
- [Execution model](#execution-model)
|
|
24
|
+
- [Security model](#security-model)
|
|
25
|
+
- [Family](#family)
|
|
26
|
+
- [License](#license)
|
|
27
|
+
|
|
11
28
|
## Install
|
|
12
29
|
|
|
13
30
|
```bash
|
|
@@ -68,11 +85,12 @@ await sandbox.dispose();
|
|
|
68
85
|
|
|
69
86
|
## Sandbox Modes
|
|
70
87
|
|
|
71
|
-
andbox supports
|
|
88
|
+
andbox supports five execution modes:
|
|
72
89
|
|
|
73
90
|
- **`worker`** (default) -- Runs in a dedicated Worker with an RPC bridge, import maps, virtual modules, and hard-kill timeout semantics. See [Security model](#security-model) for what this does and doesn't protect against.
|
|
91
|
+
- **`node-worker`** -- The `worker` mode on `node:worker_threads`. Selected automatically under Node when there is no global `Worker`; see [Node](#node).
|
|
74
92
|
- **`inline`** -- Same-thread execution via AsyncFunction. Lighter weight, no Worker overhead, no isolation at all -- code runs with full access to the calling context. Only for code you already trust.
|
|
75
|
-
- **`data-uri`** -- Dynamic `import()` via Blob URL. Module-level separation without a Worker. Supports globals injection.
|
|
93
|
+
- **`data-uri`** -- Dynamic `import()` via Blob URL (a `data:` URL under Node). Module-level separation without a Worker. Supports globals injection.
|
|
76
94
|
- **`service-worker`** -- Not code execution at all: registers a Service Worker that serves an in-memory `path → content` map with real HTTP-shaped fetch/navigation semantics. For hosting a small virtual multi-file site (HTML/CSS/JS, arbitrary paths), not for running JS in isolation. See [andbox#14](https://github.com/johnhenry/andbox/issues/14) and [Security model](#security-model) -- this mode does **not** provide isolation by merely existing.
|
|
77
95
|
|
|
78
96
|
```js
|
|
@@ -94,6 +112,27 @@ const site = await createSandbox({
|
|
|
94
112
|
// Only navigate into scope *after* this resolves -- see Security model.
|
|
95
113
|
```
|
|
96
114
|
|
|
115
|
+
## Node
|
|
116
|
+
|
|
117
|
+
From 0.0.4 no shim is needed. Under Node (`engines >= 26`), `createSandbox()` just works:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
import { createSandbox } from '@johnhenry/andbox';
|
|
121
|
+
const sandbox = await createSandbox({ capabilities: { now: () => Date.now() } });
|
|
122
|
+
await sandbox.evaluate('return await host.call("now")');
|
|
123
|
+
await sandbox.dispose(); // worker threads keep the process alive until disposed
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Mode selection.** `mode: 'worker'` (the default) picks `node:worker_threads` when `typeof Worker === 'undefined'` and the runtime is Node; if a global `Worker` exists (browser, or a shim you installed) that is used as before. `mode: 'node-worker'` forces the Node implementation. `workerFactory: (source) => WorkerLike` overrides both; `createNodeWorkerFactory()` is exported for that purpose.
|
|
127
|
+
|
|
128
|
+
**How it works.** The same worker script the browser uses is passed to `new worker_threads.Worker(prelude + source, { eval: true })`. No blob URLs are created. The prelude maps `parentPort` to `self.postMessage`/`onmessage`/`close`, and makes the worker's virtual-module loader mint `data:` URLs (Node cannot `import()` a `blob:` URL). `node:worker_threads` is loaded with a dynamic `import()` so browser bundles never see a `node:` specifier. Timeouts, `AbortSignal`, and `dispose()` hard-kill the thread with `terminate()` and a fresh one is started on the next call.
|
|
129
|
+
|
|
130
|
+
**Not supported / differences.**
|
|
131
|
+
|
|
132
|
+
- `mode: 'service-worker'` needs a browser and rejects under Node.
|
|
133
|
+
- Virtual modules are `data:` modules, so they cannot use *relative* imports of each other; use import maps or `sandboxImport()`. `createVirtualModuleRegistry()` still works as a path table in Node, but its `blob:` URLs cannot be `import()`ed there.
|
|
134
|
+
- A worker thread is not a security boundary: it shares the process, and sandboxed code can reach `process` and `require`. Treat it like the browser Worker mode, which also is not a defense against hostile code (see [Security model](#security-model)); for untrusted code on a server add OS-level isolation.
|
|
135
|
+
|
|
97
136
|
## API
|
|
98
137
|
|
|
99
138
|
### `createSandbox(options?)`
|
|
@@ -266,7 +305,18 @@ Code runs inside a Web Worker created from a Blob URL. This gets you, for free,
|
|
|
266
305
|
|
|
267
306
|
## Security model
|
|
268
307
|
|
|
269
|
-
andbox is **not** a boundary against code that is actively trying to escape it. If you're running code you don't fully trust, read this section
|
|
308
|
+
andbox is **not** a boundary against code that is actively trying to escape it. If you're running code you don't fully trust, read this section before you rely on `capabilities`/`policy`/`createNetworkFetch` for anything.
|
|
309
|
+
|
|
310
|
+
**What andbox guarantees:**
|
|
311
|
+
|
|
312
|
+
- **No DOM access.** Worker-mode code executes in a real Worker global scope, which has no `document`, `window`, or other DOM references -- this is a platform property of Workers, not something andbox has to enforce itself.
|
|
313
|
+
- **No implicit host object references.** Only what you explicitly pass in (`capabilities`, `globals`, import map entries) is reachable from sandboxed code -- ordinary (non-adversarial) code cannot accidentally read or mutate host-side state it wasn't given a reference to.
|
|
314
|
+
- **Hard kill on timeout.** `evaluate()` calls that exceed `timeoutMs` `terminate()` the Worker outright and start a fresh one for the next call -- this is a real process-level kill, not a cooperative cancellation the running code could ignore. See "still yours" below for what a kill does *not* undo.
|
|
315
|
+
- **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor. Previously fixed; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
|
|
316
|
+
- **`createNetworkFetch()`'s allowlist is redirect-safe.** Requests are made with `redirect: 'manual'` and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. Previously fixed; see [andbox#6](https://github.com/johnhenry/andbox/issues/6).
|
|
317
|
+
- **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
|
|
318
|
+
|
|
319
|
+
**What is still yours:**
|
|
270
320
|
|
|
271
321
|
- **Worker-global APIs are directly reachable, regardless of `capabilities`.** Sandboxed code executes in a real Worker global scope, so `fetch`, `WebSocket`, `Worker` (nested workers), `importScripts`, `indexedDB`, and `self.postMessage` are all callable directly -- omitting a `fetch` capability does not block network access. This is fundamental to how `Function`-based evaluation works and isn't fixable without a different execution strategy (e.g. a cross-origin iframe with a strict CSP, or a Realms/Compartments-based approach). See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
|
|
272
322
|
- **`sandboxImport()` will load and execute an arbitrary remote URL.** Any `http(s)://` specifier is passed straight to `import()` with no allowlist, independent of any network policy configured for capabilities. See [andbox#7](https://github.com/johnhenry/andbox/issues/7).
|
|
@@ -274,12 +324,22 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
274
324
|
- **`mode: 'service-worker'` does not provide isolation by merely existing.** It's a hosting mechanism -- a real Service Worker, same-origin by default, serving your `files` map with real fetch/navigation interception. Content served through it can see and touch its own origin exactly like any other same-origin page can; nothing about registering a Service Worker sandboxes what runs inside the pages it serves. If you're hosting content you don't fully trust, point this mode at a genuinely separate origin from day one -- the same recommendation the `fetch`/`WebSocket`/`Worker` item above makes for `worker` mode (a cross-origin iframe with a strict CSP), not something bolted on after the fact. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
|
|
275
325
|
- **The Service Worker does not control the very first navigation into its scope.** A page/iframe navigation into `scope` that happens *before* the registration has finished activating is a normal, unintercepted network request -- Service Workers never retroactively intercept a request that already went out. `createSandbox({ mode: 'service-worker' })`'s returned promise only resolves once the registration is active (its generated script also calls `clients.claim()` on activate, which helps *already-open* clients but not fresh navigations); the documented, load-bearing contract is: don't navigate anything into `scope` until that promise resolves. Do that and every request is intercepted from the first byte, because the registration already matches `scope` before the navigation request is made. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
|
|
276
326
|
|
|
277
|
-
|
|
327
|
+
If you need to run untrusted/adversarial code safely, andbox alone is not sufficient -- pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
|
|
278
328
|
|
|
279
|
-
|
|
280
|
-
- ~~`createNetworkFetch()`'s allowlist was not redirect-safe~~ (it checked the request hostname before the fetch, not the final response URL). Fixed by fetching with `redirect: 'manual'` and rejecting any redirect response outright. See [andbox#6](https://github.com/johnhenry/andbox/issues/6).
|
|
329
|
+
## Family
|
|
281
330
|
|
|
282
|
-
|
|
331
|
+
andbox isn't just a standalone sandbox runtime -- it's the sandboxing engine
|
|
332
|
+
that [aimatey](https://github.com/johnhenry/aimatey) middleware wires in for
|
|
333
|
+
code-based tool execution.
|
|
334
|
+
|
|
335
|
+
- **[`@johnhenry/aimatey-middleware-andbox`](https://github.com/johnhenry/aimatey-middleware-andbox)** --
|
|
336
|
+
wires andbox's `createSandbox()` factory (or a pre-built sandbox) into an
|
|
337
|
+
[aimatey](https://github.com/johnhenry/aimatey) bridge middleware, via
|
|
338
|
+
`toolsToCapabilities()` to convert the middleware's `tools`/`executeToolFn`
|
|
339
|
+
into andbox `capabilities`. This package's own [Security model](#security-model)
|
|
340
|
+
is the source of truth for what that capability gate does and does not
|
|
341
|
+
guarantee -- the middleware's Security model section points back here
|
|
342
|
+
rather than repeating it.
|
|
283
343
|
|
|
284
344
|
## License
|
|
285
345
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/andbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.mjs"
|
|
12
|
+
}
|
|
9
13
|
},
|
|
10
14
|
"files": [
|
|
11
15
|
"src",
|
|
@@ -36,6 +40,6 @@
|
|
|
36
40
|
},
|
|
37
41
|
"homepage": "https://opensource.johnhenry.me/andbox/",
|
|
38
42
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
43
|
+
"node": ">=26.0.0"
|
|
40
44
|
}
|
|
41
45
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -305,8 +305,36 @@ export interface SandboxOptions {
|
|
|
305
305
|
policy?: GatePolicy;
|
|
306
306
|
/** Console output handler. Called when sandboxed code uses console.log/warn/error/etc. */
|
|
307
307
|
onConsole?: (level: string, ...args: string[]) => void;
|
|
308
|
+
/**
|
|
309
|
+
* Worker mode selection. Omitted/`'worker'` uses a Web Worker, or
|
|
310
|
+
* `node:worker_threads` automatically when run under Node with no global
|
|
311
|
+
* `Worker`. `'node-worker'` forces the Node implementation.
|
|
312
|
+
*/
|
|
313
|
+
mode?: 'worker' | 'node-worker';
|
|
314
|
+
/**
|
|
315
|
+
* Supply the Worker implementation: given the worker script source, return
|
|
316
|
+
* a Web-Worker-shaped object. Overrides automatic selection. See
|
|
317
|
+
* `createNodeWorkerFactory()`.
|
|
318
|
+
*/
|
|
319
|
+
workerFactory?: (source: string) => WorkerLike;
|
|
308
320
|
}
|
|
309
321
|
|
|
322
|
+
/** The subset of the Web Worker interface andbox drives. */
|
|
323
|
+
export interface WorkerLike {
|
|
324
|
+
onmessage: ((ev: { data: any }) => void) | null;
|
|
325
|
+
onerror: ((ev: { message?: string }) => void) | null;
|
|
326
|
+
postMessage(message: unknown): void;
|
|
327
|
+
addEventListener(type: 'message', fn: (ev: { data: any }) => void): void;
|
|
328
|
+
removeEventListener(type: 'message', fn: (ev: { data: any }) => void): void;
|
|
329
|
+
terminate(): void;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Node only: load `node:worker_threads` and return a synchronous factory that
|
|
334
|
+
* runs andbox's worker source in a thread. Suitable for `workerFactory`.
|
|
335
|
+
*/
|
|
336
|
+
export declare function createNodeWorkerFactory(): Promise<(source: string) => WorkerLike>;
|
|
337
|
+
|
|
310
338
|
/** Sandbox statistics. */
|
|
311
339
|
export interface SandboxStats {
|
|
312
340
|
disposed: boolean;
|
package/src/index.mjs
CHANGED
|
@@ -15,3 +15,4 @@ export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from '.
|
|
|
15
15
|
export { makeWorkerSource } from './worker-source.mjs';
|
|
16
16
|
export { makeServiceWorkerSource } from './service-worker-source.mjs';
|
|
17
17
|
export { resolveServiceWorkerResponse } from './service-worker-response.mjs';
|
|
18
|
+
export { createNodeWorkerFactory } from './node-worker.mjs';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node adapter: a Web-Worker-shaped object on top of `node:worker_threads`.
|
|
3
|
+
*
|
|
4
|
+
* The browser path builds a Blob URL and passes it to `new Worker(url)`.
|
|
5
|
+
* Node has no global `Worker`, `worker_threads` cannot start from a `blob:`
|
|
6
|
+
* URL, and the ESM loader cannot `import()` one. So in Node the worker
|
|
7
|
+
* source is passed as text with `{ eval: true }` (the thread starts as a
|
|
8
|
+
* CommonJS script, which is all the worker source needs), preceded by a
|
|
9
|
+
* small prelude that maps `parentPort` onto `self.postMessage` /
|
|
10
|
+
* `self.onmessage` / `self.close`, and shims `Blob` + `URL.createObjectURL`
|
|
11
|
+
* *inside the thread only* so the worker's virtual-module loader mints
|
|
12
|
+
* `data:` URLs (which Node can `import()`) instead of `blob:` URLs. The
|
|
13
|
+
* worker source itself is the same string the browser uses.
|
|
14
|
+
*
|
|
15
|
+
* `node:worker_threads` is loaded with a dynamic `import()` so the browser
|
|
16
|
+
* entry never pulls a `node:` specifier into a bundle's static graph.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PRELUDE = `
|
|
20
|
+
const { parentPort } = require('node:worker_threads');
|
|
21
|
+
globalThis.self = globalThis;
|
|
22
|
+
globalThis.postMessage = (m) => parentPort.postMessage(m);
|
|
23
|
+
globalThis.close = () => process.exit(0);
|
|
24
|
+
parentPort.on('message', (data) => {
|
|
25
|
+
try { const r = globalThis.onmessage?.({ data }); r?.catch?.((e) => { throw e; }); }
|
|
26
|
+
catch (e) { parentPort.postMessage({ type: 'error', message: String(e?.message ?? e) }); }
|
|
27
|
+
});
|
|
28
|
+
// import(blob:) is unsupported by Node's ESM loader; data: works.
|
|
29
|
+
globalThis.Blob = class AndboxNodeBlob {
|
|
30
|
+
constructor(parts) { this.text = parts.map(String).join(''); }
|
|
31
|
+
};
|
|
32
|
+
URL.createObjectURL = (b) => 'data:text/javascript;base64,' + Buffer.from(b.text).toString('base64');
|
|
33
|
+
URL.revokeObjectURL = () => {};
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
class NodeWebWorker {
|
|
37
|
+
#thread;
|
|
38
|
+
#listeners = new Set();
|
|
39
|
+
#terminated = false;
|
|
40
|
+
onmessage = null;
|
|
41
|
+
onerror = null;
|
|
42
|
+
|
|
43
|
+
constructor(ThreadWorker, source) {
|
|
44
|
+
const w = new ThreadWorker(PRELUDE + '\n' + source, { eval: true });
|
|
45
|
+
w.on('message', (data) => {
|
|
46
|
+
const ev = { data };
|
|
47
|
+
this.onmessage?.(ev);
|
|
48
|
+
for (const fn of [...this.#listeners]) fn(ev);
|
|
49
|
+
});
|
|
50
|
+
w.on('error', (e) => this.onerror?.({ message: e?.message ?? String(e) }));
|
|
51
|
+
w.on('exit', (code) => {
|
|
52
|
+
if (!this.#terminated && code !== 0) {
|
|
53
|
+
this.onerror?.({ message: `worker exited with code ${code}` });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
this.#thread = w;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
postMessage(m) {
|
|
60
|
+
if (!this.#terminated) this.#thread.postMessage(m);
|
|
61
|
+
}
|
|
62
|
+
addEventListener(type, fn) { if (type === 'message') this.#listeners.add(fn); }
|
|
63
|
+
removeEventListener(type, fn) { if (type === 'message') this.#listeners.delete(fn); }
|
|
64
|
+
terminate() {
|
|
65
|
+
this.#terminated = true;
|
|
66
|
+
this.#listeners.clear();
|
|
67
|
+
this.#thread.terminate();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when running under Node (including with no global `Worker`). */
|
|
72
|
+
export function isNodeRuntime() {
|
|
73
|
+
return typeof process !== 'undefined' && typeof process?.versions?.node === 'string';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load `node:worker_threads` and return a synchronous factory
|
|
78
|
+
* `(workerSource: string) => Worker-like` (onmessage/onerror/postMessage/
|
|
79
|
+
* addEventListener/removeEventListener/terminate). Pass it as the
|
|
80
|
+
* `workerFactory` option of `createSandbox()` to force it explicitly.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<(source: string) => object>}
|
|
83
|
+
*/
|
|
84
|
+
export async function createNodeWorkerFactory() {
|
|
85
|
+
if (!isNodeRuntime()) {
|
|
86
|
+
throw new Error("createNodeWorkerFactory() requires Node (node:worker_threads); in a browser use mode: 'worker'.");
|
|
87
|
+
}
|
|
88
|
+
const { Worker: ThreadWorker } = await import('node:worker_threads');
|
|
89
|
+
return (source) => new NodeWebWorker(ThreadWorker, source);
|
|
90
|
+
}
|
package/src/sandbox.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { makeWorkerSource } from './worker-source.mjs';
|
|
|
14
14
|
import { gateCapabilities } from './capability-gate.mjs';
|
|
15
15
|
import { makeDeferred, makeTimeoutError, makeAbortError } from './deferred.mjs';
|
|
16
16
|
import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
|
|
17
|
+
import { isNodeRuntime, createNodeWorkerFactory } from './node-worker.mjs';
|
|
17
18
|
|
|
18
19
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
19
20
|
|
|
@@ -62,7 +63,7 @@ function createDataUriSandbox(opts = {}) {
|
|
|
62
63
|
const output = [];
|
|
63
64
|
const globalEntries = Object.entries(globals);
|
|
64
65
|
const preamble = globalEntries.length > 0
|
|
65
|
-
? `const { ${globalEntries.map(([k]) => k).join(', ')} } =
|
|
66
|
+
? `const { ${globalEntries.map(([k]) => k).join(', ')} } = __globals__;\n`
|
|
66
67
|
: '';
|
|
67
68
|
const wrappedCode = `
|
|
68
69
|
const __globals__ = globalThis.__andbox_globals__;
|
|
@@ -70,8 +71,11 @@ const print = globalThis.__andbox_print__;
|
|
|
70
71
|
delete globalThis.__andbox_globals__;
|
|
71
72
|
delete globalThis.__andbox_print__;
|
|
72
73
|
${preamble}${code}`;
|
|
73
|
-
|
|
74
|
-
const
|
|
74
|
+
// Node's ESM loader cannot import() a blob: URL; it can a data: URL.
|
|
75
|
+
const nodeRuntime = isNodeRuntime();
|
|
76
|
+
const url = nodeRuntime
|
|
77
|
+
? 'data:text/javascript;base64,' + Buffer.from(wrappedCode).toString('base64')
|
|
78
|
+
: URL.createObjectURL(new Blob([wrappedCode], { type: 'text/javascript' }));
|
|
75
79
|
const print = (...args) => {
|
|
76
80
|
output.push(args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '));
|
|
77
81
|
};
|
|
@@ -92,7 +96,7 @@ ${preamble}${code}`;
|
|
|
92
96
|
return { success: false, output: output.join('\n'), error: e.message || String(e) };
|
|
93
97
|
} finally {
|
|
94
98
|
clearTimeout(timer);
|
|
95
|
-
URL.revokeObjectURL(url);
|
|
99
|
+
if (!nodeRuntime) URL.revokeObjectURL(url);
|
|
96
100
|
delete globalThis.__andbox_globals__;
|
|
97
101
|
delete globalThis.__andbox_print__;
|
|
98
102
|
}
|
|
@@ -314,10 +318,10 @@ export function createSandbox(options = {}) {
|
|
|
314
318
|
if (mode === 'inline') return createInlineSandbox(options);
|
|
315
319
|
if (mode === 'data-uri') return createDataUriSandbox(options);
|
|
316
320
|
if (mode === 'service-worker') return createServiceWorkerSandbox(options);
|
|
317
|
-
return createWorkerSandbox(options);
|
|
321
|
+
return createWorkerSandbox(options, mode === 'node-worker');
|
|
318
322
|
}
|
|
319
323
|
|
|
320
|
-
async function createWorkerSandbox(options = {}) {
|
|
324
|
+
async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
321
325
|
const {
|
|
322
326
|
importMap = { imports: {}, scopes: {} },
|
|
323
327
|
capabilities = {},
|
|
@@ -327,6 +331,14 @@ async function createWorkerSandbox(options = {}) {
|
|
|
327
331
|
onConsole,
|
|
328
332
|
} = options;
|
|
329
333
|
|
|
334
|
+
// Node mode: no global Worker (and no blob: worker URLs) -> node:worker_threads.
|
|
335
|
+
// An explicit workerFactory always wins; 'node-worker' forces Node; the
|
|
336
|
+
// default selects it only when there is no global Worker under Node.
|
|
337
|
+
let workerFactory = options.workerFactory || null;
|
|
338
|
+
if (!workerFactory && (forceNode || (typeof Worker === 'undefined' && isNodeRuntime()))) {
|
|
339
|
+
workerFactory = await createNodeWorkerFactory();
|
|
340
|
+
}
|
|
341
|
+
|
|
330
342
|
// Gate capabilities with rate limits
|
|
331
343
|
const { gated: gatedCaps, stats: gateStats } = gateCapabilities(capabilities, policy);
|
|
332
344
|
|
|
@@ -346,10 +358,18 @@ async function createWorkerSandbox(options = {}) {
|
|
|
346
358
|
|
|
347
359
|
function createWorker() {
|
|
348
360
|
const source = makeWorkerSource();
|
|
361
|
+
if (workerFactory) {
|
|
362
|
+
worker = workerFactory(source);
|
|
363
|
+
attachWorkerHandlers();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
349
366
|
const blob = new Blob([source], { type: 'application/javascript' });
|
|
350
367
|
workerBlobURL = URL.createObjectURL(blob);
|
|
351
368
|
worker = new Worker(workerBlobURL, { type: 'classic' });
|
|
369
|
+
attachWorkerHandlers();
|
|
370
|
+
}
|
|
352
371
|
|
|
372
|
+
function attachWorkerHandlers() {
|
|
353
373
|
worker.onmessage = ({ data: msg }) => {
|
|
354
374
|
switch (msg.type) {
|
|
355
375
|
case 'configured':
|