@johnhenry/andbox
v0.3.3
Published
Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, timeouts, and an optional QuickJS-in-WebAssembly mode
Maintainers
Readme
andbox
Full documentation: opensource.johnhenry.me/andbox
A separate-context JavaScript runtime with Worker isolation, RPC capabilities, import maps, and timeouts.
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.
andbox's job is running code in its own context with a clean RPC surface, not containing adversarial code. The Worker boundary keeps well-behaved code from touching the DOM or host globals by accident, and gives you rate limits, timeouts, and a kill switch for code you trust but don't want to block on or grant unrestricted access to. It is not a security sandbox: code that specifically tries to escape can reach fetch, WebSocket, Worker, and other Worker-global APIs directly, regardless of what capabilities you grant. See Security model before using andbox to run code you don't trust.
Zero dependencies. Uses only Web Workers and standard browser APIs.
Contents
- Install
- Quick Start
- Sandbox Modes
- Node
mode: 'wasm'mode: 'iframe'- Mediated network:
network - Bridges: sharing a host API
- Chrome AI bridge
- API
- Execution model
- Security model
- Threat model by mode
- Family
- License
Install
npm install @johnhenry/andboxOr via CDN (no bundler needed):
import { createSandbox } from 'https://esm.sh/@johnhenry/andbox';Provenance: previously published as
[email protected]. This package restarts versioning at0.0.0under the@johnhenryscope.
Quick Start
import { createSandbox } from '@johnhenry/andbox';
const sandbox = await createSandbox({
capabilities: {
readFile: async (path) => { /* host-side file read */ },
writeFile: async (path, content) => { /* host-side file write */ },
},
importMap: {
imports: {
'lodash': 'https://esm.sh/lodash',
},
},
onConsole: (level, ...args) => console.log(`[sandbox:${level}]`, ...args),
});
// Evaluate code in the sandbox
const result = await sandbox.evaluate(`
const greeting = 'Hello from the sandbox!';
console.log(greeting);
// Call a host capability
const content = await host.call('readFile', '/etc/hostname');
return content;
`);
// Define a virtual module
await sandbox.defineModule('utils', `
export function add(a, b) { return a + b; }
`);
// Import the virtual module from sandbox code
await sandbox.evaluate(`
const { add } = await sandboxImport('utils');
return add(2, 3); // 5
`);
// Clean up
await sandbox.dispose();Sandbox Modes
andbox supports seven execution modes (any other mode throws an error listing these):
worker(default) -- Runs in a dedicated Worker with an RPC bridge, import maps, virtual modules, and hard-kill timeout semantics. See Security model for what this does and doesn't protect against.node-worker-- Theworkermode onnode:worker_threads. Selected automatically under Node when there is no globalWorker; see Node.wasm-- Optional. Runs the code in QuickJS-ng compiled to WebAssembly inside the Worker (or worker thread), withhost.callas the only authority and real memory, stack, fuel and deadline limits. The only mode that withholds the Worker's own globals (fetch,WebSocket,importScripts, ...). Seemode: 'wasm'.iframe-- Browser only. Runs the code in a sandboxed<iframe sandbox="allow-scripts" srcdoc>: an opaque origin with its own realm,windowanddocument, so the code can render real DOM (charts, canvas animations, HTML) that you mount in your page. Same API asworker. The first mode with a browser-enforced origin boundary; seemode: 'iframe'for what that does and does not cover.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.data-uri-- Dynamicimport()via Blob URL (adata:URL under Node). Module-level separation without a Worker. Supports globals injection.service-worker-- Not code execution at all: registers a Service Worker that serves an in-memorypath → contentmap 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 and Security model -- this mode does not provide isolation by merely existing.
// Inline mode (no Worker)
const inline = createSandbox({ mode: 'inline', globals: { math: Math } });
const result = await inline.execute('return math.sqrt(16)');
// Data-URI mode
const dataUri = createSandbox({ mode: 'data-uri', globals: { x: 42 } });
const result = await dataUri.execute('print(x)');
// Service-Worker mode (browser only -- see examples/06-service-worker-mode/)
const site = await createSandbox({
mode: 'service-worker',
scriptURL: '/andbox-sw.js', // you serve makeServiceWorkerSource()'s output here
scope: '/virtual/',
files: { '/virtual/hello.html': '<h1>Hello</h1>' },
});
// Only navigate into scope *after* this resolves -- see Security model.Node
From 0.0.4 no shim is needed. Under Node (engines >= 26), createSandbox() just works:
import { createSandbox } from '@johnhenry/andbox';
const sandbox = await createSandbox({ capabilities: { now: () => Date.now() } });
await sandbox.evaluate('return await host.call("now")');
await sandbox.dispose(); // worker threads keep the process alive until disposedMode 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.
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.
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).
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.)
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:
const sandbox = await createSandbox({
nodeWorker: {
permissions: true, // see below
// env: { MODE: 'x' }, // explicit env; with permissions the default is {}
// maxMemoryMb: 128, // heap cap (default 256 with permissions: true)
// resourceLimits: {...}, // raw worker_threads resourceLimits, merged on top
// execArgv: [...], // extra thread flags, e.g. '--allow-fs-read=/data'
// captureStdio: true, // route thread stdout/stderr to onConsole('stdout'|'stderr', text)
},
});permissions: true does all of the following to the thread:
- 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:fsreads andchild_processare denied withERR_ACCESS_DENIED, Nested workers and native addons are not granted (--allow-worker/--allow-addonsare not passed), though only thefsandchild_processdenials are covered by tests. Grant specific paths withexecArgv: ['--allow-fs-read=/dir']. - Isolated
env.{}(or the object you pass) instead of a copy of the host's environment, so secrets inprocess.envare not visible. - Memory cap.
resourceLimits.maxOldGenerationSizeMb(default 256, ormaxMemoryMb). An allocation loop kills the thread with an out-of-memory error, the pendingevaluate()rejects, and the next call starts a fresh thread. (andbox has no other memory-limit option, somaxMemoryMbis new here;policy.limitsonly governs capability calls.) - Stripped
process. Deletesprocess.binding,_linkedBinding,getBuiltinModule,dlopen,kill,abort,reallyExit,mainModule. - Blocked host imports. A resolve hook rejects any
import()/sandboxImport()that resolves to anode:orfile:URL (import('node:fs'),import('fs'),import('file:///...')).data:,http(s):and virtual modules still work. - Captured stdio. Thread
process.stdout/stderrwrites go toonConsoleinstead of the host terminal.
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. The options throw if combined with a browser Worker or a custom workerFactory, rather than silently doing nothing.
Not supported / differences.
mode: 'service-worker'needs a browser and rejects under Node.- A worker thread is not a security boundary -- not by default and not with
nodeWorker.permissions. By default sandboxed code can reachprocess(includingprocess.env,process.binding,process.getBuiltinModule('fs')) and canimport('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).
mode: 'wasm'
mode: 'wasm' (added in 0.0.8, andbox#21) is the "different execution strategy" the Security model says worker mode is missing. The code you evaluate() is not run by the Worker's JavaScript engine at all: it runs in QuickJS-ng compiled to WebAssembly, in the same Worker (browser) or worker_thread (Node) andbox already uses. A fresh QuickJS runtime and context is created for every evaluate().
The engine has no fetch, WebSocket, XMLHttpRequest, importScripts, indexedDB, postMessage, self, Worker, timers or process: they are not hidden, they simply do not exist in that engine. Its only way out is the single native function behind host.call(name, ...args), which goes through the same capabilityCall message, gateCapabilities() and policy as worker mode. sandboxImport() and import() resolve only virtual modules (defineModule()); no URL is ever fetched.
import { createSandbox } from '@johnhenry/andbox';
const sandbox = await createSandbox({
mode: 'wasm',
capabilities: { readFile: async (path) => { /* host side */ } },
fuel: 50_000, // interrupt polls, deterministic (default: unlimited)
memoryBytes: 32 * 1024 * 1024, // JS heap cap (default 64 MiB)
stackBytes: 128 * 1024, // guest stack cap (default 128 KiB)
deadlineMs: 2_000, // cooperative wall-clock deadline (default: the call's timeoutMs)
});
await sandbox.evaluate('return await host.call("readFile", "/etc/hostname")');Installing the engine (optional, pinned)
The engine is an optional peer dependency, pinned to exact versions, so the default install stays dependency-free:
npm install --save-exact [email protected] @jitl/[email protected]Without them createSandbox({ mode: 'wasm' }) rejects with ERR_ANDBOX_ENGINE_MISSING and the install command above. Every other mode is unaffected.
Node: nothing else to do. andbox finds the installed packages and the .wasm file itself.
Browser, no CDN: the Worker needs two files from your own origin: the engine as one ES module, and the .wasm file. Build them once:
npx esbuild node_modules/@johnhenry/andbox/src/wasm-engine.mjs \
--bundle --format=esm --platform=browser --minify --outfile=public/andbox-quickjs.mjs
cp node_modules/@jitl/quickjs-ng-wasmfile-release-sync/dist/emscripten-module.wasm public/andbox-quickjs.wasmand point andbox at them (relative URLs resolve against baseURL, which defaults to the page URL):
const sandbox = await createSandbox({
mode: 'wasm',
engineURL: '/andbox-quickjs.mjs',
wasmURL: '/andbox-quickjs.wasm',
});Or give them through the sandbox import map, so a page that already has one needs no new option: importMap: { imports: { '@johnhenry/andbox/wasm-engine': '/andbox-quickjs.mjs', '@johnhenry/andbox/wasm': '/andbox-quickjs.wasm' } }. Serve the .wasm as application/wasm. Size: about 54 KB for the engine module (15 KB gzipped) plus 528 KB for the .wasm (248 KB gzipped); the main andbox entry grows by about 10 KB minified (4 KB gzipped) for the extra worker source. examples/08-wasm-browser/ is a complete working build + server + headless-Chrome check.
Limits
| Option (sandbox or per evaluate() call) | What it does | Reported as |
|---|---|---|
| fuel | Budget of interrupt-handler polls (QuickJS polls about once per 10,000 VM operations). Counted, not timed, so the same code stops at the same count on every run. sandbox.stats().fuelUsed shows the last call's count. Guest code cannot catch it. | FuelExhaustedError, code: 'ERR_ANDBOX_FUEL_EXHAUSTED' |
| memoryBytes | Cap on the guest JS heap (checked while the code runs, on a time budget of roughly 10% overhead) plus QuickJS's own limit, which rejects any single allocation above it. The engine's whole linear memory also gets a hard maximum of about 2 * memoryBytes + 32 MiB, which is what actually bounds ArrayBuffer and similar allocations. | MemoryLimitError, code: 'ERR_ANDBOX_MEMORY_LIMIT' |
| stackBytes | QuickJS call-stack cap. Overflow is an ordinary, catchable guest RangeError. | RangeError |
| deadlineMs | Wall-clock deadline for the whole call, including time spent awaiting host calls. Checked in the interrupt handler and by a timer. The Worker survives; no respawn. Defaults to the call's timeoutMs. | TimeoutError, code: 'ERR_ANDBOX_DEADLINE' |
| timeoutMs | Unchanged, but in this mode it is the backstop: the host terminate()s the Worker max(timeoutMs, deadlineMs) + 1000 ms after the call starts, which covers anything that stops the engine from polling. AbortSignal still terminates and restarts the Worker. | TimeoutError / AbortError |
stats() additionally returns fuelUsed, peakMemoryBytes (sampled) and totalFuelUsed. The error classes are plain Errors with the name and code shown; WASM_ERROR_CODES exports the codes. An exception that escapes the engine itself (for example the host stack overflowing when stackBytes is set far above the default) is caught and reported as EngineError / ERR_ANDBOX_ENGINE; the engine is reloaded for the next call.
Differences from worker mode
host.callarguments and results, and the value youreturn, are JSON-serialised (worker mode uses structured clone).undefined, functions ('[Function]') andBigInt(as a string) are handled as in worker mode;Map,Set,Dateand typed arrays are not preserved.- Each
evaluate()starts from a fresh global scope. Nothing set onglobalThissurvives; usedefineModule()or the host for shared state. sandboxImport()is limited to virtual modules (relative imports, bare names and import-map names that point at a virtual module name all work). An import map entry that points at a URL does not make that URL loadable.- Capability arguments are JSON, so a capability that expects non-JSON values will not get them.
- Concurrent
evaluate()calls on one sandbox share the Worker's single thread: a busy loop in one delays the others (their deadlines are wall-clock). Use one sandbox per tenant if that matters. nodeWorker.permissionsis rejected with this mode for now (the Worker has to read the engine from disk). The othernodeWorkeroptions work.- A guest can catch the engine's out-of-memory error and keep running inside the cap; it cannot exceed the cap.
Date,Math.randomandperformanceexist in the guest (QuickJS provides them from the host clock); see the threat model.
mode: 'iframe'
mode: 'iframe' (0.1.2) runs evaluated code in a sandboxed <iframe> instead of a Worker, for code that needs a real DOM: a notebook pane that draws a chart, plays a canvas animation, or renders HTML. It is the cross-origin-iframe option andbox#10 named next to mode: 'wasm'. The frame is created from srcdoc with sandbox="allow-scripts" and no allow-same-origin, so the browser gives it an opaque origin: it cannot read your page, your cookies or your storage.
import { createSandbox } from '@johnhenry/andbox';
const pane = await createSandbox({
mode: 'iframe',
container: document.querySelector('#pane'), // where the frame goes (default: offscreen in <body>)
html: '<style>body { margin: 0 }</style>', // initial body markup
capabilities: { data: () => [3, 7, 4, 9] },
onConsole: (level, ...args) => console.log(`[pane:${level}]`, ...args),
onFrame: (iframe) => { iframe.className = 'pane-frame'; }, // every new frame, before it is attached
});
await pane.evaluate(`
const canvas = document.createElement('canvas'); // the frame's own document
document.body.append(canvas);
const values = await host.call('data');
// ... draw, animate, play(Scene, { canvas }) ...
return values.length;
`);
pane.iframe.style.height = '300px'; // the live element; size it like any otherSame contract as worker mode. evaluate(code, opts) wraps the code in an async IIFE and resolves with what it returns, by structured clone (Map, Date, typed arrays survive; a DOM node rejects with DataCloneError). host.call(name, ...args) goes through the same capability gate and policy, and capabilities get the same this.signal. sandboxImport() resolves virtual modules (defineModule()), the importMap, and remote URLs under the same allowedImportHosts rules. console.* is forwarded to onConsole (sandbox-level or per call). timeoutMs/defaultTimeoutMs and an AbortSignal hard-kill the frame (it is removed and a fresh one created) and reject with the same TimeoutError / AbortError as worker mode. stats(), dispose() and isDisposed() are unchanged.
Differences from worker mode.
- The code has a full browser window.
window,document,fetch, timers andrequestAnimationFrameare the frame's own; nothing is deleted or shadowed (the worker-mode lockdown does not apply, the origin boundary does).document.body.append(...)renders where you mounted the frame. Withnetwork,fetchis replaced by the host-backed one. sandbox.iframeis the live element, and it changes. A timeout, an abort, or the frame navigating/reloading itself replaces it with a new element in the same place, with the same attributes (class,style,width, ...); everything the old document held is gone.onFrame(iframe)is called for every new frame before it is attached, so set things up there if you need them on every frame, or insert the frame yourself there.sandbox.iframeisnullafterdispose().- Do not move the element in the DOM. Re-parenting an iframe reloads its document. andbox notices (the pending call rejects with
Sandbox iframe unloaded ...) and the next call gets a fresh frame, but state is lost. Passcontainer(or place it inonFrame) instead. - Remote modules are cross-origin requests. The frame's origin is
null, so a module URL must be served withAccess-Control-Allow-Origin(CDNs such as esm.sh do). A relativesandboxImport('./x.js')resolves againstbaseURL(default: your page URL) and needs the same header. cspis injected as<meta http-equiv="Content-Security-Policy">after andbox's bootstrap script.evaluate()compiles code withnew Function, so a policy that restricts scripts must allow'unsafe-eval', plusblob:fordefineModule()modules and the hosts you import from. Example:"default-src 'none'; script-src 'unsafe-eval' blob: https://esm.sh; img-src data:"leaves the frame no network except module imports from esm.sh.iframeSandbox: ['allow-forms', 'allow-popups', ...]adds sandbox tokens.'allow-same-origin'is refused: combined withallow-scriptsit puts the frame in your origin, where it can reach your page and storage and delete its ownsandboxattribute.dangerouslyAllowSameOrigin: truepermits it for code you trust completely.- Browser only. Without a DOM (Node, a Worker)
createSandbox({ mode: 'iframe' })rejects.workerFactory,nodeWorkerand the wasm limits do not apply;untrusted: truestill meansmode: 'wasm'. - Startup is bounded by
defaultTimeoutMs: a frame that never completes its handshake (for example acontainerthat is not in a document) rejectscreateSandbox()and is removed.
frameSrc: where the frame may navigate itself (0.3.2, andbox#50). A sandboxed frame can always navigate itself: location = 'https://elsewhere/?' + data, a link it clicks, a <meta http-equiv="refresh">. andbox notices the unload and replaces the frame, but by then the request (and whatever the URL carried) has gone out. Nothing inside the frame can stop it: CSP has no navigation directive (navigate-to never shipped), no sandbox token forbids a frame's own navigation, and the Navigation API's navigate event does not fire in an opaque-origin document. What does work is the embedding document's frame-src, which the browser checks before a child frame's navigation request is sent. Your page usually can't carry that policy (it embeds other things, and a page's CSP can't change), so frameSrc gives the frame its own embedding document:
const pane = await createSandbox({
mode: 'iframe',
container: document.querySelector('#pane'),
frameSrc: ['https://example.com'], // CSP source expressions; [] allows none
});
pane.iframe; // the wrapper: the element in your page (style it, size it)
pane.frameWindow; // the window the code runs in (post to it, compare event.source with it)- How. The element andbox places (
sandbox.iframe, whatonFramegets andcontainerholds) becomes a same-origin wrapper: asrcdocdocument with no script whose only policy isframe-src <frameSrc>(frame-src 'none'for[]). andbox builds the sandboxed frame inside it once it has loaded, filling it (width/height: 100%, no border). A navigation to a host not in the list is blocked before any request is made (the frame typically shows the browser's error page; andbox sees the unload, the pending call rejects withSandbox iframe unloaded ...and the next call gets a new frame); a listed host loads as before.test/browser/frame-src.spec.mjschecks with local stub servers that a blockedlocation =, link click and meta refresh send nothing, that a listed host gets its request, and that evaluate,host.call, consoles and bridges work through the wrapper. - The handshake and the host page. Inside the frame the host page is
parent.parent(parentis the wrapper, same-origin with your page but not reachable from the opaque frame). andbox's own handshake uses it; if your code in the frame talks to the page withpostMessage, post toparent.parent, and on the page compareevent.sourcewithsandbox.frameWindow(notsandbox.iframe.contentWindow, which is now the wrapper's window).frameWindowalso works withoutframeSrc, where it isiframe.contentWindow. - What is copied in. The wrapper's
allow,allowfullscreenandtitleattributes (as set by the time it loads, e.g. inonFrame) are copied to the sandboxed frame: permissions policy has to be delegated at every level. The wrapper document takes the element's computedcolor-schemewhen it is built, so a frame with a different scheme gets the same backdrop as without the wrapper; a later change to the page's scheme reaches it with the next frame. - What it inherits. The sandboxed frame is a
srcdocframe, so it inherits the wrapper's policy as well as your page's: frames it makes itself are held to the same list. That is also why the wrapper has nothing butframe-src: ascript-srcthere would block andbox's bootstrap. - What it does not cover. Only navigation of the frame (and of frames inside it).
fetch,XMLHttpRequest,WebSocket,<img>,import()and the like are still governed bycsp(ornetwork); popups, forms and top-level navigation are refused by the sandbox unless you add the tokens. Anabout:blanknavigation makes no request and is not affected. WithoutframeSrcthere is no wrapper and nothing changes.
Synchronous infinite loops depend on the browser's process model. An await-based hang (a promise that never settles, a long setInterval) is always killed on time. A synchronous while (true) {} can only be killed when the browser runs the frame on another thread:
- Desktop Chrome (site isolation on, the default; checked with Chrome 154) runs a sandboxed frame in its own renderer process. The timeout fires on time, the frame is removed, and the next call gets a new frame in milliseconds, provided no other sandboxed frame from your site shares that process. Chrome groups them by site, so while another andbox iframe (or any other opaque-origin frame from your site) is alive, the looping process cannot be shut down: the replacement frame lands in it and its startup times out, and those other frames stop responding too.
- WebKit (Safari's engine) and Chromium without full site isolation (measured: Playwright's WebKit, and Playwright's Chromium without
--site-per-process) run the frame on the host page's main thread. A synchronous infinite loop freezes your page, the timeout cannot fire, and the browser's own "page unresponsive" handling is the only way out. Chrome on Android, which does not isolate every site, is expected to behave the same; Firefox was not measured for this release.
Use mode: 'wasm' (fuel and deadline inside the engine) or worker mode for code that may spin, and iframe mode for code that needs the DOM. examples/09-iframe-browser/ is a working two-pane notebook demo; test/browser/iframe-mode.spec.mjs runs the contract in Chromium, Firefox and WebKit.
Mediated network: network
Worker mode removes fetch from the sandbox. Code you hand a capability to can call host.call(...), but a library imported into the sandbox (d3.json(), ky, an API client) calls the global fetch and fails. The network option (0.1.3, andbox#39) installs a global fetch in the sandbox that sends every request to a function on the host, which decides what happens. No network unless you list hosts: since 0.2.0 (andbox#43) network.allowedHosts is required, so setting network never means "every host" by accident:
import { createSandbox } from '@johnhenry/andbox';
const sandbox = await createSandbox({
network: {
allowedHosts: ['api.example.com'], // required: checked on the host before fetch is called
// Optional. Runs on the host for every allowed request. Same signature as fetch.
async fetch(url, init) {
console.log(init.method, url); // you see every request
return fetch(url, { ...init, referrerPolicy: 'no-referrer' }); // init.credentials is 'omit'
},
},
policy: { capabilities: { fetch: { maxCalls: 100 } } }, // the usual gate applies
});
await sandbox.defineModule('lib', `export const getJSON = async (u) => (await fetch(u)).json();`);
await sandbox.evaluate(`
const { getJSON } = await sandboxImport('lib'); // knows nothing about andbox
return getJSON('https://api.example.com/items');
`);Inside the sandbox, fetch(input, init) resolves relative URLs against baseURL, refuses anything that is not http:/https: with a TypeError, lets the platform normalise the method, headers and body (strings, typed arrays, Blob, FormData, URLSearchParams, a Request), and sends only the URL, method, header pairs, body and redirect mode to the host. credentials, mode, cache, referrer and the other RequestInit fields are dropped. It resolves with a real Response (status, statusText, headers, url, redirected, json()/text()/arrayBuffer()/body). A host error rejects with TypeError('fetch failed: ...'), and init.signal rejects the call with AbortError. Everything else on the lockdown list (XMLHttpRequest, WebSocket, EventSource, WebTransport, importScripts, ...) stays removed.
On the host, the request becomes an ordinary capability named fetch, so it goes through the capability gate and policy like any other (policy.capabilities.fetch limits it; stats().gate.perCapability.fetch counts it; binary bodies travel as base64, so maxArgBytes counts them). The host side treats it as untrusted input, because evaluated code can also call host.call('fetch', url, init) directly: it checks the URL again (http(s) only), validates the method, header pairs, body and redirect, checks the host against allowedHosts, and then calls your function as fetch(url, init) with init.headers a Headers, init.body a string or Uint8Array, init.credentials set by the host, and init.signal aborted when the sandbox is terminated (it is called without a this, so network: { allowedHosts, fetch } with the platform's own fetch works). Return a Response, or a plain { status, statusText, headers, body, url, redirected } (body a string, ArrayBuffer, typed array or Blob). Set-Cookie is never passed to the sandbox, and opaque or error responses (status outside 200-599) reject.
| network field | Type | Default | Description |
|---|---|---|---|
| allowedHosts | string[] \| (url: URL) => boolean \| '*' | required | Which hosts the sandbox may reach, checked on the host before fetch is called (forms below). Leaving it out throws at createSandbox(). |
| fetch | (url, init) => Response \| { status, headers, body, ... } | the platform's global fetch | Host function for every request allowedHosts lets through. |
| credentials | 'omit' \| 'same-origin' \| 'include' | 'omit' | What the host passes as init.credentials. The sandbox cannot change it. |
allowedHosts takes one of three forms:
// 1. A list of hostnames: the sandbox reaches these and nothing else.
network: { allowedHosts: ['api.example.com', 'cdn.example.com'] }
// 2. A function, asked on the host for every request (and every redirect hop):
// for an allowlist that changes while the sandbox runs, like a notebook
// that asks the user per host. Only `true` (or a promise of it) allows.
const approved = new Set(['api.example.com']);
network: { allowedHosts: (url) => approved.has(url.hostname) }
// 3. The explicit opt-in to any http(s) host. Your fetch is then the whole
// policy, so write it next to one that enforces its own rules.
network: { allowedHosts: '*', fetch: myPolicyFetch }- A list matches the request URL's hostname exactly: case-insensitive, normalised the way
URLdoes it (IDN to punycode, IPv4 forms to dotted decimal), on any port and either scheme. There is no subdomain or wildcard matching:'example.com'does not allowapi.example.com, and'api.example.com'does not allowexample.com. Write IPv6 in brackets ('[::1]'). An entry with a scheme, port, path or*throws instead of silently never matching, and an empty list throws (to give the sandbox no network, leavenetworkout). It iscreateNetworkFetch()in front of yourfetch. - A function gets a fresh
URL(changing it does not change the request). Anything other thantruerefuses withNetwork access denied: <host> is not allowed by network.allowedHosts; if it throws, the sandbox'sfetchrejects with that error's message, which is a good place for "allow this host in settings". It may beasync. '*'skips the host check (the URL must still be http(s)) and leaves everything else, redirects included, to yourfetch. Without afetch, it is the platform'sfetchfor any host.
Redirects. With a list, every request is made with redirect: 'manual' and any redirect response is refused, so an allowlisted host cannot send the request elsewhere. With a function, andbox follows redirects itself: each request is made with redirect: 'manual', and for every Location it asks your function again before requesting it (at most 20 hops; 301/302 after a POST and 303 become a GET without a body, as the Fetch standard does; Authorization is dropped when the hop changes origin). The sandbox sees the final url and redirected: true; redirect: 'error' from the sandbox rejects on a redirect and redirect: 'manual' returns it unfollowed. A browser's own fetch hides a manual redirect's target (an opaqueredirect response), so over the page's fetch a function policy cannot check the next hop and the request fails rather than following it blindly; on Node, Deno and Bun, or with a network.fetch that returns the 3xx response, redirects are followed and checked. With '*', your fetch receives the sandbox's redirect mode and decides; the platform fetch follows redirects to any host.
On a server, the host function has the server's network position. With node-worker (or any server-side host), whatever allowedHosts lets through is fetched from inside your network: localhost, private addresses, and cloud metadata endpoints such as 169.254.169.254 are reachable if the policy allows them. Prefer a list; with a function or '*', refuse private and link-local addresses yourself (and remember DNS can point a public name at one).
Modes. worker and node-worker: the shim is the only fetch the code can reach by name. iframe: it replaces the frame's own fetch, but the frame still has its other network APIs (XMLHttpRequest, WebSocket, <img>, import()); add csp: "connect-src 'none'" (and default-src as needed) to leave the host function as the only way to fetch, since the shim talks to the host over a MessagePort that CSP does not affect. wasm, inline, data-uri and service-worker throw if network is given (in wasm, expose a capability and use host.call()). Passing both network and a capability named fetch throws.
Limits. The response body is buffered on the host and copied into the sandbox (no streaming; enforce size limits in your function, for example from content-length and arrayBuffer().byteLength). Aborting the sandbox-side signal rejects the call immediately but does not cancel the host request; terminating the sandbox does (init.signal). The rebuilt Response has type: 'default'. What this does and does not narrow is in the Security model.
Bridges: sharing a host API
A capability is one function. Some host APIs are more than that: a model session you create, prompt several times and destroy; a stream of chunks; a call you want to cancel; a progress callback. bridges (0.3.0, andbox#46) shares such an API with sandboxed code without sharing a host object. Each entry becomes a global in the sandbox whose methods send structured-clone messages to functions you define on the host. It is the same pattern as network: a sandbox-side shim backed by a gated host side.
import { createSandbox, defineBridge } from '@johnhenry/andbox';
const notes = defineBridge({ // defineBridge() only validates; a plain object works too
api: {
// Every method gets a context first: ctx.signal, ctx.state, ctx.handle(), ctx.target.
open: { handle: true, call: (ctx, name) => ctx.handle('Notebook', new Notebook(name)) },
search: (ctx, query, options) => db.search(query, { signal: ctx.signal }),
},
handles: {
Notebook: { // host objects the sandbox holds handles to
methods: {
add: (ctx, text) => ctx.target.add(text), // ctx.target is the host object
read: { stream: true, call: (ctx) => ctx.target.entries() }, // ReadableStream or (async) iterable
},
props: ['name', 'size'], // copied to the sandbox, refreshed after every call
destroy: (notebook) => notebook.close(), // on handle.destroy() and when the sandbox goes away
},
},
limits: { maxHandles: 4, maxStreams: 2, maxResultBytes: 1_000_000, timeoutMs: 30_000 },
onRequest: async ({ method, args, handle }) => askTheUser(method, args), // only `true` allows
});
const sandbox = await createSandbox({
bridges: { notes },
policy: { capabilities: { 'notes.Notebook.add': { maxCalls: 100 } } }, // the gate applies per method
});
await sandbox.evaluate(`
const book = await notes.open('ideas'); // a handle: methods, props, destroy()
await book.add('hello');
for await (const entry of book.read()) console.log(entry); // a ReadableStream, pulled chunk by chunk
const c = new AbortController();
setTimeout(() => c.abort(), 100);
await notes.search('x', { signal: c.signal }).catch((e) => e.name); // 'AbortError'; ctx.signal aborts on the host
book.destroy();
`);Inside the sandbox the global (notes, also on globalThis, so imported libraries see it) has the api tree as nested namespaces of async methods. Arguments and results cross by structured clone (Map, Date, typed arrays, Blob survive; a result that cannot be cloned rejects with DataCloneError instead of leaking anything). Host errors reject with their name and message (a DOMException name such as NotAllowedError or QuotaExceededError becomes a DOMException, TypeError/RangeError stay those types; the host stack never crosses).
- Handles. A method that returns
ctx.handle(type, object)gives the sandbox an ordinary object with the type's methods, itspropsas read-only getters (snapshot values, refreshed after every call on it),destroy()and[Symbol.dispose]. The host object stays on the host under a random id. A handle can be passed back as an argument (the host method receives the host object). Afterdestroy(), calls reject withInvalidStateErrorand pending ones withAbortError, as Chrome's AI sessions do. Mark a methodhandle: truesolimits.maxHandlesis checked before it runs (otherwise an over-limit object is destroyed right after it is made). - Streams. A
stream: truemethod returns aReadableStreamat once (likepromptStreaming()), async-iterable in every engine. It is pull-driven: the host reads its stream or iterator only when the sandbox asks for the next chunk, so a slow reader slows the producer.cancel(), abreakout offor await, an aborted signal orlimits.timeoutMscancel the host stream and abortctx.signal. - Abort. An
AbortSignalanywhere in the arguments rejects the call in the sandbox with the signal's reason at once and abortsctx.signalon the host. An already-aborted signal never reaches the host.ctx.signalalso aborts on timeout,destroy()of the handle, and when the Worker/frame is terminated. - Callbacks (reverse calls). A function in the arguments becomes an async proxy on the host: calling it runs the function in the sandbox and resolves with its (cloned) result. It lives as long as the call, or, when the call returned a handle, as long as that handle; after that, and when the sandbox goes away, calling the proxy rejects. Use it for progress events and anything the host must ask the sandbox; never for code you would run on the host.
On the host, every method call is a capability call in the capability gate named ${bridge}.${method} (notes.open, notes.Notebook.add), so policy limits it and stats().gate.perCapability counts it; host.call() cannot reach those names. Then onRequest is asked (if given), then the method runs. stats().bridges.notes shows live handles, streams and pendingCalls, plus whatever the definition's stats(state) returns.
| Definition field | Type | Description |
|---|---|---|
| api | tree of methods | Namespaces and methods of the global. A method is (ctx, ...args) => value or { call, stream?, handle?, requiresUserActivation? }. |
| handles | { [type]: { methods?, props?, destroy? } } | Host object types. methods get ctx.target; props are copied (primitives and arrays of primitives); destroy(target) releases it. |
| limits | { maxHandles, maxStreams, maxResultBytes, timeoutMs } | Per sandbox; defaults 32, 8, 0, 0 (0 = unlimited). maxResultBytes is approximate (strings as UTF-8, binary by byteLength) and applies to each result and stream chunk; timeoutMs covers one call, and a stream until it ends (TimeoutError). |
| onRequest | (request) => boolean \| Promise<boolean> | Consent hook: { bridge, method, args, handle, requiresUserActivation, userActivation, signal }. Only true allows; false, anything else or a throw rejects in the sandbox with NotAllowedError (a thrown error's message is kept). It may wait for the user; request.signal aborts if the sandbox gives up. Asked for every method call, not for destroy(), cancel or abort. |
| createState, stats | functions | Per-sandbox state (ctx.state, kept across restarts) and extra stats() fields. |
| client, clientOptions | function, JSON | A self-contained function run in the sandbox with the generated global, to adapt shapes a message cannot carry (the Chrome AI bridge uses it for monitor). |
| globals | { alias: 'path' } | Extra sandbox globals pointing into the api, e.g. { LanguageModel: 'languageModel' }. |
User activation. Some host APIs need a gesture on the page (Chrome's model download). andbox cannot grant activation, and the sandbox cannot produce it. Mark the method requiresUserActivation: true | 'transient' | 'sticky', or a function (ctx, ...args) that decides per call. onRequest then sees requiresUserActivation and the page's current userActivation, and after it allows, andbox checks navigator.userActivation and refuses with NotAllowedError if the page has none. The way an app satisfies it: show a button and resolve onRequest from its click handler, so the method runs while the page has activation (sticky activation lasts for the page's lifetime; transient lasts a few seconds). Where the platform has no navigator.userActivation (Node), the check is skipped.
Lifetime. Handles, streams and callbacks belong to the Worker or frame that made them. When it is terminated (dispose(), a timeout or abort of evaluate(), a crash, an iframe navigation) every handle's destroy runs on the host, every stream is cancelled, every in-flight call's ctx.signal aborts, and pending reverse calls reject. The next evaluate() runs in a fresh runtime with a fresh global. Keep defaultTimeoutMs above your slowest bridge call: a timed-out evaluate() destroys the sandbox's handles with the runtime.
Modes. worker, node-worker (the host API just has to exist in Node) and iframe. wasm, inline, data-uri and service-worker throw if bridges is given: the QuickJS guest has no ReadableStream, AbortSignal or EventTarget and only JSON crosses its boundary, so in wasm expose capabilities and use host.call(). A bridge name or alias must be an identifier that is not a runtime name (host, sandboxImport, console, fetch, self, ...); method names may not be then, constructor, destroy and the like; a capability with a bridge method's gate name throws.
Not supported (v1). Events on handles (addEventListener on a session, e.g. Chrome's contextoverflow), getters that change between calls without a call (props are snapshots), passing a stream or a function in a result, and markers: an argument object with an own __andbox_bridge__ key is read as a function/signal/handle reference.
Chrome AI bridge
Chrome's built-in AI APIs (LanguageModel, Summarizer, Writer, Rewriter, Translator, LanguageDetector, Proofreader) exist only in Window contexts: not in Workers, and in a sandboxed opaque-origin iframe they are blocked by permissions policy (availability() is 'unavailable', create() throws NotAllowedError; measured in Chromium). So sandboxed code can only reach the on-device model through the page. @johnhenry/andbox/bridges/chrome-ai is that bridge, with no dependencies:
import { createSandbox } from '@johnhenry/andbox';
import { chromeAI } from '@johnhenry/andbox/bridges/chrome-ai';
const sandbox = await createSandbox({
bridges: {
ai: chromeAI({
apis: ['languageModel', 'summarizer'], // default: all seven
budgets: { maxInputTokens: 20_000, maxInputTokensPerCall: 2_000 },
onRequest: (request) => consentDialog(request), // e.g. resolve(true) from an "Allow" button's click handler
}),
},
defaultTimeoutMs: 120_000, // model calls are slow; a timed-out evaluate() destroys its sessions
});
await sandbox.evaluate(`
if (await ai.languageModel.availability() === 'unavailable') return null;
const session = await ai.languageModel.create({
initialPrompts: [{ role: 'system', content: 'Be brief.' }],
monitor(m) { m.addEventListener('downloadprogress', (e) => console.log(e.loaded)); },
});
let text = '';
for await (const chunk of session.promptStreaming('Summarize andbox.')) text += chunk;
console.log(session.contextUsage, '/', session.contextWindow);
session.destroy();
return text;
`);In the sandbox, ai.languageModel, ai.summarizer, ai.writer, ai.rewriter, ai.translator, ai.languageDetector and ai.proofreader mirror the platform constructors: availability(options), create(options) (plus params() for languageModel where the host has it, i.e. extensions). chromeAI({ globals: true }) also installs LanguageModel, Summarizer, ... as sandbox globals, so code written for the page runs unchanged. Sessions are handles with the platform's methods and attributes:
| API | Methods | Attributes (snapshots) |
|---|---|---|
| languageModel | prompt, promptStreaming, append, measureContextUsage, measureInputUsage, clone, destroy | contextUsage, contextWindow, samplingMode; legacy inputUsage, inputQuota, topK, temperature where the host has them |
| summarizer, writer, rewriter | summarize/write/rewrite, their ...Streaming, measureInputUsage, destroy | sharedContext, type/tone, format, length, expectedInputLanguages, expectedContextLanguages, outputLanguage, inputQuota |
| translator | translate, translateStreaming, measureInputUsage, destroy | sourceLanguage, targetLanguage, inputQuota |
| languageDetector | detect, measureInputUsage, destroy | expectedInputLanguages, inputQuota |
| proofreader | proofread, measureInputUsage, destroy | includeCorrectionTypes, includeCorrectionExplanations, expectedInputLanguages, correctionExplanationLanguage |
On the host, the platform objects are looked up on globalThis at call time (chromeAI({ scope }) substitutes another object, which the tests use for a fake). An API the browser lacks reports 'unavailable' and its create() rejects with NotSupportedError; a method the browser lacks rejects with NotSupportedError. Every call passes the platform the call's ctx.signal, so a sandbox-side signal, session.destroy(), a timeout or the sandbox going away aborts the model call. Gate names are ai.languageModel.create, ai.LanguageModel.prompt, ai.Summarizer.summarize, ... (policy.capabilities['ai.LanguageModel.prompt']). Default limits: 8 live sessions and 4 open streams per sandbox.
- Consent and the download gesture.
onRequestsees every call, e.g.{ method: 'LanguageModel.prompt', args: ['...'] }, and can show the prompt text, refuse, or wait.create()needs a user gesture when the model is not on disk yet (availability()is'downloadable'); the bridge then reportsrequiresUserActivation: 'sticky'and refuses withNotAllowedErrorif the page has never had activation. ResolveonRequestfrom a click handler (an "Allow download" button) and the download proceeds. The check is against the page that owns the bridge, and activation is not consent: a click inside aniframesandbox also activates its parent page (the HTML spec propagates activation to ancestor frames; measured in WebKit), so sandboxed code that gets the user to click its own button satisfies the activation check. Decide inonRequest, with UI the sandbox cannot draw. - Token budgets.
budgets.maxInputTokensPerCallandbudgets.maxInputTokens(per sandbox) are enforced on the host: before everyprompt,promptStreaming,append,summarize,write,rewrite,translate,detectandproofread, the input is measured with the session'smeasureContextUsage()(ormeasureInputUsage()), and a call over budget rejects withQuotaExceededError(requested,quota) before it reaches the model. The tokensinitialPromptsuse count atcreate(). Output tokens are not counted (each session'scontextWindowbounds them). With a budget set, an API that cannot measure (the Proofreader spec has nomeasureInputUsage) rejects instead of running unmetered.stats().bridges.ai.inputTokensis the running total. monitorworks as on the page: it is called in the sandbox with anEventTargetthat receivesdownloadprogressevents (loaded0 to 1,total1; aProgressEventwhere the runtime has one), forwarded from the host's monitor as reverse calls.
Differences from the platform API.
create()options and inputs cross by structured clone: images and audio must be cloneable (Blob,ImageBitmap,ImageData,ArrayBuffer; notAudioBuffer); anHTMLCanvasElementorHTMLImageElementfrom an iframe sandbox's document rejects withDataCloneError, so convert it first.- Attributes are snapshots refreshed after each call on the session; there are no events on sessions (
contextoverflow/quotaoverfloware not forwarded). measureContextUsage()andmeasureInputUsage()are both available and call whichever the browser has.- Tool use. Chrome's Prompt API has no
executecallback: tool use is open-loop and, as of Chrome 152, behind a flag. The bridge passes tool declarations through, returnstool-callcontent as plain{ callID, callId, name, arguments }objects, and turnstool-responsecontent you send back ({ callID, name, result }or{ callID, name, errorMessage }) into the platform'sLanguageModelToolSuccess/LanguageModelToolError; atool-callininitialPromptsor a prompt (replayed history) becomes aLanguageModelToolCall. The platform spells the idcallIDand requires it in every init; the sandbox side acceptscallIDorcallIdand emits both, and a missing id is sent as''. Chrome returns an emptycallID(measured in Chrome 154), so match responses to calls by order orname, not by id. Chrome refusestoolsunlessexpectedOutputsincludes{ type: 'tool-call' }; the bridge refuses the samecreate()with the sameTypeErrorbefore it reaches the platform. A tool with anexecutefunction is refused withNotSupportedErrorrather than silently ignored; when the platform gains closed-loop tools, bridgingexecuteis a reverse call (it would run in the sandbox, never on the host).promptStreaming()chunks are passed through unconverted, so tool calls are only bridged fromprompt(). samplingMode,topK,temperature,params(): whatever the host's browser supports (the web ignorestopK/temperature;params()exists only in extensions).
Where it works. The host must be a page (Window) where the API is enabled: Chrome desktop 138+ for Summarizer, Translator and LanguageDetector, 148+ for the Prompt API on the web; Writer, Rewriter and Proofreader are origin-trial or flag only. A page inside a cross-origin iframe needs allow="language-model; summarizer; ..." delegated to it. test/browser/bridges.spec.mjs runs the bridge against a fake LanguageModel in Chromium, Firefox and WebKit (CI has no model) and a smoke test against the real API where the browser has it; examples/11-chrome-ai-browser/ is a working page with a consent prompt.
API
createSandbox(options?)
Creates a new sandboxed runtime. Returns a promise (Worker, wasm and iframe modes) or object (inline/data-uri mode).
Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| mode | 'worker' \| 'node-worker' \| 'wasm' \| 'iframe' \| 'inline' \| 'data-uri' \| 'service-worker' | 'worker' | Execution mode |
| importMap | { imports?, scopes? } | {} | Import map for package resolution (Worker mode) |
| capabilities | Record<string, Function> | {} | Host functions callable via host.call() (Worker mode) |
| defaultTimeoutMs | number | 30000 | Default timeout for evaluate() |
| baseURL | string | location.href | Base URL for relative imports |
| allowedImportHosts | string[] | unset | Restricts sandboxImport() of remote http(s) modules to these hostnames (plus baseURL's own host). Unset: remote imports are allowed. Provided: only listed hosts; [] denies all remote imports. Import-map targets are host-authored and always allowed. |
| untrusted | boolean | false | Convenience for untrusted code: selects mode: 'wasm'. Throws if combined with another mode, and rejects (never falls back to a Worker) if wasm mode is unavailable. See Security model. |
| policy | GatePolicy | -- | Rate limiting policy |
| onConsole | (level, ...args) => void | -- | Console output handler |
| globals | Record<string, any> | {} | Global variables (inline/data-uri modes) |
| engineURL, wasmURL | string | -- | mode: 'wasm': same-origin URLs of the bundled engine module and the .wasm (optional under Node) |
| fuel, memoryBytes, stackBytes, deadlineMs | number | see Limits | mode: 'wasm' limits (also accepted per evaluate() call) |
| container | Element | offscreen in document.body | mode: 'iframe': element the frame is appended to. A restarted frame takes its predecessor's place instead. |
| html | string | '' | mode: 'iframe': initial <body> markup of every new frame |
| csp | string | -- | mode: 'iframe': Content-Security-Policy for the frame (<meta http-equiv>); must allow 'unsafe-eval' if it restricts scripts |
| iframeSandbox | string[] | [] | mode: 'iframe': extra sandbox tokens (allow-scripts is always set); 'allow-same-origin' throws unless dangerouslyAllowSameOrigin |
| dangerouslyAllowSameOrigin | boolean | false | mode: 'iframe': permit 'allow-same-origin', which removes the origin boundary |
| onFrame | (iframe) => void | -- | mode: 'iframe': called with every new frame (first and after each restart) before it is attached |
| frameSrc | string[] | unset | mode: 'iframe': nest the frame in a same-origin wrapper whose CSP is frame-src with these hosts, so the frame cannot navigate itself (and send a request) anywhere else. sandbox.iframe is then the wrapper; the code's window is sandbox.frameWindow. See frameSrc. |
| network | { allowedHosts, fetch?, credentials? } | unset | worker, node-worker, iframe: install a global fetch in the sandbox that sends every request through the host function (the gated fetch capability). allowedHosts is required (a hostname list, a (url) => boolean function, or '*'); without it createSandbox() throws. Unset: no fetch in worker modes (unchanged). See Mediated network. |
| bridges | Record<string, BridgeDefinition> | unset | worker, node-worker, iframe: one sandbox global per entry that proxies a host API, with handles, streams, abort, callbacks, consent and budgets. See Bridges and the Chrome AI bridge. |
Returns (Worker mode): Promise<{ evaluate, defineModule, dispose, stats, isDisposed }>. mode: 'iframe' adds iframe, the live HTMLIFrameElement (null after dispose(), replaced after a restart; see mode: 'iframe'), and a non-enumerable frameWindow, the window the code runs in (with frameSrc, inside the wrapper).
sandbox.evaluate(code, opts?)
Evaluates JavaScript code in the sandbox. The code is wrapped in an async IIFE -- use return to produce a result.
| Option | Type | Description |
|--------|------|-------------|
| timeoutMs | number | Override default timeout |
| signal | AbortSignal | Abort evaluation |
| onConsole | (level, ...args) => void | Per-call console handler: gets this call's console output only |
| consoleId | string \| number | A name for this call's console output: every console handler gets it as this.consoleId |
Inside sandbox code (Worker mode):
host.call(name, ...args)-- Call a host capability by namesandboxImport(name)-- Import a virtual moduleconsole.log/warn/error/info-- Forwarded to hostonConsole
Console attribution (0.3.1). Calls on one sandbox may overlap, and each call's console output goes to that call's own onConsole while it is running, never to whichever call started last (andbox#41). Output from a call that passed no onConsole, and output that arrives after a call settled (a timer it left behind), goes to the sandbox-level onConsole. Every handler is called with this.consoleId, the consoleId of the call that logged (undefined if it gave none), so late output can still be credited (use a function, not an arrow):
const sb = await createSandbox({
onConsole(level, ...args) { log(this.consoleId ?? 'sandbox', level, args); },
});
await Promise.all([
sb.evaluate(codeA, { consoleId: 'pane-a' }),
sb.evaluate(codeB, { consoleId: 'pane-b' }),
]);What it covers: the console andbox gives the evaluated code, including closures that keep it (a timer, a callback) after the call returns. It is attribution, not authentication: the runtime carries the id with each message, so code in the sandbox that holds another call's console logs as that call. Thread stdout/stderr (node-worker's captureStdio) names no call and goes to the newest running call's handler, as before.
Errors keep the sandbox's stack (0.3.1). A rejected evaluate() has the error's name and message (and code, when set); err.stack is the host's own stack, and err.sandboxStack is the stack as the sandbox saw it, with the evaluated code's frames, line numbers and any //# sourceURL= names (andbox#41). Line numbers count from andbox's wrapper, which puts three lines before your code in the new Function-based modes: in V8 (Chrome, Node) line 1 of your code is reported as line 4; other engines may count differently. It is undefined when the sandbox reported no stack.
sandbox.defineModule(name, source)
Defines a virtual module that sandbox code can import via sandboxImport(name).
sandbox.dispose()
Terminates the Worker (removes the frame in mode: 'iframe') and rejects all pending evaluations.
sandbox.stats()
Returns runtime statistics including pending evaluations, virtual modules, and gate stats.
gateCapabilities(capabilities, policy?)
Wraps host functions with rate limiting and payload caps.
import { gateCapabilities } from '@johnhenry/andbox';
const { gated, stats } = gateCapabilities(
{ fetch: async (url) => (await fetch(url)).text() },
{
limits: { maxCalls: 100, maxArgBytes: 1_000_000, maxConcurrent: 8 },
capabilities: { fetch: { maxCalls: 50 } },
}
);resolveWithImportMap(specifier, importMap, parentURL?)
Resolves a module specifier against an import map, following the browser import map algorithm.
createVirtualModuleRegistry(files?, options?)
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.
import { createVirtualModuleRegistry } from '@johnhenry/andbox';
const registry = createVirtualModuleRegistry({
'index.js': 'import { add } from "./util.js"; export const result = add(2, 3);',
'util.js': 'export function add(a, b) { return a + b; }',
});
registry.resolve('index.js'); // importable URL, or null if unknown
registry.source('index.js'); // the registered source text
registry.resolveSpecifier('./util.js', 'index.js'); // resolves relative to the importing file
registry.define('extra.js', 'export const x = 1;'); // register (or replace) a file at runtime
registry.dispose(); // revokes blob URLs / drops the Node filesresolveSpecifier(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.
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.
Honest caveat (browser / blob: backend): this only solves module resolution (the URL a given specifier
