@johnhenry/andbox 0.0.3 → 0.0.5
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 +24 -2
- package/package.json +4 -1
- package/src/index.d.ts +28 -0
- package/src/index.mjs +1 -0
- package/src/node-worker.mjs +93 -0
- package/src/sandbox.mjs +26 -6
package/README.md
CHANGED
|
@@ -85,11 +85,12 @@ await sandbox.dispose();
|
|
|
85
85
|
|
|
86
86
|
## Sandbox Modes
|
|
87
87
|
|
|
88
|
-
andbox supports
|
|
88
|
+
andbox supports five execution modes:
|
|
89
89
|
|
|
90
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).
|
|
91
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.
|
|
92
|
-
- **`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.
|
|
93
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.
|
|
94
95
|
|
|
95
96
|
```js
|
|
@@ -111,6 +112,27 @@ const site = await createSandbox({
|
|
|
111
112
|
// Only navigate into scope *after* this resolves -- see Security model.
|
|
112
113
|
```
|
|
113
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
|
+
|
|
114
136
|
## API
|
|
115
137
|
|
|
116
138
|
### `createSandbox(options?)`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/andbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -41,5 +41,8 @@
|
|
|
41
41
|
"homepage": "https://opensource.johnhenry.me/andbox/",
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=26.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"esbuild": "^0.28.2"
|
|
44
47
|
}
|
|
45
48
|
}
|
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,93 @@
|
|
|
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
|
+
// Non-literal specifier: bundlers (webpack 5 fails on an unresolvable
|
|
89
|
+
// `node:` scheme) must not try to resolve this browser-irrelevant import.
|
|
90
|
+
const specifier = 'node:' + 'worker_threads';
|
|
91
|
+
const { Worker: ThreadWorker } = await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier);
|
|
92
|
+
return (source) => new NodeWebWorker(ThreadWorker, source);
|
|
93
|
+
}
|
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':
|