@johnhenry/andbox 0.0.3 → 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 CHANGED
@@ -85,11 +85,12 @@ await sandbox.dispose();
85
85
 
86
86
  ## Sandbox Modes
87
87
 
88
- andbox supports four execution modes:
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",
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",
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(', ')} } = globalThis.__andbox_globals__;\n`
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
- const blob = new Blob([wrappedCode], { type: 'text/javascript' });
74
- const url = URL.createObjectURL(blob);
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':