The runtime internals
The standard library the sandbox installs lives in packages/executor/src/sandbox/runtime, and packages/executor/src/sandbox/runtime.ts holds the host’s half. Users of that library get a chapter of their own. It says what the library gives a program and which standard decides each answer. Runtime deviations says where it answers differently from the standard that governs an interface. This chapter is for whoever is about to change it.
One rule shapes the whole directory. Every interface here is a reference implementation of the text that governs it, written against that text clause by clause. Each module names the clauses it implements at the top of its own file. Host bridges appear only where the isolate holds no privilege of its own.
Two constraints bend that rule, and between them they explain the two most abstract modules here. A JavaScript class does not produce the object shape Web IDL describes, and webidl.ts repairs that on behalf of every interface. Writing a startup snapshot also happens in an isolate poorer than the one that restores it, and intrinsics.ts turns that into a habit each module has to keep. Every other module here assumes both, so 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 answers to |
|---|---|---|
| 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 |
Two of those rows have edges into all the others. webidl.ts decides the shape of every interface at once, and changes there are felt everywhere a member is read back. And url.ts is imported by url-pattern.ts and walked by fetch on every request target, so it is written the way a hot path is written.
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 carries 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. It leaves length, prototype and constructor with the descriptors a class already writes, and those are the ones Web IDL asks for. It then writes the identifier as the name of the interface object and as the class string on the prototype. The 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. Blob answering to [object _Blob] is the kind of difference that reaches user code.
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 it, and a primitive answers undefined and does not throw. 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. Every interface below stores its state in such a map and not on the object. An object and the objects around it then reach each other without going back through a public method the guest could have replaced.
The rest of the layer is one helper per Web IDL construct that some interface here declares.
defineMixincopies a mixin’s members onto an including interface. Because interface mixins give every including interface its own function objects, a caller builds the mixin for itself and never shares one object. Fetch’sBodymixin, the generic reader the two stream readers share, and the two mixins the encoding streams include all arrive this way.defineConstantsplaces constants on the interface object and the prototype, unwritable and unconfigurable on both.EventandDOMExceptionneed exactly that.definePairIteratorinstalls the iteration methods, with one function object namedentriesanswering under bothentriesand@@iterator, and a default iterator object holding the internal slots it was created on, 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. It covers the receiver check and the argument conversions as much as the steps.throwIllegalConstructorandassertEnoughArgumentsare the two refusals every interface needs: a constructor the interface object does not give, and a call shorter than any declared argument list.
converters.ts holds only the conversions some interface actually calls. Each value is converted exactly once, at the top of the operation that receives it. defineDictionary is the piece with a rule that is easy to break by hand. A dictionary reads each member exactly once, in the lexicographic order of the identifiers, with the members of an inherited dictionary read before the ones the deriving dictionary adds. Callers pass one table per dictionary in the chain, least derived first, and the sorting happens inside defineDictionary and not in the tables. Hand-sorted tables are one rename away from reading a caller’s getters in the wrong order.
One shape recurs outside the helpers. An operation Web IDL builds with CreateBuiltinFunction carries no [[Construct]], so operations that are not class methods are written as method definitions extracted from an object literal, { btoa(data) { … } }.btoa. Function declarations would answer new with an object.
Global scope
Section titled “Global scope”installGlobals writes one Object.defineProperties call. What a name gets out of it depends on what kind of thing the name holds. An interface object gets the writable, non-enumerable, configurable descriptor. An operation gets the enumerable one. An attribute gets a [Replaceable] accessor whose setter redefines the property as a data property of whatever was assigned, so ECMA-429’s global scope clause lets an application take a name back. Nothing published is read-only. That clause would permit it, and the deviations page records the choice as deliberate.
Then the global object is reparented:
Object.setPrototypeOf(globalThis, EventTarget.prototype)That same clause asks the global object to be an instance of EventTarget. HTML gets that by having WorkerGlobalScope inherit the interface, but there is no WorkerGlobalScope here to inherit anything. Putting the interface prototype object directly behind the global is the same arrangement seen from user code. globalThis instanceof EventTarget answers true, addEventListener and its two siblings answer through the prototype chain and not as own properties of the global, and %Object.prototype% stays reachable behind them because it is already behind EventTarget.prototype. Attaching the listener list that makes the arrangement work happens in the handshake and not here, for the reason the runtime chapter states.
installGlobals ends by defining three of the event handler attributes HTML gives a global scope. onerror fires through report an exception. Both promise handlers 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 but runs in the handshake. It deletes WebAssembly. It replaces ArrayBuffer.prototype.resize and SharedArrayBuffer.prototype.grow with methods that throw a NotSupportedError carrying a text from the protocol package. Each replacement is built through a computed key, so it keeps the name it stands in for, and a caller reading that name back finds it.
Events with no tree
Section titled “Events with no tree”events.ts holds DOM’s events and its aborting in one module. A listener may carry 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 holds the target alone, so both passes reach that one target. The capture listeners run first, and because the invoke algorithm returns on the stop-propagation flag, 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. eventPhase therefore reads AT_TARGET for the whole dispatch, and composedPath answers the current target alone while a dispatch is running and an empty list at every other moment. Both are what the algorithm produces, not a shortcut around it.
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 reaches its dependents and its sources through WeakRef and holds nothing alive merely by listing it. Strong references the garbage collection clause does demand are held on the source, and only while the dependent has not aborted and carries an abort algorithm or an abort listener. Once one of those stops being true the hold is dropped, 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. Sweeping costs therefore stay 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. 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. That difference is the one the deviations page records about bodies. The host performs the exchange and answers 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.
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 answer of sort and combine. Fetch re-runs that algorithm for every step of an iteration, and a list nobody touched answers the same thing every time. So the cache is invalidated on change and never recomputed per step. That 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 carry their own function objects, and each mixin’s receiver check runs against that interface’s own slot map. Every reading a body offers 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 itself parses the input into a Request, reads the body to its end, and crosses to the host with the header list as it stands and the body as an ArrayBuffer. The host never rejects: it answers ['returned', response] or ['thrown', text], because isolated-vm delivers a rejected host promise into the isolate and the host process still records it as unhandled. A failure inside a settled frame is rethrown behind the boundary, and that also keeps host stack traces out of the sandbox. Every exchange is raced against the request’s signal. The abort algorithm comes off the signal as soon as the exchange settles, so a controller driving a long run of requests carries 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. Slices can therefore 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. It also 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 carries the least of its own state: its map holds the record of the stream it controls. [[stream]] is the only slot a controller owns on its own, and the rest of what it holds sits at controller inside that record. One map lookup therefore puts an algorithm exactly where the standard’s prose already stands.
readable-stream.ts carries 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. Each interface keeps its own slot map, so a default reader’s read rejects a BYOB reader 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 intersected into each controller’s record and not held 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. Callers that read size off one strategy and call it on nothing at all therefore get the answer the standard asks for.
But transform-stream.ts is the one module that cannot reach into another’s slots. It holds 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. A promise capability therefore carries a pending flag beside its resolving functions. [[PromiseIsHandled]] cannot be set either, and in JavaScript setting it means attaching a rejection handler that does nothing:
export function setPromiseIsHandled(promise: Promise<unknown>): void { promise.then(undefined, returnUndefined)}Streams hand out promises that reject with nobody attached, a reader’s closed after its lock was released above all. Left alone each one 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 held as a string, so membership costs a read and a mask. Runs above U+007F are handed 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 is a no-op. A URL builds its query object on the first read and not in its constructor, which is unobservable because the identity 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. In an ordinary pattern almost every one of the eight components is one or the other, and answering those without running a regular expression is where the match time goes. Every component compiles with the v flag the standard requires, and Runtime deviations states the one spelling that costs a caller.
Text, bytes and the tables that are not there
Section titled “Text, bytes and the tables that are not there”Three places here could pull a large table into the bundle. None of them does.
text-encoding.ts carries 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. They need no table at all. windows-1252 needs one entry per byte from 0x80 to 0x9F, because every byte from 0xA0 up stands for the code point of the same value. Those entries are one string literal indexed by the byte, decoded by the single-byte decoder. But the rest of the Encoding index is legacy mapping tables for encodings no byte sequence inside this isolate arrives in. Labels naming one are rejected the way TextDecoder rejects a label it cannot resolve. A decoder here is an instance in the standard’s sense: it holds the state its algorithm names, carries 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 dropping 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. But that file is most of a megabyte of text, and shipping it 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. Three tables carry it, 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 carries 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. No entry is ASCII, so the parser lowercases an ASCII domain without consulting the table at all, 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 holds for the Unicode version it was generated from. The counts therefore 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, reads it under the flags the URL Standard passes, and for every code point in the whole range compares what the table says against what the engine’s own normalize('NFKC').toLowerCase() answers. 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.
That --check mode reads no table and reaches no network. It is the tables lane of pnpm verify. It imports the committed module, parses its two character classes back into ranges, rebuilds the corrections lookup, and holds all three against the running engine. The engine has to implement the Unicode version the tables were derived from. No correction may be one the engine already answers, 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. And the mapping the three tables define has to be idempotent over the whole code point range, the property UTS46 states of its own processing. One exception to the folding rule is U+002E, 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. But the check 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.
Cryptography on the isolate side
Section titled “Cryptography on the isolate side”No cryptography runs inside the isolate. Every operation of the interface answers 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, and the sandbox chapter covers it. The isolate’s side of the same seam follows.
A CryptoKey here is a number. The host keeps the key and the isolate keeps the identity it answers to. A key imported as non-extractable therefore stays non-extractable: the material is never inside the isolate to be read, whatever a program does to the object standing for it. A key nested inside an algorithm, the public member of an ECDH derivation for one, crosses the same way an argument does, because one walk reaches every position a CryptoKey can occupy.
Data crosses in the other direction. An algorithm object is carried as the members it owns, down to depthLimit, which 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. That choice costs one deviation row about 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 carrying 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 reading a slot from JavaScript
Section titled “Structured cloning, and reading a slot from JavaScript”Recognizing what a value is, without trusting anything a guest can rewrite, is a problem every interface here has. structured-clone.ts has it worst. It is the place to read the discipline the others follow.
HTML splits serialization from deserialization. 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. But 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. Transferred sources are stood in for by a placeholder during the walk, and every reference to one is recorded and written afterwards.
StructuredSerializeInternal dispatches on internal slots, and JavaScript exposes no way to read one directly. Class strings are ordinary properties any value may carry, so one is read here only where it answers 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. The standard serializes a value that fails it as a plain object.
Snapshot and the intrinsics rule
Section titled “Snapshot and the intrinsics rule”readRuntimeSnapshot memoizes ivm.Isolate.createSnapshot over the generated bundle, on the terms the sandbox chapter states. The isolate V8 writes a snapshot in is poorer than the isolate that restores one. Reading the global names inside createSnapshot and reading them again after restore turns up several that appeared in between: SharedArrayBuffer, WebAssembly, and whatever intrinsics the current edition has staged, Float16Array and the base64 conversions of a typed array among them. Members behave the same way. Uint8Array.prototype.toBase64 and Promise.try are absent while the snapshot is written and present afterwards. But ArrayBuffer.prototype.transfer is there throughout. V8 installs the staged intrinsics on the isolate that restores the snapshot, so a module that read one into a variable at load time would have captured the write-time answer for good. Which names are missing moves with every V8 the engine ships, so the runtime needs a habit and not a list.
intrinsics.ts holds the rule that follows. It also holds the helpers that serve it. 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, the first call that reads the table builds it, and buildOnFirstCall is the helper for that:
/** Holds a table back until the first call that reads it. */export function buildOnFirstCall<T extends object>(build: () => T): () => T { let built: T | undefined
return () => (built ??= build())}Prototype dispatch branches of the structured clone, its typed array constructors, and the view kinds of the readable byte stream are all deferred through buildOnFirstCall. Mapping corrections of the domain parser are deferred for a different reason, since a URL whose domain is ASCII never reaches them.
Two more helpers 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 does that by resizing the buffer underneath the view and putting it back. holdBufferResize takes the original as the replacement goes in, readBufferResize is the only way back to it, and runtime.spec.ts reads both facts in one case: a tracking view clones with its length, and buffer.resize answers the guest with a refusal.
One more thing a snapshot cannot carry is an entry of a weak collection whose key is the global object. The probe that shows it puts a WeakMap entry and a WeakSet member under an ordinary object and under globalThis into a snapshot. After restore both ordinary ones are still there, both global ones are gone, and a WeakRef to the global object still derefs to it. So the listener list of the global object is attached in the handshake, and a future interface that wants to key state on globalThis has to do 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 the bundle therefore publishes the global scope and does not only define it. The snapshot is taken of the published scope.
Most of its settings are esbuild’s defaults. Three of the rest would be invisible until the moment they broke something. Minifying pays for itself. Every byte is parsed once per process into the snapshot. Keeping names matters because Web IDL publishes the identifier of an interface object and of an operation as a name property a caller can read back, so a renaming minifier would quietly change an answer. Resolving the protocol package through its source export condition means the bundle never depends on a built dist being current.
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 the gate instead of reaching 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 the module. runtime.spec.ts proves the rest. It is the only place the generated bundle, the minifier, the modules and V8’s serializer meet. It reads the whole global surface of a restored isolate against an isolate that evaluated the bundle instead and requires the two to be equal, each name with how it is written and the members of the interface prototype behind it. It pins 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.