Sandbox runtime
V8 gives a bare isolate the JavaScript language and almost nothing else. Object, Promise, JSON, Intl and Temporal are there because the engine defines them. fetch, URL, Headers and TextDecoder are not: 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 here against 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 this runtime differs 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 provide, and it defers the behaviour of each member to the W3C or WHATWG text that governs it. ECMA-429 §5 also requires that a divergence be documented with its explanation and its impact. Runtime deviations exists for that clause, and it stays complete.
Inside an interface this runtime publishes, the standard governing that interface decides the behaviour. Headers follows Fetch, URL follows the URL Standard, and 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. Grouping follows ECMA-429 §5.1 itself: each row names the standard that defines the members beside it, and that standard governs 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. Code calls addEventListener and its two siblings through the prototype chain, not as own properties.
One name is removed instead of published. A WebAssembly memory is allocated where the isolate’s memory limit cannot meter it, so WebAssembly is deleted from the global before a program runs, in the handshake of Two moments of installation. Runtime deviations states that closure together with the two buffer growth operations that stay and throw.
Declared absences fill the last two rows. Those names exist on the global object, and typeof reads each one as if the capability were there. The first touch is where a program learns otherwise: it fails with a text naming why and what remains possible. Every one of those texts is wire contract, and the declared absences states them. A capability this runtime neither implements nor declares absent is absent completely, and typeof on its name reads undefined.
Beyond this runtime, the sandbox installs names of its own. It replaces the console a bare isolate already has with one whose captured lines travel back with the result. setTimeout and its family are built on a host delay, because a bare V8 context has no timer facility. Provider proxies turn a tool call into a message to the caller, and import resolves against the module map. The executor protocol specifies all four.
How a capability arrives
Section titled “How a capability arrives”This runtime adds to a global that starts empty instead of subtracting from a full one. A subtraction list is proven complete only by enumerating a surface nobody fully enumerates, and one missed member is a capability. Every name on the table above was installed on purpose, and none 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 rest on that: no native frame of this repository runs inside a guest call, so every allocation a program makes goes through 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 where the isolate has no privilege of its own, and nowhere else:
| 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 every one of those names is deleted before a program runs. A program meets the capability and never the reference behind it:
await executor.execute({ code: `async () => [ typeof __hostBindings, typeof __randomUUID, typeof __randomBytes, typeof __sendRequest, typeof __performSubtleOperation, ].join(' ')`,})// { result: 'undefined undefined undefined undefined undefined', logs: [] }__hostBindings is the handshake the runtime bundle publishes. A bindings script the sandbox compiles reads it and the four references, deletes all five names, and then calls the handshake with a wrapper around each reference. Each bridge has bounds of its own, because 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”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, so an isolate created from the snapshot never parses the bundle. Restoring one costs under half of evaluating the bundle, measured turn by turn in packages/executor/src/sandbox/runtime.spec.ts so that a machine which stalls stalls both paths. Once per isolate, a bindings script hands the host references in and deletes every name it read, and the handshake it took from __hostBindings runs before the program does.
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>"] bindings["the bindings script<br/><small>reads five names, deletes five</small>"] handshake["the handshake"] program["the program"] bundle -- "installGlobals: every published name and prototype" --> snapshot snapshot -- "restored heap" --> isolate isolate -- "__hostBindings and the four host references" --> bindings bindings -- "a wrapper around each reference" --> handshake handshake -- "clock, closures, listener list, bindings" --> program
The handshake runs four steps before any program runs:
| Step | Why it runs per isolate |
|---|---|
Start the clock behind performance.timeOrigin |
A moment read while the snapshot was written would freeze into every isolate restored from it, and an isolate restored much later would measure from a moment it has no relation to. |
| Close the allocation paths the memory limit cannot see | 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, so it closes here with the other two and not because it has to. |
| Attach the listener list of the global object | The list lives in a weak collection keyed by the object that owns it, and an entry whose key is the global object may not survive the write: snapshots written repeatedly from one script keep it in some and lose it in the rest. An entry keyed on an ordinary object crosses every time, so nothing may rely on the one keyed on the global. |
| Install the host references | The keys a crypto bridge keeps belong to one execution, and the outbound setting belongs to one request. |
runtime.spec.ts also 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, then reads that same surface from an isolate that evaluated the bundle instead. The two have to be equal.
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 makes a smaller snapshot and a process writes that snapshot at every start. packages/executor/src/sandbox/runtime-bundle.generated.ts declares the result as RUNTIME_SOURCE. But the bundle keeps its names through the minifier on purpose: Web IDL §3.7.1 gives an interface object a name property whose value is the identifier and §3.7.7 does the same for an operation, so a renaming minifier would quietly change what a caller reads back. Composing a Sandbox writes the snapshot and running an execution does not, and a pool child composes exactly one sandbox at startup, so it has 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 lane checks the committed Unicode tables against the engine actually running. node tools/executor/generate-idna-tables.ts --check re-renders the module byte for byte from the tables inside it, checks that the engine implements the Unicode version they were derived from, checks that the three 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 checked 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. Whenever the engine moves to a new Unicode version, the pin fails and forces a regeneration. 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.