The sandbox runtime
A V8 isolate carries the JavaScript language and almost nothing else. Object, Promise, JSON, Intl and Temporal are there because the engine puts them there. But fetch, URL, Headers and TextDecoder are not, because nothing in the engine defines them: they belong to the web platform, and an isolate is not a browser. This runtime is the standard library that closes that gap. It is written in this repository from the standards that govern each interface, bundled into one script, and installed into every isolate before the first line of a program runs.
Runtime deviations lists every place the runtime answers differently from the standard that governs an interface. The internals chapter describes how it is built.
Targeting ECMA-429
Section titled “Targeting ECMA-429”This runtime takes its surface from ECMA-429, Minimum common web API, first edition, December 2025. That standard names the interfaces a non-browser JavaScript runtime should carry, and it defers the behaviour of each member to the W3C or WHATWG text that governs it.
Choosing a standard over a competitor’s feature list buys two things. A standard can be conformed to in writing, so the surface is decided by a document and not by whatever the last runtime shipped. And a standard states how to diverge from it. ECMA-429 §5 requires that a divergence be documented with its explanation and its impact. So the deviations page exists, and it stays complete. Decision 0008 records the move and what it replaced.
Inside a carried interface the governing standard decides the behaviour. Headers follows Fetch, URL follows the URL Standard, structuredClone follows HTML §2.7.3. Familiar code works because the runtime follows the same texts a browser does. So the sandbox writes no instructions to the model about what it may call.
Names published on the global
Section titled “Names published on the global”Installing the runtime puts every name below on the global object. One name is removed. Grouping follows ECMA-429 §5.1 itself: each row is the standard that defines the members beside it and answers any question about how they behave.
| Standard | Published on the global |
|---|---|
| DOM | Event, EventTarget, AbortController, AbortSignal |
| HTML | CustomEvent, ErrorEvent, MessageEvent, PromiseRejectionEvent, the onerror, onunhandledrejection and onrejectionhandled handler attributes, self, atob, btoa, queueMicrotask, reportError, structuredClone, Navigator, navigator |
| Web IDL | DOMException |
| Fetch | fetch, Headers, Request, Response |
| File API | Blob, File |
| XMLHttpRequest | FormData |
| Streams | ReadableStream, ReadableStreamDefaultReader, ReadableStreamBYOBReader, ReadableStreamDefaultController, ReadableByteStreamController, ReadableStreamBYOBRequest, WritableStream, WritableStreamDefaultWriter, WritableStreamDefaultController, TransformStream, TransformStreamDefaultController, ByteLengthQueuingStrategy, CountQueuingStrategy |
| Encoding | TextEncoder, TextDecoder, TextEncoderStream, TextDecoderStream |
| URL | URL, URLSearchParams |
| URL Pattern | URLPattern |
| Web Cryptography | crypto, Crypto, SubtleCrypto, CryptoKey |
| High Resolution Time | performance, Performance |
| Compression | CompressionStream and DecompressionStream, both declared absent |
| Service Workers | caches, declared absent |
globalThis itself is not a plain object here: the runtime puts EventTarget.prototype behind it. ECMA-429 §6 asks the global object to implement EventTarget with no WorkerGlobalScope to inherit from, and the prototype is how that is done. addEventListener and its two siblings answer through the prototype chain, not as own properties.
WebAssembly is the removed name. Its memory is allocated where the isolate’s memory limit cannot meter it. Runtime deviations states that closure along with the two buffer growth operations that stay and throw.
Declared absences fill the last two rows. Those names exist on the global object, answer typeof as if they were served, and reject the first touch with a text naming why and what remains possible. Each wording is wire contract, so the entries live in the declared absences. A capability that is merely not built yet is absent completely and reads as undefined.
Some of what a program can call comes from the sandbox around this runtime and not from the runtime itself. The console a bare isolate already carries is replaced by one whose captured lines travel back with the result. setTimeout and its family arrive over a host delay, because a bare V8 context has no timer facility. Provider proxies turn a tool call into a message to the caller. The module map answers import. All of those are specified in the executor protocol.
How a capability arrives
Section titled “How a capability arrives”Every capability is an explicit addition to a global that starts empty. Decision 0004 states why that direction was taken over the other one: a subtraction list is proven complete only by enumerating a surface nobody fully enumerates, and one missed member is a capability. Nothing on the table above is inherited from the Node process the isolate was created in. What this is describes what a program cannot reach at all.
Most of the library is a reference implementation of its specification, running as ordinary JavaScript inside the isolate. Both per-execution bounds hold on that fact. No native frame of this repository runs inside a guest call, so every allocation a program makes lands in the heap the engine meters, and the interrupt behind the deadline reaches guest code at its back edges. The threat model states what each bound is for and where it stops. But a host bridge exists only where the isolate holds no privilege of its own:
| Privilege | Reached through | Performed by |
|---|---|---|
| A version 4 UUID | crypto.randomUUID() |
The host’s webcrypto |
| Cryptographic random bytes | crypto.getRandomValues(), and the boundary of a multipart/form-data body |
The host’s webcrypto |
| One HTTP exchange | fetch() |
The outbound setting of the request |
One SubtleCrypto operation |
crypto.subtle.* |
The host’s webcrypto, with the key material kept there |
Each of those arrives as a host reference under a global name, and the handshake deletes that name as soon as it has read it. A program meets the capability and never the reference behind it. A spec file runs the check below inside a fully installed isolate, and reads undefined for every one of them:
;[ typeof __hostBindings, typeof __randomUUID, typeof __randomBytes, typeof __sendRequest, typeof __performSubtleOperation,].join(' ')// 'undefined undefined undefined undefined undefined'Each bridge carries bounds of its own. Host work sits outside the isolate’s memory limit and outside its deadline. Runtime deviations states the cap on keys and the one-operation-at-a-time rule of the crypto bridge, and the executor protocol states the payload bound on an outbound request.
Two moments of installation
Section titled “Two moments of installation”Installation happens twice. The split makes a fresh isolate per execution affordable. Once per process, the bundle is evaluated in an isolate created for that purpose, and V8 writes the resulting heap into a startup snapshot. Every interface object, every prototype, every property descriptor and every table a module built while it loaded is in that heap. An isolate created from the snapshot has the whole library already in place and never parses the bundle. Once per isolate, the sandbox hands in the host references under __hostBindings, and the handshake behind that name does its work before deleting it.
flowchart LR bundle["the bundle<br/><small>one generated script</small>"] snapshot["startup snapshot<br/><small>written once per process</small>"] isolate["fresh isolate<br/><small>one per execution</small>"] handshake["the handshake"] program["the program"] bundle -- "installGlobals: every published name and prototype" --> snapshot snapshot -- "restored heap" --> isolate isolate -- "__hostBindings: the host references" --> handshake handshake -- "name deleted" --> program
Most of what the handshake does is there because an isolate cannot inherit it from a snapshot, and each part has its own reason. A clock starts. performance.timeOrigin is the moment this isolate began, and a moment read while the snapshot was written would freeze into every isolate restored from it. An isolate restored much later would then measure from a moment it has no relation to.
The global object gets its listener list. That list lives in a weak collection keyed by the object that owns it, and V8’s serializer drops the entry of a weak collection whose key is the global object. Every other entry crosses, so this is the one list that has to be attached again.
Unmetered allocation paths close. An isolate writing a startup snapshot has neither WebAssembly nor SharedArrayBuffer, so neither the deletion nor the grow replacement would outlive the write, and V8 installs both again on every isolate it restores. ArrayBuffer.prototype.resize is the exception: it is there while the snapshot is written and a replacement of it survives the restore, and it closes here with the other two. One step remains, and it installs the host bindings themselves. They are per-isolate by construction: the keys a crypto bridge holds belong to one execution, and the outbound setting belongs to one request.
packages/executor/src/sandbox/runtime.spec.ts pins that the two moments produce the same runtime. It reads the whole global surface of a restored isolate, every name with how it is written and the members of the interface prototype behind it. That reading is held against an isolate that evaluated the bundle instead, and the two have to be equal. It also pins that restoring costs less than half of evaluating, measured turn by turn so that a machine which stalls stalls both paths.
How the bundle is produced and checked
Section titled “How the bundle is produced and checked”pnpm generate:runtime bundles the runtime into one script with esbuild, as an IIFE, and writes it into a committed TypeScript module. Minification is on, because a smaller script is a smaller snapshot and the snapshot is written on every process start. On the committed tree that size is RUNTIME_SOURCE.length. But names are kept through the minifier on purpose: Web IDL §3.7.1 gives an interface object a name property holding the identifier and §3.7.7 does the same for an operation, so a renaming minifier would quietly change what a caller reads back. Snapshots are taken when a Sandbox is composed, not when an execution runs. A pool child composes exactly one sandbox at startup, so it holds the snapshot before its first request arrives.
Two lanes of pnpm verify keep the generated files honest. Neither reaches the network.
One lane re-bundles the runtime and fails on any difference from the committed copy, so a change to a module that was never bundled cannot reach a snapshot:
$ node tools/executor/generate-runtime-bundle.ts --checkruntime bundle is currentThe other holds the committed Unicode tables against the engine actually running. node tools/executor/generate-idna-tables.ts --check re-renders the module from the tables it carries, checks that the engine implements the Unicode version they were derived from, checks that its tables stay disjoint, and checks that the mapping they define is idempotent, as UTS46 §4 states of its own processing. Its one line of output names the Unicode version it held against and the size of each table. But that lane cannot see whether the tables still agree with Unicode, because only Unicode can say so. The version pin forces a regeneration when the engine moves. The internals chapter describes what those tables are and why they are derived instead of shipped.
Runtime deviations is the list of differences: the interfaces ECMA-429 requires that this runtime does not carry, the behaviour that diverges inside the interfaces it does, and the Unicode and encoding tables it leaves out. Each row states the requirement, the reason and what breaks for code that relies on it: the form the conformance clause asks for. The runtime internals is the machinery: the module map, the Web IDL layer every interface passes through, how the fetch stack and the streams are laid out, how the crypto bridge reaches the host, and what a snapshot-writing isolate does not carry.