Status: Canonical description of the implementation on main.
This document describes what the prototype implements. Historical rationale is recorded in architectural-decisions.md, and the versioned architecture diagrams are indexed in ../README.md.
The plugin isolates configured browser-side npm libraries inside QuickJS contexts compiled to WebAssembly. Its objective is to prevent a library from automatically inheriting the host page's global authority while retaining enough API compatibility for evaluated applications.
The prototype protects the host realm through two controls:
- Execution isolation: library code and its globals execute in a per-library QuickJS context rather than the host JavaScript realm.
- Capability selection: the runtime exposes only host globals listed for that library in
.sandbox/sandbox.config.js.
This boundary does not make every object safe. A host object passed as a function argument is itself a capability and may expose further reachable authority. The current compatibility-first treatment of such arguments is documented in host-argument-capability-limitation.md.
The Webpack-facing API is:
export interface SandboxPluginOptions {
configPath: string;
}Use it in a Webpack configuration:
new WebpackSandboxPlugin({
configPath: "./.sandbox/sandbox.config.js",
});configPath is resolved from compiler.context. The configuration module may expose its object directly or as a default export. The build fails if the policy file is missing or cannot be loaded, if it does not export a library-keyed object, or if a configured package cannot be resolved from the compiler context.
The configuration object is keyed by package request:
export default {
"example-library": {
permissions: ["Intl"],
workloads: {
example: {
entry: "performance-harness/browser/workloads/example/workload.js",
exports: ["runExample"],
},
},
},
};| Field | Required | Meaning |
|---|---|---|
<library>.permissions |
Yes in maintained configuration | Host global names made available to this library. Use [] for no host capabilities. |
<library>.workloads |
No | Coarse-grained adapters that execute inside the same sandbox as the library. |
workloads.<name>.entry |
Yes for a workload | Repository-relative JavaScript entry bundled by a child compiler. |
workloads.<name>.exports |
Yes for a workload | Functions exposed by the generated workload proxy. |
The plugin treats every top-level key as a library to bundle and redirect. It validates the policy-file presence, module shape, and package resolution before compilation, but does not validate a separate schema for every library option.
WebpackSandboxPlugin.apply() performs these steps during Webpack setup:
- Resolve and load the configuration.
- Collect configured libraries and workload definitions.
- Search
node_modules/<library>recursively for.d.tsfiles. If none exist, searchnode_modules/@types/<library>. - Parse declaration roots with the TypeScript compiler API.
- Generate
.sandbox/proxys/<library>-proxy.jsand optional workload proxies. - Install
NormalModuleReplacementPluginrules for exact library requests andsandbox-workload/<library>/<workload>requests.
The library rule is intentionally exact in the current implementation. A configured root request does not redirect package subpaths; the resulting coverage limitation and future direction are documented in isolation-coverage-and-cache-limitations.md.
The declaration parser records named exports and detects TypeScript export = identifier patterns used by callable singleton packages such as Lodash. It does not map TypeScript types into runtime validation rules. If no declarations are available, the plugin writes a minimal proxy whose default function throws Sandbox Proxy Generation Failed.
During the compiler's asynchronous make hook, the plugin creates a Webpack child compiler for each configured library. Each child compiler:
- resolves the package entry from the parent compiler context;
- uses
EntryPluginfor that entry; - enables Webpack's
varlibrary output; - writes
dist/<library>.bundle.js; - assigns the package export to the QuickJS-side global
__SANDBOXED_LIB__.
Webpack documents a child compiler as a second compiler with its own settings created within the parent compilation. In this prototype, child compilation separates the library asset from the host application bundle; runtime execution in QuickJS creates the actual isolation boundary.
Each configured workload is compiled to:
dist/<library>.<workload>.workload.bundle.js
ExternalsPlugin maps sandbox-real/<library> to __SANDBOXED_LIB__, allowing adapter code to call the already-loaded library without bundling a second copy. A missing workload entry produces a warning and skips that workload bundle; it does not fail normal library compilation.
The generated workload proxy is imported as:
import { runExample } from "sandbox-workload/example-library/example";Workload proxies share their library sandbox. disposeWorkload() is intentionally a no-op; callers dispose the owning library through disposeLibrary().
Generated proxies use top-level await and run in both Node.js and browsers.
- In Node.js, read
dist/<library>.bundle.jsfrom disk. - In a browser, fetch
/dist/<library>.bundle.js. - Call
SandboxManager.createSandbox(libraryName, libraryCode, options). - In Node.js, call
unrefPump()after initialization so the job-pump timer alone does not keep the process alive. - Export wrappers derived from the declarations.
Named functions route calls through a precompiled QuickJS call expression. Classes use librarySandbox.createInstance() so construction and instance identity remain inside QuickJS before the returned proxy is created. Callable singleton exports receive apply, get, and construct traps. Other named values are read from librarySandbox.exports.
Every generated library proxy exposes disposeLibrary(). Applications that create and discard sandboxes dynamically must call it; otherwise the QuickJS context, Arena, helper functions, and job-pump interval remain allocated.
SandboxManager supports synchronous and asynchronous context acquisition:
createSandboxSync()usesgetQuickJSSync();createSandbox()usesnewQuickJSAsyncWASMModule().
Both paths delegate to buildSandbox() and create a new QuickJS context for the library. QuickJS contexts have isolated global objects. The module API used here creates a corresponding runtime whose lifecycle is tied to the context.
buildSandbox():
- creates the registered Arena marshaller configuration;
- creates an Arena for the QuickJS context;
- exposes configured host capabilities;
- evaluates the library bundle and reads
__SANDBOXED_LIB__; - precompiles helpers for temporary-object storage, method calls, property reads, and construction;
- evaluates and binds a workload bundle when its generated workload proxy first requests it;
- wraps the library exports through the reverse membrane;
- starts a 1 ms interval that checks
vm.runtime.hasPendingJob()and callsarena.executePendingJobs().
The job pump exists because Promise reactions queued inside QuickJS do not run automatically from the host event loop. It remains referenced during top-level-await initialization and is unreferenced afterwards only in Node-generated proxies.
Disposal clears the interval, disposes all registered marshallers and precompiled helpers, then disposes the Arena and QuickJS context. Any new handle or helper introduced by a runtime change must participate in this lifecycle.
exposeWebAPIs() reads the selected library's permissions list. Ordinary globals are obtained from globalThis, wrapped, synchronized through Arena, and exposed under the same name.
The forward membrane in forward-membrane.js protects host receiver semantics:
bindWebApi()walks own properties and the prototype chain up toObject.prototype;- functions are rebound to their real owner through
makeCallable(); - nested values are wrapped lazily through accessors;
- a
WeakMappreserves repeated references and handles cycles; - constructor-only host functions first receive
Reflect.apply; recognized constructor errors triggerReflect.construct.
This handling is required for APIs with native internal slots, including Intl objects and browser constructors such as TextEncoder.
The crypto permission is special. The runtime does not expose the complete host Crypto object. It installs a null-prototype QuickJS facade containing:
getRandomValues()for integer typed arrays, enforcing the 65,536-byte limit;randomUUID()when the host implements it.
Random bytes and UUIDs are produced by host functions synchronized into Arena and hidden after the QuickJS facade is installed.
The reverse membrane makes QuickJS exports usable by the host without treating an Arena dump as the authoritative object.
bindSandboxExport()wraps exported objects and functions, caches wrappers, and routes method access back to QuickJS-side closures.bindSandboxFunction()implements direct calls, construction delegation, and static-property access.wrapReturned()pins stateful QuickJS objects inglobalThis.__SANDBOX_TEMP__and routes later methods and getters to the original object.createInstance()constructs in QuickJS before crossing the boundary, preserving WeakMap/private-field identity.- host-side method overrides are retained for test mocking and marshalled back on later calls.
Values that already have supported value semantics—arrays, typed arrays, ArrayBuffer, Date, RegExp, Error, Map, Set, arguments, primitives, and plain objects—are not all pinned through wrapReturned. Their fidelity depends on Arena and the registered marshallers.
.sandbox/marshallers/index.js registers four marshallers in order:
| Marshaller | Purpose |
|---|---|
typed-array.js |
Copies typed-array view bytes and preserves the concrete typed-array constructor. Raw ArrayBuffer is not claimed by this custom marshaller. |
regexp.js |
Preserves pattern source and flags. |
arguments.js |
Tags and recreates arguments-like values. |
error.js |
Recreates supported host/QuickJS error constructors and copies message, stack, cause, and bounded JSON-safe custom fields. |
Each marshaller implements isWrappable, isHandleWrappable, marshal, unmarshal, and dispose. Arena uses the first applicable custom conversion; a marshaller file that is not imported and registered in index.js is inactive.
The repository contains no active generic collection marshaller. Map, Set, DataView, boxed primitives, and other internal-slot types therefore remain known compatibility boundaries rather than documented supported conversions.
The implemented boundary provides:
- separate QuickJS globals and heaps for each configured library;
- no ambient host globals unless configuration exposes them or host arguments convey them;
- membrane-mediated access to explicitly exposed host objects;
- isolation of QuickJS prototype/global mutations from the host realm;
- deterministic host import replacement for evaluated package requests.
It does not provide:
- process, browser, CPU, memory, or execution-time quotas;
- validation of arbitrary package safety or integrity;
- revocation of capabilities supplied through arguments;
- complete JavaScript cross-realm observational equivalence;
- a guarantee that every possible host global is safe when named in
permissions; - independent membrane wrappers when multiple sandboxes receive the same mutable host object identity;
- production packaging, versioned public APIs, or long-term compatibility guarantees.
Cross-runtime membranes cannot transparently preserve every observable property:
- strict object identity and aliasing may differ after copying or wrapping;
- host and QuickJS prototype chains are different, affecting
instanceofand prototype equality; - symbols with equal descriptions do not have equal identity across runtimes;
- functions expose wrapper source rather than the original
toString()result; - native internal-slot methods may reject a proxy receiver;
- copied objects do not preserve every non-enumerable descriptor, accessor, or mutation;
null/undefined, errors, and unusual test-runner values depend on the bridge path;- deeply nested or retained object graphs can increase memory pressure.
These are evaluated per library through the automated compatibility harness. They must be reported as limitations, not silently reclassified as successful compatibility.
To add a library, update the sandbox configuration, ensure declarations exist, build the bundles/proxy, add deterministic test redirection, register the library in the compatibility manifest, and run native and sandbox campaigns.
To add a host capability, test both allowed behaviour and denied reachability.
To add a marshaller, implement the complete marshaller lifecycle, register it in index.js, and test values in both directions, including disposal and error paths.
To change the boundary or proxy semantics, update this specification and architectural-decisions.md in the same change.
Current import-coverage, cache-scope, and dynamic helper-generation limitations are recorded in isolation-coverage-and-cache-limitations.md. Future work must preserve the evaluated compatibility contract while strengthening those boundaries.