@johnhenry/andbox 0.0.5 → 0.0.6
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 +41 -8
- package/package.json +4 -2
- package/src/index.d.ts +56 -3
- package/src/node-vfs.mjs +181 -0
- package/src/node-worker.mjs +78 -6
- package/src/sandbox.mjs +59 -16
- package/src/virtual-module-registry.mjs +47 -5
- package/src/worker-source.mjs +5 -0
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ await sandbox.dispose();
|
|
|
85
85
|
|
|
86
86
|
## Sandbox Modes
|
|
87
87
|
|
|
88
|
-
andbox supports five execution modes:
|
|
88
|
+
andbox supports five execution modes (any other `mode` throws an error listing these):
|
|
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
91
|
- **`node-worker`** -- The `worker` mode on `node:worker_threads`. Selected automatically under Node when there is no global `Worker`; see [Node](#node).
|
|
@@ -125,13 +125,42 @@ await sandbox.dispose(); // worker threads keep the process alive until disposed
|
|
|
125
125
|
|
|
126
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
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
|
|
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 installs an in-thread module loader hook (`module.registerHooks`) that serves virtual modules from memory (see below). `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
|
+
**Virtual modules can import each other (0.0.6).** `defineModule()` modules are served under `andbox-vfs://` URLs, so they can import one another relatively (`./b`, `../lib/c.js`), by bare name (`import x from "lib/x"`), through the sandbox `importMap`, and cyclically. Extensions are optional (`./b`, `./b.js`, `./b/index.js` all find `b`). Redefining a module (or a dependency) takes effect on the next `sandboxImport()`. Difference from the browser: modules are cached per definition, so repeated `sandboxImport(name)` returns the same module instance until it is redefined. `createVirtualModuleRegistry()` uses the same mechanism under Node (see its section).
|
|
131
|
+
|
|
132
|
+
**Process lifetime.** A live worker thread keeps the host process alive until `dispose()` (confirmed by test: no `dispose()` and the process never exits; after `dispose()` it exits). Pass `unref: true` to let the process exit while the sandbox is *idle*: the thread is unref'd between calls and ref'd again while a startup, `evaluate()` or `defineModule()` is in flight, so an `await sandbox.evaluate(...)` is never abandoned. Default `false`. (Ignored with a browser Worker.)
|
|
133
|
+
|
|
134
|
+
**Hardening (opt-in): `nodeWorker`.** By default the thread behaves like any `worker_threads` thread: it gets a copy of `process.env`, no memory cap, and full access to `process`, `fs`, `child_process`, etc. These options tighten that:
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
const sandbox = await createSandbox({
|
|
138
|
+
nodeWorker: {
|
|
139
|
+
permissions: true, // see below
|
|
140
|
+
// env: { MODE: 'x' }, // explicit env; with permissions the default is {}
|
|
141
|
+
// maxMemoryMb: 128, // heap cap (default 256 with permissions: true)
|
|
142
|
+
// resourceLimits: {...}, // raw worker_threads resourceLimits, merged on top
|
|
143
|
+
// execArgv: [...], // extra thread flags, e.g. '--allow-fs-read=/data'
|
|
144
|
+
// captureStdio: true, // route thread stdout/stderr to onConsole('stdout'|'stderr', text)
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`permissions: true` does all of the following to the thread:
|
|
150
|
+
|
|
151
|
+
- **Permission model.** Spawns it with `execArgv: ['--permission']` (this works per-thread in Node 26 even though the flag is documented process-wide). Verified by test: `fs` reads and `child_process` are denied with `ERR_ACCESS_DENIED`, Nested workers and native addons are not granted (`--allow-worker` / `--allow-addons` are not passed), though only the `fs` and `child_process` denials are covered by tests. Grant specific paths with `execArgv: ['--allow-fs-read=/dir']`.
|
|
152
|
+
- **Isolated `env`.** `{}` (or the object you pass) instead of a copy of the host's environment, so secrets in `process.env` are not visible.
|
|
153
|
+
- **Memory cap.** `resourceLimits.maxOldGenerationSizeMb` (default 256, or `maxMemoryMb`). An allocation loop kills the thread with an out-of-memory error, the pending `evaluate()` rejects, and the next call starts a fresh thread. (andbox has no other memory-limit option, so `maxMemoryMb` is new here; `policy.limits` only governs capability calls.)
|
|
154
|
+
- **Stripped `process`.** Deletes `process.binding`, `_linkedBinding`, `getBuiltinModule`, `dlopen`, `kill`, `abort`, `reallyExit`, `mainModule`.
|
|
155
|
+
- **Blocked host imports.** A resolve hook rejects any `import()` / `sandboxImport()` that resolves to a `node:` or `file:` URL (`import('node:fs')`, `import('fs')`, `import('file:///...')`). `data:`, `http(s):` and virtual modules still work.
|
|
156
|
+
- **Captured stdio.** Thread `process.stdout/stderr` writes go to `onConsole` instead of the host terminal.
|
|
157
|
+
|
|
158
|
+
**What this does *not* prevent.** The thread is still in your process. `permissions` does not restrict the network (`fetch`, `WebSocket`, `net` via any route that remains), CPU use (use `timeoutMs`), or anything that exploits a bug in V8 or Node; the `process` stripping and import hook are denylists, not a proof, and a determined attacker may find another path to a host capability. It only reduces the blast radius of ordinary and semi-hostile code. See [Security model](#security-model). The options throw if combined with a browser Worker or a custom `workerFactory`, rather than silently doing nothing.
|
|
129
159
|
|
|
130
160
|
**Not supported / differences.**
|
|
131
161
|
|
|
132
162
|
- `mode: 'service-worker'` needs a browser and rejects under Node.
|
|
133
|
-
-
|
|
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.
|
|
163
|
+
- **A worker thread is not a security boundary** -- not by default and not with `nodeWorker.permissions`. By default sandboxed code can reach `process` (including `process.env`, `process.binding`, `process.getBuiltinModule('fs')`) and can `import('node:child_process')`. For untrusted code on a server add OS-level isolation (a separate process or container with its own filesystem, network and resource limits).
|
|
135
164
|
|
|
136
165
|
## API
|
|
137
166
|
|
|
@@ -204,7 +233,7 @@ Resolves a module specifier against an import map, following the browser import
|
|
|
204
233
|
|
|
205
234
|
### `createVirtualModuleRegistry(files?, options?)`
|
|
206
235
|
|
|
207
|
-
Takes a `path → source` map and mints one real `blob:` URL
|
|
236
|
+
Takes a `path → source` map and mints one importable URL per entry: a real `blob:` URL in a browser (the same `new Blob([...]) + URL.createObjectURL()` pattern `data-uri` mode uses, generalized to a whole file table), or, under Node (which cannot `import()` a `blob:` URL), an `andbox-vfs://<id>/<path>?v=<n>` URL served by an in-thread module hook. `options.backend` is `'auto'` (default), `'blob'` or `'node'`. Returns a registry for a multi-file tree that references itself by relative path -- an entry module `import`-ing `./util.js`, which itself imports `../shared/x.js`, and so on.
|
|
208
237
|
|
|
209
238
|
```js
|
|
210
239
|
import { createVirtualModuleRegistry } from '@johnhenry/andbox';
|
|
@@ -214,15 +243,18 @@ const registry = createVirtualModuleRegistry({
|
|
|
214
243
|
'util.js': 'export function add(a, b) { return a + b; }',
|
|
215
244
|
});
|
|
216
245
|
|
|
217
|
-
registry.resolve('index.js'); //
|
|
246
|
+
registry.resolve('index.js'); // importable URL, or null if unknown
|
|
247
|
+
registry.source('index.js'); // the registered source text
|
|
218
248
|
registry.resolveSpecifier('./util.js', 'index.js'); // resolves relative to the importing file
|
|
219
249
|
registry.define('extra.js', 'export const x = 1;'); // register (or replace) a file at runtime
|
|
220
|
-
registry.dispose(); // revokes
|
|
250
|
+
registry.dispose(); // revokes blob URLs / drops the Node files
|
|
221
251
|
```
|
|
222
252
|
|
|
223
253
|
`resolveSpecifier(specifier, parentPath?)` checks, in order: (1) import-map resolution via `resolveWithImportMap()` -- not reimplemented, just delegated, with `parentPath`'s own blob URL passed through as `parentURL` so `scopes` apply; (2) a relative-path (`./`, `../`) fallback looked up against the registry's own file table, which `resolveWithImportMap()` alone has no notion of; (3) `null` if neither matched, e.g. a genuine external bare specifier -- left for the caller to handle, not swallowed.
|
|
224
254
|
|
|
225
|
-
**
|
|
255
|
+
**Under Node, relative imports just work.** `await import(registry.resolve('index.js'))` runs `index.js`, whose `./util.js` import is resolved by the hook (relative, bare-name, import map, cycles, and extensionless/`index.js` lookups). Redefine a file with `define()` and import the new URL it returns.
|
|
256
|
+
|
|
257
|
+
**Honest caveat (browser / `blob:` backend):** this only solves module *resolution* (the URL a given specifier should point at), not automatic rewriting of `import` statements inside the source text, and not general asset URLs (`<img src>`, CSS `url()`) or navigation. A `blob:` URL has no hierarchical path of its own, so a literal `import "./util.js"` statement inside blob-served source will **not** resolve on its own in a browser -- call `resolveSpecifier()` yourself with the specifier your loader saw and use the URL it returns (or rewrite the specifier to that URL before creating the blob). See [andbox#13](https://github.com/johnhenry/andbox/issues/13) for the full design discussion, and [andbox#14](https://github.com/johnhenry/andbox/issues/14) for the harder "make it behave like a real server" problem this deliberately does not attempt to solve.
|
|
226
258
|
|
|
227
259
|
### `createNetworkFetch(allowedHosts?, fetchFn?)`
|
|
228
260
|
|
|
@@ -311,6 +343,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
311
343
|
|
|
312
344
|
- **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
345
|
- **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.
|
|
346
|
+
- **(Node) A worker thread is not a security boundary.** It shares the process with the host; by default it inherits `process.env` and can reach `process`, `fs`, and `child_process`. The opt-in `nodeWorker.permissions` hardening (see [Node](#node)) adds the permission model, an isolated env, a memory cap and import blocking, which raises the cost of casual abuse but does not make the thread a boundary.
|
|
314
347
|
- **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
348
|
- **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
349
|
- **`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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/andbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"node": ">=26.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"esbuild": "^0.28.2"
|
|
46
|
+
"esbuild": "^0.28.2",
|
|
47
|
+
"vite": "^8.3.3",
|
|
48
|
+
"webpack": "^5.111.1"
|
|
47
49
|
}
|
|
48
50
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -135,7 +135,11 @@ export declare function resolveWithImportMap(
|
|
|
135
135
|
|
|
136
136
|
// ── virtual-module-registry ──
|
|
137
137
|
|
|
138
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* A registry of in-memory files: each backed by its own `blob:` URL in a
|
|
140
|
+
* browser, or an importable `andbox-vfs:` URL under Node (where files can
|
|
141
|
+
* import each other relatively).
|
|
142
|
+
*/
|
|
139
143
|
export interface VirtualModuleRegistry {
|
|
140
144
|
/**
|
|
141
145
|
* Get the `blob:` URL registered for a path.
|
|
@@ -165,6 +169,9 @@ export interface VirtualModuleRegistry {
|
|
|
165
169
|
*/
|
|
166
170
|
define(path: string, source: string): string;
|
|
167
171
|
|
|
172
|
+
/** The source text registered for a path, or null. Works in every runtime. */
|
|
173
|
+
source(path: string): string | null;
|
|
174
|
+
|
|
168
175
|
/** Whether a path is registered. */
|
|
169
176
|
has(path: string): boolean;
|
|
170
177
|
|
|
@@ -188,7 +195,11 @@ export interface VirtualModuleRegistry {
|
|
|
188
195
|
*/
|
|
189
196
|
export declare function createVirtualModuleRegistry(
|
|
190
197
|
files?: Record<string, string>,
|
|
191
|
-
options?: {
|
|
198
|
+
options?: {
|
|
199
|
+
importMap?: ImportMap;
|
|
200
|
+
/** `'auto'` (default): Node hooks under Node, `blob:` elsewhere. */
|
|
201
|
+
backend?: 'auto' | 'blob' | 'node';
|
|
202
|
+
},
|
|
192
203
|
): VirtualModuleRegistry;
|
|
193
204
|
|
|
194
205
|
// ── network-policy ──
|
|
@@ -311,12 +322,52 @@ export interface SandboxOptions {
|
|
|
311
322
|
* `Worker`. `'node-worker'` forces the Node implementation.
|
|
312
323
|
*/
|
|
313
324
|
mode?: 'worker' | 'node-worker';
|
|
325
|
+
// Any other value throws: supported modes are 'worker', 'node-worker', 'inline', 'data-uri', 'service-worker'.
|
|
314
326
|
/**
|
|
315
327
|
* Supply the Worker implementation: given the worker script source, return
|
|
316
328
|
* a Web-Worker-shaped object. Overrides automatic selection. See
|
|
317
329
|
* `createNodeWorkerFactory()`.
|
|
318
330
|
*/
|
|
319
331
|
workerFactory?: (source: string) => WorkerLike;
|
|
332
|
+
/**
|
|
333
|
+
* Node worker_threads options and opt-in hardening. Only valid with the
|
|
334
|
+
* built-in Node mode (throws with a browser Worker or custom workerFactory).
|
|
335
|
+
* A worker thread is NOT a security boundary even with `permissions: true`.
|
|
336
|
+
*/
|
|
337
|
+
nodeWorker?: NodeWorkerOptions;
|
|
338
|
+
/**
|
|
339
|
+
* Node only (default false). When true the thread is unref'd while the
|
|
340
|
+
* sandbox is idle so a forgotten sandbox does not keep the process alive;
|
|
341
|
+
* it stays ref'd during startup, evaluate() and defineModule(). Without it
|
|
342
|
+
* a live sandbox keeps the process alive until dispose().
|
|
343
|
+
*/
|
|
344
|
+
unref?: boolean;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Options for the built-in Node worker_threads mode (`nodeWorker`). */
|
|
348
|
+
export interface NodeWorkerOptions {
|
|
349
|
+
/**
|
|
350
|
+
* Spawn the thread with Node's permission model (`--permission`), an
|
|
351
|
+
* isolated `env` (`{}` unless `env` is given), a default 256 MB heap cap,
|
|
352
|
+
* stripped `process` escapes, blocked `node:`/`file:` imports and captured
|
|
353
|
+
* stdio. Does not restrict network or CPU.
|
|
354
|
+
*/
|
|
355
|
+
permissions?: boolean;
|
|
356
|
+
/** Environment for the thread. Default: a copy of `process.env` (or `{}` with `permissions`). */
|
|
357
|
+
env?: Record<string, string>;
|
|
358
|
+
/** Heap cap in megabytes (`resourceLimits.maxOldGenerationSizeMb`). */
|
|
359
|
+
maxMemoryMb?: number;
|
|
360
|
+
/** Raw `worker_threads` resourceLimits, merged over `maxMemoryMb`. */
|
|
361
|
+
resourceLimits?: {
|
|
362
|
+
maxOldGenerationSizeMb?: number;
|
|
363
|
+
maxYoungGenerationSizeMb?: number;
|
|
364
|
+
codeRangeSizeMb?: number;
|
|
365
|
+
stackSizeMb?: number;
|
|
366
|
+
};
|
|
367
|
+
/** Extra thread `execArgv`, e.g. `['--allow-fs-read=/data']`. */
|
|
368
|
+
execArgv?: string[];
|
|
369
|
+
/** Route thread stdout/stderr to `onConsole('stdout' | 'stderr', text)`. Default: true with `permissions`. */
|
|
370
|
+
captureStdio?: boolean;
|
|
320
371
|
}
|
|
321
372
|
|
|
322
373
|
/** The subset of the Web Worker interface andbox drives. */
|
|
@@ -333,7 +384,9 @@ export interface WorkerLike {
|
|
|
333
384
|
* Node only: load `node:worker_threads` and return a synchronous factory that
|
|
334
385
|
* runs andbox's worker source in a thread. Suitable for `workerFactory`.
|
|
335
386
|
*/
|
|
336
|
-
export declare function createNodeWorkerFactory(
|
|
387
|
+
export declare function createNodeWorkerFactory(
|
|
388
|
+
options?: NodeWorkerOptions,
|
|
389
|
+
): Promise<(source: string) => WorkerLike>;
|
|
337
390
|
|
|
338
391
|
/** Sandbox statistics. */
|
|
339
392
|
export interface SandboxStats {
|
package/src/node-vfs.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node virtual filesystem for ES modules.
|
|
3
|
+
*
|
|
4
|
+
* Node's ESM loader cannot `import()` a `blob:` URL, and a `data:` URL has
|
|
5
|
+
* no hierarchical base, so two virtual modules can never import each other
|
|
6
|
+
* relatively through either. This installs in-thread module customization
|
|
7
|
+
* hooks (`module.registerHooks`, synchronous, same thread) that serve an
|
|
8
|
+
* in-memory file table under a hierarchical custom scheme:
|
|
9
|
+
*
|
|
10
|
+
* andbox-vfs://<namespace>/<path>?v=<version>
|
|
11
|
+
*
|
|
12
|
+
* Relative specifiers (`./b.js`, `../c`) resolve against that base natively,
|
|
13
|
+
* bare specifiers are looked up as other virtual module names and then in a
|
|
14
|
+
* per-namespace import map, and cycles work as they do for real files.
|
|
15
|
+
*
|
|
16
|
+
* `installNodeVfs` is deliberately self-contained (no free variables) so the
|
|
17
|
+
* Node worker adapter can inline `installNodeVfs.toString()` into the thread
|
|
18
|
+
* prelude and share one implementation between the host and the thread.
|
|
19
|
+
*
|
|
20
|
+
* @param {(hooks: object) => unknown} registerHooks `module.registerHooks`
|
|
21
|
+
*/
|
|
22
|
+
export function installNodeVfs(registerHooks) {
|
|
23
|
+
const KEY = Symbol.for('andbox.vfs');
|
|
24
|
+
if (globalThis[KEY]) return globalThis[KEY];
|
|
25
|
+
|
|
26
|
+
const SCHEME = 'andbox-vfs:';
|
|
27
|
+
const spaces = new Map(); // ns -> { files: Map(name -> {v, src}), hist: Map, importMap }
|
|
28
|
+
let counter = 0;
|
|
29
|
+
let denyHostResources = false;
|
|
30
|
+
|
|
31
|
+
function space(ns, create) {
|
|
32
|
+
let s = spaces.get(ns);
|
|
33
|
+
if (!s && create) {
|
|
34
|
+
s = { files: new Map(), hist: new Map(), importMap: null };
|
|
35
|
+
spaces.set(ns, s);
|
|
36
|
+
}
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function urlFor(ns, name, v) {
|
|
41
|
+
return `andbox-vfs://${ns}/${name.split('/').map(encodeURIComponent).join('/')}?v=${v}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parse(url) {
|
|
45
|
+
const u = new URL(url);
|
|
46
|
+
return {
|
|
47
|
+
ns: u.host,
|
|
48
|
+
name: decodeURIComponent(u.pathname.slice(1)),
|
|
49
|
+
v: u.searchParams.get('v'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lookup(ns, name) {
|
|
54
|
+
const s = spaces.get(ns);
|
|
55
|
+
if (!s) return null;
|
|
56
|
+
const bare = name.replace(/\.m?js$/, '');
|
|
57
|
+
for (const cand of [name, name + '.js', name + '.mjs', name + '/index.js', bare]) {
|
|
58
|
+
const f = s.files.get(cand);
|
|
59
|
+
if (f) return { name: cand, v: f.v };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function matchSpec(specifier, mapping) {
|
|
65
|
+
if (mapping[specifier] !== undefined) return mapping[specifier];
|
|
66
|
+
let best = null;
|
|
67
|
+
for (const key of Object.keys(mapping)) {
|
|
68
|
+
if (key.endsWith('/') && specifier.startsWith(key) && (best === null || key.length > best.length)) best = key;
|
|
69
|
+
}
|
|
70
|
+
return best === null ? null : mapping[best] + specifier.slice(best.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mapSpecifier(map, specifier, parentURL) {
|
|
74
|
+
if (!map) return null;
|
|
75
|
+
if (map.scopes) {
|
|
76
|
+
const scopes = Object.keys(map.scopes)
|
|
77
|
+
.filter((k) => parentURL.startsWith(k))
|
|
78
|
+
.sort((a, b) => b.length - a.length);
|
|
79
|
+
for (const k of scopes) {
|
|
80
|
+
const r = matchSpec(specifier, map.scopes[k]);
|
|
81
|
+
if (r !== null) return r;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return map.imports ? matchSpec(specifier, map.imports) : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkDenied(url, specifier) {
|
|
88
|
+
if (denyHostResources && (url.startsWith('node:') || url.startsWith('file:'))) {
|
|
89
|
+
const e = new Error(`Import of '${specifier}' is blocked in a hardened andbox sandbox`);
|
|
90
|
+
e.code = 'ERR_ANDBOX_BLOCKED';
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
registerHooks({
|
|
96
|
+
resolve(specifier, context, nextResolve) {
|
|
97
|
+
const parent = context.parentURL;
|
|
98
|
+
if (specifier.startsWith(SCHEME)) {
|
|
99
|
+
const p = parse(specifier);
|
|
100
|
+
const hit = lookup(p.ns, p.name);
|
|
101
|
+
if (!hit) throw new Error(`Cannot find virtual module '${p.name}'`);
|
|
102
|
+
return {
|
|
103
|
+
url: urlFor(p.ns, hit.name, hit.v),
|
|
104
|
+
format: 'module',
|
|
105
|
+
shortCircuit: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (typeof parent === 'string' && parent.startsWith(SCHEME)) {
|
|
109
|
+
const pp = parse(parent);
|
|
110
|
+
const relative = /^(\.\.?)?\//.test(specifier);
|
|
111
|
+
if (relative) {
|
|
112
|
+
const name = decodeURIComponent(new URL(specifier, parent).pathname.slice(1));
|
|
113
|
+
const hit = lookup(pp.ns, name);
|
|
114
|
+
if (!hit) throw new Error(`Cannot resolve '${specifier}' from virtual module '${pp.name}'`);
|
|
115
|
+
return { url: urlFor(pp.ns, hit.name, hit.v), format: 'module', shortCircuit: true };
|
|
116
|
+
}
|
|
117
|
+
const hit = lookup(pp.ns, specifier);
|
|
118
|
+
if (hit) return { url: urlFor(pp.ns, hit.name, hit.v), format: 'module', shortCircuit: true };
|
|
119
|
+
const mapped = mapSpecifier(spaces.get(pp.ns)?.importMap, specifier, parent);
|
|
120
|
+
if (mapped !== null) {
|
|
121
|
+
checkDenied(mapped, specifier);
|
|
122
|
+
return nextResolve(mapped, context);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const r = nextResolve(specifier, context);
|
|
126
|
+
checkDenied(r.url, specifier);
|
|
127
|
+
return r;
|
|
128
|
+
},
|
|
129
|
+
load(url, context, nextLoad) {
|
|
130
|
+
if (!url.startsWith(SCHEME)) return nextLoad(url, context);
|
|
131
|
+
const p = parse(url);
|
|
132
|
+
const src = spaces.get(p.ns)?.hist.get(p.name + '\0' + p.v);
|
|
133
|
+
if (src === undefined) throw new Error(`Virtual module '${p.name}' is not available (disposed?)`);
|
|
134
|
+
return { format: 'module', source: src, shortCircuit: true };
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const api = {
|
|
139
|
+
/** Define/replace a file; returns its current URL. Unchanged source keeps its version. */
|
|
140
|
+
define(ns, name, source) {
|
|
141
|
+
const s = space(ns, true);
|
|
142
|
+
const cur = s.files.get(name);
|
|
143
|
+
if (cur && cur.src === source) return urlFor(ns, name, cur.v);
|
|
144
|
+
const v = ++counter;
|
|
145
|
+
s.files.set(name, { v, src: source });
|
|
146
|
+
s.hist.set(name + '\0' + v, source);
|
|
147
|
+
return urlFor(ns, name, v);
|
|
148
|
+
},
|
|
149
|
+
urlOf(ns, name) {
|
|
150
|
+
const f = spaces.get(ns)?.files.get(name);
|
|
151
|
+
return f ? urlFor(ns, name, f.v) : null;
|
|
152
|
+
},
|
|
153
|
+
sourceOf(ns, name) {
|
|
154
|
+
return spaces.get(ns)?.files.get(name)?.src ?? null;
|
|
155
|
+
},
|
|
156
|
+
has: (ns, name) => !!spaces.get(ns)?.files.has(name),
|
|
157
|
+
names: (ns) => [...(spaces.get(ns)?.files.keys() ?? [])],
|
|
158
|
+
setImportMap(ns, map) {
|
|
159
|
+
space(ns, true).importMap = map || null;
|
|
160
|
+
},
|
|
161
|
+
dispose(ns) {
|
|
162
|
+
spaces.delete(ns);
|
|
163
|
+
},
|
|
164
|
+
/** From now on, imports resolving to `node:` or `file:` URLs throw. */
|
|
165
|
+
denyHostResources() {
|
|
166
|
+
denyHostResources = true;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
globalThis[KEY] = api;
|
|
170
|
+
return api;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The host-thread VFS, installed lazily (and only under Node, via the
|
|
175
|
+
* synchronous `process.getBuiltinModule`, which keeps `node:module` out of
|
|
176
|
+
* bundler graphs). Returns null where hooks are unavailable (browsers).
|
|
177
|
+
*/
|
|
178
|
+
export function getHostNodeVfs() {
|
|
179
|
+
const registerHooks = globalThis.process?.getBuiltinModule?.('node:module')?.registerHooks;
|
|
180
|
+
return typeof registerHooks === 'function' ? installNodeVfs(registerHooks) : null;
|
|
181
|
+
}
|
package/src/node-worker.mjs
CHANGED
|
@@ -12,15 +12,39 @@
|
|
|
12
12
|
* `data:` URLs (which Node can `import()`) instead of `blob:` URLs. The
|
|
13
13
|
* worker source itself is the same string the browser uses.
|
|
14
14
|
*
|
|
15
|
+
* Virtual modules in the thread are served by `node-vfs.mjs` (in-thread
|
|
16
|
+
* `module.registerHooks`), so they can import each other relatively, by
|
|
17
|
+
* bare name, and through the sandbox import map. The VFS source is inlined
|
|
18
|
+
* into the prelude via `Function.prototype.toString`.
|
|
19
|
+
*
|
|
20
|
+
* Opt-in hardening (`nodeWorker.permissions`) spawns the thread with Node's
|
|
21
|
+
* permission model, an isolated `env`, a heap cap, captured stdio, and a
|
|
22
|
+
* stripped `process`. None of this makes a worker thread a security boundary;
|
|
23
|
+
* see the README's Security model.
|
|
24
|
+
*
|
|
15
25
|
* `node:worker_threads` is loaded with a dynamic `import()` so the browser
|
|
16
26
|
* entry never pulls a `node:` specifier into a bundle's static graph.
|
|
17
27
|
*/
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
import { installNodeVfs } from './node-vfs.mjs';
|
|
30
|
+
|
|
31
|
+
function makePrelude({ harden }) {
|
|
32
|
+
return `
|
|
20
33
|
const { parentPort } = require('node:worker_threads');
|
|
34
|
+
const __exit = process.exit.bind(process);
|
|
35
|
+
const __vfs = (${installNodeVfs.toString()})(require('node:module').registerHooks);
|
|
36
|
+
delete globalThis[Symbol.for('andbox.vfs')];
|
|
37
|
+
Object.defineProperty(globalThis, '__andboxNodeVirtual', {
|
|
38
|
+
configurable: true,
|
|
39
|
+
value(name, modules, importMap) {
|
|
40
|
+
__vfs.setImportMap('sb', importMap);
|
|
41
|
+
for (const [n, src] of modules) __vfs.define('sb', n, src);
|
|
42
|
+
return import(__vfs.urlOf('sb', name));
|
|
43
|
+
},
|
|
44
|
+
});
|
|
21
45
|
globalThis.self = globalThis;
|
|
22
46
|
globalThis.postMessage = (m) => parentPort.postMessage(m);
|
|
23
|
-
globalThis.close = () =>
|
|
47
|
+
globalThis.close = () => __exit(0);
|
|
24
48
|
parentPort.on('message', (data) => {
|
|
25
49
|
try { const r = globalThis.onmessage?.({ data }); r?.catch?.((e) => { throw e; }); }
|
|
26
50
|
catch (e) { parentPort.postMessage({ type: 'error', message: String(e?.message ?? e) }); }
|
|
@@ -31,7 +55,15 @@ globalThis.Blob = class AndboxNodeBlob {
|
|
|
31
55
|
};
|
|
32
56
|
URL.createObjectURL = (b) => 'data:text/javascript;base64,' + Buffer.from(b.text).toString('base64');
|
|
33
57
|
URL.revokeObjectURL = () => {};
|
|
58
|
+
|
|
59
|
+
${harden ? `
|
|
60
|
+
__vfs.denyHostResources();
|
|
61
|
+
for (const k of ['binding', '_linkedBinding', 'getBuiltinModule', 'dlopen', 'kill', 'abort', '_kill', 'reallyExit', 'mainModule', 'moduleLoadList']) {
|
|
62
|
+
try { delete process[k]; } catch {}
|
|
63
|
+
}
|
|
64
|
+
` : ''}
|
|
34
65
|
`;
|
|
66
|
+
}
|
|
35
67
|
|
|
36
68
|
class NodeWebWorker {
|
|
37
69
|
#thread;
|
|
@@ -40,8 +72,13 @@ class NodeWebWorker {
|
|
|
40
72
|
onmessage = null;
|
|
41
73
|
onerror = null;
|
|
42
74
|
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
dead = false;
|
|
76
|
+
onstdio = null;
|
|
77
|
+
|
|
78
|
+
constructor(ThreadWorker, source, config) {
|
|
79
|
+
const w = new ThreadWorker(makePrelude({ harden: config.harden }) + '\n' + source, config.workerOptions);
|
|
80
|
+
if (config.workerOptions.stdout) w.stdout.on('data', (d) => this.onstdio?.('stdout', String(d)));
|
|
81
|
+
if (config.workerOptions.stderr) w.stderr.on('data', (d) => this.onstdio?.('stderr', String(d)));
|
|
45
82
|
w.on('message', (data) => {
|
|
46
83
|
const ev = { data };
|
|
47
84
|
this.onmessage?.(ev);
|
|
@@ -49,6 +86,7 @@ class NodeWebWorker {
|
|
|
49
86
|
});
|
|
50
87
|
w.on('error', (e) => this.onerror?.({ message: e?.message ?? String(e) }));
|
|
51
88
|
w.on('exit', (code) => {
|
|
89
|
+
this.dead = true;
|
|
52
90
|
if (!this.#terminated && code !== 0) {
|
|
53
91
|
this.onerror?.({ message: `worker exited with code ${code}` });
|
|
54
92
|
}
|
|
@@ -61,6 +99,9 @@ class NodeWebWorker {
|
|
|
61
99
|
}
|
|
62
100
|
addEventListener(type, fn) { if (type === 'message') this.#listeners.add(fn); }
|
|
63
101
|
removeEventListener(type, fn) { if (type === 'message') this.#listeners.delete(fn); }
|
|
102
|
+
/** Let the process exit while the thread is idle / keep it alive. */
|
|
103
|
+
ref() { if (!this.dead) this.#thread.ref(); }
|
|
104
|
+
unref() { if (!this.dead) this.#thread.unref(); }
|
|
64
105
|
terminate() {
|
|
65
106
|
this.#terminated = true;
|
|
66
107
|
this.#listeners.clear();
|
|
@@ -81,7 +122,7 @@ export function isNodeRuntime() {
|
|
|
81
122
|
*
|
|
82
123
|
* @returns {Promise<(source: string) => object>}
|
|
83
124
|
*/
|
|
84
|
-
export async function createNodeWorkerFactory() {
|
|
125
|
+
export async function createNodeWorkerFactory(options = {}) {
|
|
85
126
|
if (!isNodeRuntime()) {
|
|
86
127
|
throw new Error("createNodeWorkerFactory() requires Node (node:worker_threads); in a browser use mode: 'worker'.");
|
|
87
128
|
}
|
|
@@ -89,5 +130,36 @@ export async function createNodeWorkerFactory() {
|
|
|
89
130
|
// `node:` scheme) must not try to resolve this browser-irrelevant import.
|
|
90
131
|
const specifier = 'node:' + 'worker_threads';
|
|
91
132
|
const { Worker: ThreadWorker } = await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier);
|
|
92
|
-
|
|
133
|
+
const config = resolveNodeWorkerConfig(options);
|
|
134
|
+
return (source) => new NodeWebWorker(ThreadWorker, source, config);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DEFAULT_HARDENED_MEMORY_MB = 256;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Turn the public `nodeWorker` options into `Worker` constructor
|
|
141
|
+
* options.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveNodeWorkerConfig(options = {}) {
|
|
144
|
+
const { permissions = false, env, maxMemoryMb, resourceLimits, execArgv, captureStdio } = options;
|
|
145
|
+
if (maxMemoryMb !== undefined && !(Number.isFinite(maxMemoryMb) && maxMemoryMb > 0)) {
|
|
146
|
+
throw new Error('nodeWorker.maxMemoryMb must be a positive number (megabytes)');
|
|
147
|
+
}
|
|
148
|
+
const workerOptions = { eval: true };
|
|
149
|
+
const memory = maxMemoryMb ?? (permissions ? DEFAULT_HARDENED_MEMORY_MB : undefined);
|
|
150
|
+
if (memory !== undefined || resourceLimits) {
|
|
151
|
+
workerOptions.resourceLimits = {
|
|
152
|
+
...(memory !== undefined ? { maxOldGenerationSizeMb: memory } : {}),
|
|
153
|
+
...resourceLimits,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (env !== undefined) workerOptions.env = env;
|
|
157
|
+
else if (permissions) workerOptions.env = {};
|
|
158
|
+
const argv = [...(permissions ? ['--permission'] : []), ...(execArgv ?? [])];
|
|
159
|
+
if (argv.length) workerOptions.execArgv = argv;
|
|
160
|
+
if (captureStdio ?? permissions) {
|
|
161
|
+
workerOptions.stdout = true;
|
|
162
|
+
workerOptions.stderr = true;
|
|
163
|
+
}
|
|
164
|
+
return { workerOptions, harden: permissions };
|
|
93
165
|
}
|
package/src/sandbox.mjs
CHANGED
|
@@ -231,9 +231,7 @@ async function createServiceWorkerSandbox(options = {}) {
|
|
|
231
231
|
const initialFiles = { ...files };
|
|
232
232
|
if (registry) {
|
|
233
233
|
for (const path of registry.paths()) {
|
|
234
|
-
|
|
235
|
-
const res = await fetch(url);
|
|
236
|
-
initialFiles[path] = { body: await res.text(), contentType: guessContentType(path) };
|
|
234
|
+
initialFiles[path] = { body: registry.source(path), contentType: guessContentType(path) };
|
|
237
235
|
}
|
|
238
236
|
}
|
|
239
237
|
|
|
@@ -307,6 +305,8 @@ async function createServiceWorkerSandbox(options = {}) {
|
|
|
307
305
|
* @property {(level: string, ...args: string[]) => void} [onConsole] - Console output handler
|
|
308
306
|
*/
|
|
309
307
|
|
|
308
|
+
const SUPPORTED_MODES = ['worker', 'node-worker', 'inline', 'data-uri', 'service-worker'];
|
|
309
|
+
|
|
310
310
|
/**
|
|
311
311
|
* Create a new sandboxed runtime.
|
|
312
312
|
*
|
|
@@ -314,7 +314,13 @@ async function createServiceWorkerSandbox(options = {}) {
|
|
|
314
314
|
* @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean }> | Promise<{ scriptURL: string, scope: string, define: Function, remove: Function, dispose: Function, isDisposed: () => boolean }>}
|
|
315
315
|
*/
|
|
316
316
|
export function createSandbox(options = {}) {
|
|
317
|
-
const mode = options.mode
|
|
317
|
+
const mode = options.mode ?? 'worker';
|
|
318
|
+
if (!SUPPORTED_MODES.includes(mode)) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Unknown sandbox mode ${typeof mode === 'string' ? `'${mode}'` : String(mode)}. ` +
|
|
321
|
+
`Supported modes: ${SUPPORTED_MODES.map((m) => `'${m}'`).join(', ')}.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
318
324
|
if (mode === 'inline') return createInlineSandbox(options);
|
|
319
325
|
if (mode === 'data-uri') return createDataUriSandbox(options);
|
|
320
326
|
if (mode === 'service-worker') return createServiceWorkerSandbox(options);
|
|
@@ -329,14 +335,25 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
329
335
|
baseURL = typeof location !== 'undefined' ? location.href : 'https://andbox.local/',
|
|
330
336
|
policy,
|
|
331
337
|
onConsole,
|
|
338
|
+
nodeWorker,
|
|
339
|
+
unref = false,
|
|
332
340
|
} = options;
|
|
333
341
|
|
|
334
342
|
// Node mode: no global Worker (and no blob: worker URLs) -> node:worker_threads.
|
|
335
343
|
// An explicit workerFactory always wins; 'node-worker' forces Node; the
|
|
336
344
|
// default selects it only when there is no global Worker under Node.
|
|
337
345
|
let workerFactory = options.workerFactory || null;
|
|
346
|
+
let usingNode = false;
|
|
338
347
|
if (!workerFactory && (forceNode || (typeof Worker === 'undefined' && isNodeRuntime()))) {
|
|
339
|
-
workerFactory = await createNodeWorkerFactory();
|
|
348
|
+
workerFactory = await createNodeWorkerFactory(nodeWorker);
|
|
349
|
+
usingNode = true;
|
|
350
|
+
}
|
|
351
|
+
if (nodeWorker && !usingNode) {
|
|
352
|
+
// A security option that silently does nothing is worse than an error.
|
|
353
|
+
throw new Error(
|
|
354
|
+
'The nodeWorker option only applies to the built-in Node worker_threads mode; ' +
|
|
355
|
+
'it cannot be honoured with a browser Worker or a custom workerFactory.'
|
|
356
|
+
);
|
|
340
357
|
}
|
|
341
358
|
|
|
342
359
|
// Gate capabilities with rate limits
|
|
@@ -351,6 +368,17 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
351
368
|
let worker = null;
|
|
352
369
|
let workerBlobURL = null;
|
|
353
370
|
|
|
371
|
+
// `unref`: the thread only keeps the host process alive while work is in
|
|
372
|
+
// flight (startup, evaluate, defineModule); idle, it lets the process exit.
|
|
373
|
+
let activeOps = 0;
|
|
374
|
+
function beginOp() {
|
|
375
|
+
activeOps++;
|
|
376
|
+
worker?.ref?.();
|
|
377
|
+
}
|
|
378
|
+
function endOp() {
|
|
379
|
+
if (--activeOps === 0 && unref) worker?.unref?.();
|
|
380
|
+
}
|
|
381
|
+
|
|
354
382
|
// Pending evaluations
|
|
355
383
|
const pending = new Map(); // id -> { resolve, reject, timer }
|
|
356
384
|
|
|
@@ -361,6 +389,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
361
389
|
if (workerFactory) {
|
|
362
390
|
worker = workerFactory(source);
|
|
363
391
|
attachWorkerHandlers();
|
|
392
|
+
worker.onstdio = (stream, text) => activeConsoleHandler?.(stream, text);
|
|
364
393
|
return;
|
|
365
394
|
}
|
|
366
395
|
const blob = new Blob([source], { type: 'application/javascript' });
|
|
@@ -471,9 +500,15 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
471
500
|
}
|
|
472
501
|
|
|
473
502
|
async function restartWorker() {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
503
|
+
beginOp();
|
|
504
|
+
try {
|
|
505
|
+
terminateWorker();
|
|
506
|
+
createWorker();
|
|
507
|
+
worker.ref?.();
|
|
508
|
+
await configureWorker();
|
|
509
|
+
} finally {
|
|
510
|
+
endOp();
|
|
511
|
+
}
|
|
477
512
|
}
|
|
478
513
|
|
|
479
514
|
// ── Public API ──
|
|
@@ -487,7 +522,8 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
487
522
|
*/
|
|
488
523
|
async function evaluate(code, opts = {}) {
|
|
489
524
|
if (disposed) throw new Error('Sandbox is disposed');
|
|
490
|
-
if (!worker) await restartWorker();
|
|
525
|
+
if (!worker || worker.dead) await restartWorker();
|
|
526
|
+
beginOp();
|
|
491
527
|
|
|
492
528
|
// Random (not sequential) id so code running during one evaluate() call
|
|
493
529
|
// can't guess the id of a concurrent evaluate() on the same worker and
|
|
@@ -516,6 +552,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
516
552
|
if (opts.signal) {
|
|
517
553
|
if (opts.signal.aborted) {
|
|
518
554
|
if (timer) clearTimeout(timer);
|
|
555
|
+
endOp();
|
|
519
556
|
throw makeAbortError();
|
|
520
557
|
}
|
|
521
558
|
opts.signal.addEventListener('abort', () => {
|
|
@@ -533,10 +570,10 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
533
570
|
worker.postMessage({ type: 'evaluate', id, code });
|
|
534
571
|
|
|
535
572
|
// Restore console handler when evaluation completes
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
573
|
+
return promise.finally(() => {
|
|
574
|
+
if (opts.onConsole) activeConsoleHandler = prevConsoleHandler;
|
|
575
|
+
endOp();
|
|
576
|
+
});
|
|
540
577
|
}
|
|
541
578
|
|
|
542
579
|
/**
|
|
@@ -551,6 +588,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
551
588
|
virtualModules.set(name, source);
|
|
552
589
|
|
|
553
590
|
if (worker) {
|
|
591
|
+
beginOp();
|
|
554
592
|
const { promise, resolve } = makeDeferred();
|
|
555
593
|
const handler = ({ data }) => {
|
|
556
594
|
if (data.type === 'moduleDefined' && data.name === name) {
|
|
@@ -560,7 +598,7 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
560
598
|
};
|
|
561
599
|
worker.addEventListener('message', handler);
|
|
562
600
|
worker.postMessage({ type: 'defineModule', name, source });
|
|
563
|
-
await promise;
|
|
601
|
+
try { await promise; } finally { endOp(); }
|
|
564
602
|
}
|
|
565
603
|
}
|
|
566
604
|
|
|
@@ -594,8 +632,13 @@ async function createWorkerSandbox(options = {}, forceNode = false) {
|
|
|
594
632
|
}
|
|
595
633
|
|
|
596
634
|
// ── Initialize ──
|
|
597
|
-
|
|
598
|
-
|
|
635
|
+
beginOp();
|
|
636
|
+
try {
|
|
637
|
+
createWorker();
|
|
638
|
+
await configureWorker();
|
|
639
|
+
} finally {
|
|
640
|
+
endOp();
|
|
641
|
+
}
|
|
599
642
|
|
|
600
643
|
return {
|
|
601
644
|
evaluate,
|
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { resolveWithImportMap } from './import-map-resolver.mjs';
|
|
13
|
+
import { getHostNodeVfs } from './node-vfs.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @typedef {Object} VirtualModuleRegistry
|
|
16
17
|
* @property {(path: string) => string | null} resolve
|
|
17
18
|
* @property {(specifier: string, parentPath?: string) => string | null} resolveSpecifier
|
|
18
19
|
* @property {(path: string, source: string) => string} define
|
|
20
|
+
* @property {(path: string) => string | null} source
|
|
19
21
|
* @property {(path: string) => boolean} has
|
|
20
22
|
* @property {() => string[]} paths
|
|
21
23
|
* @property {() => void} dispose
|
|
@@ -23,18 +25,43 @@ import { resolveWithImportMap } from './import-map-resolver.mjs';
|
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
|
-
* Create a registry of in-memory files
|
|
28
|
+
* Create a registry of in-memory files.
|
|
29
|
+
*
|
|
30
|
+
* In a browser each file is backed by its own `blob:` URL. Under Node, whose
|
|
31
|
+
* ESM loader cannot `import()` a `blob:` URL, the registry is backed by an
|
|
32
|
+
* in-thread module hook instead (see `node-vfs.mjs`): `resolve(path)`
|
|
33
|
+
* returns an `andbox-vfs://<id>/<path>?v=<n>` URL that Node *can* `import()`,
|
|
34
|
+
* and files in the registry can import each other by relative path
|
|
35
|
+
* (`./util.js`), by bare name, and through the registry's import map.
|
|
36
|
+
* Call `dispose()` to drop the files.
|
|
37
|
+
* `options.backend` is `'auto'` (default: Node hooks under Node, blob
|
|
38
|
+
* elsewhere), `'blob'` or `'node'`.
|
|
27
39
|
*
|
|
28
40
|
* @param {Record<string, string>} [files] - path → source text
|
|
29
|
-
* @param {{ importMap?: { imports?: Record<string,string>, scopes?: Record<string,Record<string,string>> } }} [options]
|
|
41
|
+
* @param {{ backend?: 'auto' | 'blob' | 'node', importMap?: { imports?: Record<string,string>, scopes?: Record<string,Record<string,string>> } }} [options]
|
|
30
42
|
* @returns {VirtualModuleRegistry}
|
|
31
43
|
*/
|
|
32
44
|
export function createVirtualModuleRegistry(files = {}, options = {}) {
|
|
33
45
|
const importMap = options.importMap || null;
|
|
34
|
-
const urls = new Map(); // path -> blob: URL
|
|
46
|
+
const urls = new Map(); // path -> blob: / andbox-vfs: URL
|
|
47
|
+
const sources = new Map(); // path -> source text
|
|
48
|
+
const backend = options.backend ?? 'auto';
|
|
49
|
+
if (!['auto', 'blob', 'node'].includes(backend)) {
|
|
50
|
+
throw new Error(`Unknown registry backend '${backend}'; use 'auto', 'blob' or 'node'`);
|
|
51
|
+
}
|
|
52
|
+
const vfs = backend === 'blob' ? null : getHostNodeVfs();
|
|
53
|
+
if (backend === 'node' && !vfs) throw new Error("backend: 'node' requires Node (module.registerHooks)");
|
|
54
|
+
const ns = vfs ? Math.random().toString(36).slice(2, 10) + Date.now().toString(36) : null;
|
|
35
55
|
let disposed = false;
|
|
56
|
+
if (vfs) vfs.setImportMap(ns, importMap);
|
|
36
57
|
|
|
37
58
|
function blobify(path, source) {
|
|
59
|
+
sources.set(path, source);
|
|
60
|
+
if (vfs) {
|
|
61
|
+
const url = vfs.define(ns, path, source);
|
|
62
|
+
urls.set(path, url);
|
|
63
|
+
return url;
|
|
64
|
+
}
|
|
38
65
|
const blob = new Blob([source], { type: 'text/javascript' });
|
|
39
66
|
const url = URL.createObjectURL(blob);
|
|
40
67
|
urls.set(path, url);
|
|
@@ -70,10 +97,22 @@ export function createVirtualModuleRegistry(files = {}, options = {}) {
|
|
|
70
97
|
*/
|
|
71
98
|
function define(path, source) {
|
|
72
99
|
assertNotDisposed();
|
|
73
|
-
if (urls.has(path)) URL.revokeObjectURL(urls.get(path));
|
|
100
|
+
if (!vfs && urls.has(path)) URL.revokeObjectURL(urls.get(path));
|
|
74
101
|
return blobify(path, source);
|
|
75
102
|
}
|
|
76
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The source text registered for a path (works in every runtime, including
|
|
106
|
+
* Node where the URL is not fetchable).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} path
|
|
109
|
+
* @returns {string | null}
|
|
110
|
+
*/
|
|
111
|
+
function source(path) {
|
|
112
|
+
assertNotDisposed();
|
|
113
|
+
return sources.get(path) ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
77
116
|
/** @param {string} path */
|
|
78
117
|
function has(path) {
|
|
79
118
|
return urls.has(path);
|
|
@@ -129,14 +168,17 @@ export function createVirtualModuleRegistry(files = {}, options = {}) {
|
|
|
129
168
|
function dispose() {
|
|
130
169
|
if (disposed) return;
|
|
131
170
|
disposed = true;
|
|
132
|
-
|
|
171
|
+
if (vfs) vfs.dispose(ns);
|
|
172
|
+
else for (const url of urls.values()) URL.revokeObjectURL(url);
|
|
133
173
|
urls.clear();
|
|
174
|
+
sources.clear();
|
|
134
175
|
}
|
|
135
176
|
|
|
136
177
|
return {
|
|
137
178
|
resolve,
|
|
138
179
|
resolveSpecifier,
|
|
139
180
|
define,
|
|
181
|
+
source,
|
|
140
182
|
has,
|
|
141
183
|
paths,
|
|
142
184
|
dispose,
|
package/src/worker-source.mjs
CHANGED
|
@@ -33,6 +33,10 @@ export function makeWorkerSource() {
|
|
|
33
33
|
let importMap = { imports: {}, scopes: {} };
|
|
34
34
|
let baseURL = 'https://andbox.local/';
|
|
35
35
|
const virtualModules = new Map();
|
|
36
|
+
// Node mode only: the adapter provides a loader that lets virtual modules
|
|
37
|
+
// import each other. Captured once and removed from the global scope.
|
|
38
|
+
const nodeVirtual = globalThis.__andboxNodeVirtual;
|
|
39
|
+
try { delete globalThis.__andboxNodeVirtual; } catch {}
|
|
36
40
|
|
|
37
41
|
// ── Import Map Resolver (inlined) ──
|
|
38
42
|
function resolveWithImportMap(specifier, map, parentURL) {
|
|
@@ -69,6 +73,7 @@ function matchSpec(specifier, mapping) {
|
|
|
69
73
|
async function sandboxImport(specifier) {
|
|
70
74
|
// 1. Virtual module
|
|
71
75
|
if (virtualModules.has(specifier)) {
|
|
76
|
+
if (nodeVirtual) return await nodeVirtual(specifier, virtualModules, importMap);
|
|
72
77
|
const src = virtualModules.get(specifier);
|
|
73
78
|
const blob = new Blob([src], { type: 'application/javascript' });
|
|
74
79
|
const url = URL.createObjectURL(blob);
|