Runtime
packages/executor/src/sandbox/runtime is the standard library the sandbox installs, and packages/executor/src/sandbox/runtime.ts is the host’s half of it. Runtime states what a program gets from that library and which standard decides each answer, and Runtime deviations lists every interface that diverges from the text governing it.
Every interface here is a reference implementation of the text that governs it, written against that text clause by clause, and each module names the clauses it implements at the top of its own file. Host bridges appear only where the isolate has no privilege of its own.
A JavaScript class does not produce the object shape Web IDL describes, so webidl.ts repairs the shape on behalf of every interface. V8 writes a startup snapshot in an isolate poorer than the one that restores it, so intrinsics.ts states when a module may read an intrinsic. Every other module assumes both, and a change anywhere else starts from those two sections.
Finding the code
Section titled “Finding the code”| What is changing | Where it lives | The text it implements |
|---|---|---|
| The shape every interface takes, and the argument conversions | webidl.ts, converters.ts |
Web IDL’s JavaScript binding and its type mapping |
| What the global publishes, and what is declared absent | global-scope.ts, absences.ts |
ECMA-429’s global scope and HTML’s web application APIs |
| Events, aborting, and the exception type | events.ts, dom-exception.ts |
DOM events and aborting, Web IDL’s DOMException |
The clock and performance |
clock.ts, performance.ts |
High Resolution Time’s clocks, time origin and Performance |
fetch and everything a body can be |
fetch.ts, blob.ts, form-data.ts, multipart.ts |
Fetch, File API, XMLHttpRequest’s FormData, HTML’s multipart form data and RFC 7578 |
| Streams of any kind, and the queuing behind them | readable-stream.ts, writable-stream.ts, transform-stream.ts, queuing-strategy.ts, promise.ts |
Streams and ECMA-262’s promise objects |
| Text, bytes and base64 | text-encoding.ts, encoding.ts, base64.ts |
Encoding, Infra’s forgiving base64 and HTML’s base64 utility methods |
| URLs, query strings, patterns and domain names | url.ts, url-search-params.ts, url-pattern.ts, idna.ts, punycode.ts, idna-tables.generated.ts |
URL, URL Pattern, UTS46 and RFC 3492 |
| Cloning a value | structured-clone.ts |
HTML’s StructuredSerializeInternal |
| Cryptography, on the isolate side | crypto.ts |
Web Cryptography API |
| The user agent a program reads | navigator.ts |
ECMA-429’s API methods and its User-Agent clause |
| The seam with the host, and the snapshot rule | index.ts, host.ts, intrinsics.ts |
nothing external; the rules are below |
webidl.ts decides the shape of every interface at once, so an edit there changes what a caller reads back off Blob, Headers and every name beside them. url-pattern.ts imports url.ts, and fetch parses every request target through it, so a slower parser costs every outbound request.
Web IDL layer
Section titled “Web IDL layer”An ES class produces the wrong shape for every member Web IDL defines. Class methods and accessors are non-enumerable where attributes and operations require enumerable, and a class defines no class string at all. That gap is identical for every interface, so webidl.ts closes it once and each interface module ends with a call into it.
defineInterface(interfaceObject, identifier) republishes the static members of the interface object and the regular members of its prototype as enumerable, and leaves length, prototype and constructor with the descriptors a class already writes. Web IDL asks for those three exactly as a class writes them. The identifier then becomes the name of the interface object and the class string on the prototype. That identifier arrives as a string argument and is never read off the class, because a bundler renames a class whose name collides with a binding it already holds, and a Blob whose class string reads [object _Blob] is the kind of difference that reaches user code. One call gives Headers its identifier, its class string and the enumerability split Web IDL asks for:
import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new IsolateExecutor({ outbound: null })
const { result } = await executor.execute({ code: `async () => ({ name: Headers.name, classString: Object.prototype.toString.call(new Headers()), append: Object.getOwnPropertyDescriptor(Headers.prototype, 'append').enumerable, constructor: Object.getOwnPropertyDescriptor(Headers.prototype, 'constructor').enumerable, })`,})
console.log(result)
await executor.dispose(){ name: 'Headers', classString: '[object Headers]', append: true, constructor: false}append reads enumerable and constructor does not, and on an interface prototype constructor is the only member that keeps the descriptor a class wrote.
readSlots(slots, value) is the receiver check and the state read at once. Web IDL makes membership of the interface the test every attribute getter and operation runs on its receiver, and a per-interface WeakMap is that test: a value the interface never created is absent from the map, and the miss throws a TypeError reading Illegal invocation. WeakMap.prototype.get returns undefined for a primitive instead of throwing, so a getter called on a string lands in the same branch. A receiver of null or undefined is read as the global object, so a bare addEventListener('x', f) works at the top of a script while Event.prototype.type called on nothing still throws, because globalThis is in the map EventTarget keeps and not in the one Event keeps. Every interface stores its state in such a map and not on the object, so an object and the objects around it find each other without going through a public method the guest could have replaced.
The rest of the layer is one helper per Web IDL construct some interface here declares:
defineMixincopies a mixin’s members onto an including interface. Interface mixins give every including interface its own function objects, so a caller builds the mixin for itself and never shares one object. Fetch’sBody, the generic reader behind both stream readers, and the two mixins the encoding streams include all go through it.defineConstantsplaces constants on the interface object and on the prototype, unwritable and unconfigurable on both.EventandDOMExceptionare the only interfaces here that declare constants.definePairIteratorinstalls the iteration methods, with one function object namedentriesinstalled under bothentriesand@@iterator, and a default iterator object that keeps the internal slots it was created on and not the object.defineAsyncIteratorinstalls asynchronous iteration forReadableStream, including the ongoing promise that makes a secondnextwait for the first however the caller drives it.rejectOnExceptionturns a throw into a rejection for a promise-returning member, and covers the receiver check and the argument conversions as well as the steps.throwIllegalConstructorrefuses a construction the interface object declares no constructor for, with aTypeErrorreadingIllegal constructor, andassertEnoughArgumentsrefuses a call shorter than any declared argument list.
converters.ts declares only the conversions some interface actually calls, and each value is converted exactly once, at the top of the operation that receives it. A dictionary reads each member exactly once, in the lexicographic order of the identifiers, and the members of an inherited dictionary are read before the ones the deriving dictionary adds. defineDictionary does that sorting, so a caller hands in one table per dictionary in the chain, least derived first, and sorts nothing itself. A hand-sorted table is one rename away from reading a caller’s getters in the wrong order.
An operation Web IDL builds with CreateBuiltinFunction has no [[Construct]], so every operation that is not a class method is written as a method definition pulled out of an object literal, { btoa(data) { … } }.btoa. A function declaration is constructible, and new btoa('x') would build an object where the method definition throws.
Global scope
Section titled “Global scope”One Object.defineProperties call publishes the interfaces, the operations and the replaceable attributes, and the descriptor a name gets follows the kind of member it is:
| Kind of member | The descriptor installGlobals writes |
|---|---|
| An interface object | A data property, writable, non-enumerable and configurable |
| An operation | A data property, writable, enumerable and configurable |
| An attribute | A [Replaceable] accessor whose setter redefines the property as a data property of whatever was assigned, so an application can take performance or crypto back |
Nothing published is read-only, and ECMA-429’s global scope clause permits a conforming runtime to leave the readonly attribute off. Runtime deviations records that as deliberate.
That same clause asks the global object to be an instance of EventTarget. HTML gets that by having WorkerGlobalScope inherit the interface, and no WorkerGlobalScope exists here to inherit anything, so installGlobals runs Object.setPrototypeOf(globalThis, EventTarget.prototype) instead. From inside the sandbox that reads as the arrangement HTML produces:
import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new IsolateExecutor({ outbound: null })
const { result } = await executor.execute({ code: `async () => { const accessor = Object.getOwnPropertyDescriptor(globalThis, 'performance') globalThis.performance = 1
addEventListener('probe', () => {})
return { instance: globalThis instanceof EventTarget, parent: Object.getPrototypeOf(globalThis) === EventTarget.prototype, own: Object.hasOwn(globalThis, 'addEventListener'), behind: Object.getPrototypeOf(EventTarget.prototype) === Object.prototype, published: typeof accessor.get, assigned: Object.getOwnPropertyDescriptor(globalThis, 'performance'), } }`,})
console.log(result)
await executor.dispose(){ instance: true, parent: true, own: false, behind: true, published: 'function', assigned: { value: 1, writable: true, enumerable: true, configurable: true }}own: false is the part that matters: addEventListener and its two siblings are inherited through the prototype chain and are not own properties of the global. %Object.prototype% stays reachable behind EventTarget.prototype, and the assignment to performance leaves the data property the last line prints. Attaching the listener list that makes a dispatch work happens in the handshake and not here, for the reason Runtime states.
installGlobals ends by defining three of the event handler attributes HTML gives a global scope, each through a call of its own inside defineEventHandler. onerror fires through report an exception. onunhandledrejection and onrejectionhandled exist because ECMA-429 asks for them, and nothing in a bare isolate reaches the HostPromiseRejectionTracker hook that would fire them.
closeUnmeteredAllocation sits in the same module and runs in the handshake. It deletes WebAssembly and replaces ArrayBuffer.prototype.resize and SharedArrayBuffer.prototype.grow with methods that throw a NotSupportedError whose text comes from the protocol package. Those three are the allocation paths V8 keeps outside what isolated-vm meters, and Bounds states what the memory limit covers. Each replacement is built through a computed key, so a caller reading ArrayBuffer.prototype.resize.name back still finds resize.
Events with no tree
Section titled “Events with no tree”events.ts implements DOM’s events and its aborting in one module. A listener may take an AbortSignal, and AbortSignal is itself an EventTarget, so the two halves refer to each other.
Dispatching an event walks an event path twice, capturing before bubbling. With no node tree the path is the target alone, so both passes invoke that one target. The capture listeners run first, and the invoke algorithm returns on the stop-propagation flag, so a capture listener that stops propagation silences the bubbling pass. But the target item is invoked in the bubbling pass whether or not the event bubbles, because the algorithm skips the bubbles check for it:
import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new IsolateExecutor({ outbound: null })
const { result } = await executor.execute({ code: `async () => { const target = new EventTarget() const seen = [] const record = (event) => seen.push({ phase: event.eventPhase, path: event.composedPath().length })
target.addEventListener('probe', record, { capture: true }) target.addEventListener('probe', record)
const event = new Event('probe', { bubbles: false }) const before = event.composedPath().length target.dispatchEvent(event)
return { AT_TARGET: Event.AT_TARGET, before, seen, after: event.composedPath().length } }`,})
console.log(result)
await executor.dispose(){ AT_TARGET: 2, before: 0, seen: [ { phase: 2, path: 1 }, { phase: 2, path: 1 } ], after: 0}phase: 2 is eventPhase reading AT_TARGET, printed by both listeners of an event constructed with bubbles: false, and path: 1 is the target itself. before and after are the empty list composedPath returns outside a dispatch, since it reads currentTarget and that slot is null at every other moment.
AbortSignal.any builds a dependency graph the garbage collector may take apart. The standard declares both the source set and the dependent set weak, so a signal refers to its dependents and its sources through WeakRef and keeps nothing alive merely by listing it. The strong reference the garbage collection clause does demand comes from the source, and only while the dependent has not aborted, still has source signals, and still has an abort algorithm or an abort listener. Once one of those three stops being true the source drops it, so a controller never accumulates one dead signal per operation. Dead entries are swept out of a dependent set once it has doubled since the last sweep, so sweeping stays proportional to the number of dependents a signal ever had.
Fetch stack
Section titled “Fetch stack”A body is two things: the stream it is read through, and the byte sequence behind that stream when there is one. Every body a caller can pass except a stream becomes bytes first, and createStreamFromByteSequence wraps those bytes in a readable byte stream whose pull hands out as much as the consumer asked for. But a stream a caller passes keeps a null source. The host performs the whole exchange and returns the answer at once, so a response body arrives as a single chunk and a request body whose source is a stream is read to its end before the exchange begins. Runtime deviations states what that costs a caller reading a body incrementally.
flowchart TB fetchCall["fetch(input, init)"] request["Request<br/><small>method, url, headers, signal</small>"] body["a body<br/><small>a stream, and the bytes behind it</small>"] wire["OutboundRequest"] setting["the outbound setting<br/><small>native, caller function, or closed</small>"] answer["Response<br/><small>one chunk, type default</small>"] fetchCall -- "new Request(input, init)" --> request request -- "extract a body from a Blob, a FormData, URLSearchParams, a buffer or text" --> body body -- "read the stream to its end" --> wire wire -- "method, url, header list, ArrayBuffer" --> setting setting -- "returned with a response, or thrown with a text" --> answer
Headers keeps the header list in the order it was built, alongside its guard and a cached result of sort and combine. Fetch re-runs that algorithm for every step of an iteration, and a list nobody touched produces the same order every time, so the cache is cleared on change and never recomputed per step. The computation groups the values under their lowercased name in one pass and sorts the distinct names once, so it costs the length of the list plus the sort, not one scan per name. But set-cookie is the one name the algorithm leaves as one entry per value.
The Body mixin is built once per including interface through defineMixin. Request and Response each get their own function objects, and each mixin’s receiver check runs against that interface’s own slot map. Every way of reading a body goes through one consume operation, so reading disturbs the stream exactly once.
Bodies are derived in two ways, and the difference is visible to a caller. Cloning a body tees the stream with structuredClone as the chunk cloner and leaves the first branch where the original was, so neither side’s reading disturbs the other’s. Deriving a Request from another Request instead pipes the inherited body through an identity transform, Streams’ creating a proxy, so the request it derived from is disturbed from the moment the pipe starts.
fetch parses the input into a Request, reads the body to its end, and crosses to the host with the header list unchanged and the body as an ArrayBuffer. The host answer never rejects: it is ['returned', response] or ['thrown', text], and the isolate turns the second into a TypeError carrying that text. isolated-vm delivers a rejected host promise into the isolate and the host process still records it as unhandled, so a failure travels inside a settled frame instead. That also keeps host stack traces out of the sandbox. Every exchange is raced against the request’s signal, and the abort algorithm comes off the signal as soon as the exchange settles, so a controller driving a long run of requests holds no algorithm from the ones already finished.
Around that core, blob.ts keeps a Blob’s bytes in a buffer the module allocated and never in one a caller shares, so slices share the byte sequence and every reading copies out of it. multipart.ts writes and reads the format over bytes and not over a decoded string, and it takes the random half of its boundary from the host generator, so a body cannot be made to collide with its own boundary by feeding the form a guessable value.
Streams
Section titled “Streams”The three stream modules share one layout. Each keeps module-private WeakMaps from a public object to its slot record, and every private algorithm takes the record and not the object. A controller’s map stores the record of the stream it controls, because [[stream]] is the only slot a controller owns and the rest of its state sits at controller inside that record. One map lookup therefore puts an algorithm where the standard’s prose already stands.
readable-stream.ts implements two controller kinds behind one stream type. A default controller queues whatever the source hands it. A byte controller queues bytes and can fill a buffer the consumer supplied, and a response body and a blob are made of exactly that. Readers come in two kinds for the same reason, and each reader interface keeps its own slot map, so read from one reader rejects an instance of the other instead of finding slots that happen to fit.
queuing-strategy.ts owns the pair of slots Streams calls a queue-with-sizes. That pair is part of each controller’s record and not a second object beside it, so the queue operations take a controller directly. ByteLengthQueuingStrategy and CountQueuingStrategy each publish one size function per realm, created at module scope and returned by every instance’s getter, so a caller that reads size off one strategy and calls it with no receiver still gets a size.
But transform-stream.ts is the one module that cannot read another module’s slots. It keeps the public ReadableStream and WritableStream objects of its two sides and crosses through the boundary functions those modules export, the ones Streams publishes for other specifications. One promise capability ties the sides: the writable side awaits it while backpressure is on, and the readable side’s pull resolves it and installs a fresh one.
promise.ts exists because the stream standards perform two operations on a promise that JavaScript does not expose. [[PromiseState]] cannot be read, so a promise capability carries a pending flag beside its resolving functions. [[PromiseIsHandled]] cannot be set either. Setting it in JavaScript means attaching a rejection handler that does nothing, and setPromiseIsHandled is that one line. Streams hand out promises that reject with nobody attached, a reader’s closed after its lock was released above all, and each one left alone would be reported as an unhandled rejection of the execution.
URL family
Section titled “URL family”fetch parses every request target, so url.ts is the hottest path in the runtime. Its parser reads the input by code unit and takes each component as one slice of that input, never growing a buffer a code point at a time. Slicing is safe because every terminator the state machine stops at is ASCII, so a slice never cuts a surrogate pair in half. Each percent-encode set is one bit in a shared table, and that table is a string, so membership costs one charCodeAt and a mask. Runs above U+007F go to encodeURIComponent, which every set includes in full and which the engine encodes natively.
A URL record keeps its host already serialized and its opaque path as a string, not as an array, so “has an opaque path” is a typeof test and no branch has to re-serialize a host. URLSearchParams reports every change of its list back to the URL that owns it through one callback, the standard’s “update a URLSearchParams object”. But a free-standing URLSearchParams has no callback and the report does nothing. A URL builds its query object on the first read of searchParams and not in its constructor, and nothing observes the difference, because the object is kept from then on.
url-pattern.ts compiles a pattern once and matches it many times, so the parser spends whatever it needs to and the match path allocates nothing it can avoid. Canonicalization runs the basic URL parser of url.ts over a dummy URL at every point the standard asks for it, and the two modules are neighbours for that reason. A compiled component records two shapes that need no regular expression at match time: a pattern that is one whole wildcard matches every input, and one made of fixed text alone matches exactly that text. Seven of the eight components of a typical pattern are one or the other, so most of a match runs no regular expression at all. Every component compiles with the v flag the standard requires, and Runtime deviations states the one spelling that costs a caller.
Encodings, base64 and domain names
Section titled “Encodings, base64 and domain names”text-encoding.ts implements only the encodings a byte sequence inside this isolate can arrive in, under every label the Encoding Standard gives them. UTF-8 and the two UTF-16 halves of the miscellaneous section are algorithms and need no table at all. windows-1252 needs thirty-two entries, one per byte from 0x80 to 0x9F, because every byte from 0xA0 up stands for the code point of the same value. It is what an HTTP response without a usable charset falls back to. Those entries are one string literal indexed by the byte, read by the single-byte decoder. The rest of the Encoding index is legacy mapping tables for encodings no byte sequence inside this isolate arrives in, so a label naming one is rejected the way TextDecoder rejects a label it cannot resolve.
A decoder here is an instance in the standard’s sense: it stores the state its algorithm names, keeps the bytes an earlier call left unread, and survives from one decode to the next. A byte sequence split across calls therefore decodes as one.
base64.ts ships no alphabet. Uint8Array.fromBase64 and Uint8Array.prototype.toBase64 under their default options are exactly the forgiving-base64 decode and encode, down to the ASCII whitespace removal, the padding rules and the discarded bits of a final chunk. So atob and btoa are those two engine intrinsics with the code unit checks HTML adds around them.
The domain parser derives its table instead of shipping it. URL defines the non-ASCII half of its work as domain parser ToASCII, the Unicode ToASCII of UTS46, whose mapping step reads the published IDNA mapping table. That file is most of a megabyte of text and would dominate the bundle. UTS46 derives the table from NFKC_Casefold, and the engine already carries NFKC, case conversion and the Unicode properties, so idna.ts ships only the difference. That difference is three tables, and each is small for its own reason.
- The ignored code points are a character class. The mapping step removes them before anything else looks at a domain, and there are few enough of them to write out.
- The disallowed code points are a character class that opens with
\p{Cn}. UTS46 disallows every unassigned code point, andCnis exactly the unassigned ones, so the property covers the great majority of the disallowed range and the class spells out only what is left. - The mapping corrections are one delta-packed string. An entry is the distance from the previous corrected code point in base 36, a colon, then the replacement’s code points in base 36 separated by full stops. No entry is ASCII, so the parser lowercases an ASCII domain without consulting the table, and a URL whose domain is ASCII never builds the lookup.
Each doc comment in idna-tables.generated.ts states how many code points its table covers for the Unicode version it was generated from, so the counts move with the file and not with this page. Runtime deviations carries a row for the UTS46 validity criteria this parser cannot run, and for what a caller sees when one of them would have refused a name.
tools/executor/generate-idna-tables.ts derives those three tables. It downloads the mapping table once into node_modules/.cache and reads it under the flags the URL Standard’s domain parser passes to Unicode ToASCII, UseSTD3ASCIIRules and Transitional_Processing both false. For every code point in the whole range it compares what the table says against what the engine’s own normalize('NFKC').toLowerCase() produces, and a disagreement becomes a correction. Each assertion the generation makes about its own output licenses a shortcut taken elsewhere: an unassigned code point UTS46 does not disallow would break the \p{Cn} fold, an ASCII code point needing a correction would break the parser’s ASCII fast path, and a disallowed code point NFC could compose out of allowed pieces would break running the disallowed check after normalization instead of during the mapping step. Any one of them fails the generation.
The --check mode reads no table and makes no network request, and it is the tables lane of pnpm verify. It imports the committed module, checks that the engine implements the Unicode version the tables were derived from, parses its two character classes back into ranges, rebuilds the corrections lookup, and holds all three against the running engine. No correction may be one the engine already produces, may name a code point the ignored class also holds, or may fail to fold together with the code point it corrects under NFKD and simple case folding. No ignored code point may be unassigned. The mapping the three tables define has to be idempotent over the whole code point range, the property UTS46 states of its own processing. U+002E is the one exception to the folding rule, because the derivation’s base mapping step sends the other three label separators there and not by decomposition. Finally the check re-renders the module from the parsed tables and compares byte for byte, so a hand edit anywhere in the file fails. Runtime states what that lane cannot see.
Cryptography on the isolate side
Section titled “Cryptography on the isolate side”No cryptography runs inside the isolate. Every operation of SubtleCrypto returns a promise. Each one is performed on the host, and the answer arrives where an await was already waiting. packages/executor/src/sandbox/crypto.ts is the host half, under Crypto bridge.
A CryptoKey here is a number: the host stores the key, and the isolate has only the number it is stored under. The material never enters the isolate, so a key imported as non-extractable stays non-extractable whatever a program does to the object standing for it. Keys nested inside an algorithm, the public member of an ECDH derivation for one, cross the same way an argument does, since one walk reaches every position a CryptoKey can occupy.
Data crosses in the other direction. An algorithm object crosses as the members it owns, walked to depthLimit, and that bound stops one level past the deepest member any dictionary of the interface declares. Normalizing an algorithm happens on the host, where the algorithm tables already are, so nothing here declares those dictionaries a second time. The cost is one row in Runtime deviations: an algorithm object reached through a prototype chain loses its inherited members.
A failure crosses as { name, message } and is rebuilt on the isolate side. The ECMAScript error names a binding raises become those errors, and every other name becomes a DOMException with that name. Code branches on the name: OperationError says the operation failed for a reason of its own, where DataError says the data it was given does not meet the algorithm’s requirements.
Structured cloning and internal slots
Section titled “Structured cloning and internal slots”Recognizing a value without trusting anything a guest can rewrite is a problem every interface here has, and structured-clone.ts meets it over the whole set of clonable types at once. StructuredSerializeInternal dispatches on internal slots, and JavaScript exposes no way to read one directly. A class string is an ordinary property any value may carry, so one is read here only where it comes from a slot. Everywhere else the branch is chosen by the intrinsic prototype a value inherits from and then confirmed by reading the slot itself through the intrinsic accessor that owns it. That accessor throws for a receiver without the slot, and the throw is the test. A value that fails it is serialized as a plain object.
HTML splits serialization from deserialization because its callers ship the record to a realm that does not exist yet. structuredClone deserializes into the realm it serialized from and is the only caller this runtime has, so the record never exists: the memory maps each input object straight to its clone, and one walk does what two algorithms describe. Cycles and repeated objects come out of that memory the way they come out of the standard’s. The transfer list is the one place the halves stay apart, because transferring detaches and the standard detaches only once the whole graph has been walked without raising. A transferred source is stood in for by a placeholder during the walk, and every reference to one is recorded and written afterwards.
Snapshot and the intrinsics rule
Section titled “Snapshot and the intrinsics rule”readRuntimeSnapshot memoizes ivm.Isolate.createSnapshot over the generated bundle, on the terms Isolate and its startup snapshot states. The isolate V8 writes a snapshot in is poorer than the isolate that restores one, and reading the same names in both places says which:
import ivm from 'isolated-vm'
const names = [ 'SharedArrayBuffer', 'WebAssembly', 'Float16Array', 'Uint8Array.fromBase64', 'Uint8Array.prototype.toBase64', 'Promise.try', 'ArrayBuffer.prototype.transfer',]
const read = `JSON.stringify({${names.map((name) => `${JSON.stringify(name)}: typeof ${name}`)}})`
const snapshot = ivm.Isolate.createSnapshot([{ code: `globalThis.atWrite = ${read}` }])const context = new ivm.Isolate({ snapshot }).createContextSync()const atWrite: Record<string, string> = JSON.parse(context.evalSync('atWrite'))const afterRestore: Record<string, string> = JSON.parse(context.evalSync(read))
console.log(`${'name'.padEnd(31)}${'at write'.padEnd(11)}after restore`)
for (const name of names) { console.log(`${name.padEnd(31)}${String(atWrite[name]).padEnd(11)}${afterRestore[name]}`)}name at write after restoreSharedArrayBuffer undefined functionWebAssembly undefined objectFloat16Array undefined functionUint8Array.fromBase64 undefined functionUint8Array.prototype.toBase64 undefined functionPromise.try undefined functionArrayBuffer.prototype.transfer function functionSharedArrayBuffer and WebAssembly are absent while the snapshot is written and present afterwards, and so is every intrinsic the current edition has staged. ArrayBuffer.prototype.transfer is there throughout. A module that read one of the staged names into a variable at load time would have captured the write-time answer for good, and which names move changes with every V8 the engine ships, so the runtime keeps a habit instead of a list.
intrinsics.ts states that habit: an intrinsic is reached where it is used and never held from the moment the module loaded. Where reaching for one is the whole of the work the call does it, and base64.ts reads the two typed-array conversions through Reflect on every call. Where several intrinsics have to be gathered into a table, buildOnFirstCall holds the table back until the first call that reads it. The prototype dispatch branches of the structured clone, its typed array constructors and the view kinds of the readable byte stream are all deferred that way. The mapping corrections of the domain parser are deferred for a different reason, since a URL whose domain is ASCII never reaches them.
holdBufferResize and readBufferResize hold one intrinsic on purpose instead of deferring it. closeUnmeteredAllocation replaces ArrayBuffer.prototype.resize, and the structured clone still needs the real operation to tell a length-tracking view from a fixed one. It tells them apart by resizing the buffer underneath the view and putting it back. holdBufferResize takes the original as the replacement goes in, and readBufferResize is the only way back to it. One case in runtime.spec.ts reads both halves: a tracking view clones with its length, and buffer.resize is refused.
An entry of a weak collection 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, for a WeakMap entry and a WeakSet member alike. An entry keyed on an ordinary object crosses every time, and a WeakRef to the global object derefs after every restore. So the listener list of the global object is attached in the handshake, and an interface that wants to key state on globalThis does the same.
Generating the bundle
Section titled “Generating the bundle”tools/executor/generate-runtime-bundle.ts runs esbuild as an IIFE targeting ES2022 over a one-line entry of its own, require('./src/sandbox/runtime/index.js').installRuntime(), and writes the result into a committed module as the string constant RUNTIME_SOURCE. Evaluating that bundle publishes the global scope rather than only defining it, and the snapshot is taken of the published scope.
Most of its settings are esbuild’s defaults, and three are not. minify shrinks the source V8 parses when it writes the snapshot, once per process. keepNames matters because Web IDL publishes the identifier of an interface object and of an operation as a name property a caller reads back, so a renaming minifier would change an answer. The source export condition on the protocol package keeps the bundle off a built dist.
The generator’s --check mode re-bundles and compares against the committed file, and that comparison is the generated lane of pnpm verify. Module changes that were never bundled therefore fail before they reach a snapshot, and the committed bundle is always the one the modules produce.
Where a change is proven
Section titled “Where a change is proven”Whatever a plain module import can falsify is proven in the spec beside that module. runtime.spec.ts proves the rest, and it is the only place the generated bundle, the minifier, the modules and V8’s serializer meet. One test reads the whole global surface of a restored isolate against an isolate that evaluated the bundle instead and requires the two to be equal, name by name, down to the members of each interface prototype. Others pin that an isolate measures time from its own start and not from the snapshot, that the handshake leaves no name behind, that the runtime reaches the intrinsics the snapshot was written without, and that restoring costs less than half of evaluating. Behaviour that appears only once the bundle has been through the minifier and V8’s serializer belongs there and not in a module spec.