Repository navigation
v0.9.0
The engine answers more of Node 24.21.0's surface and several things every
guest meets changed. The exported surface changed and one export answers
differently for the same input, hence a minor release.
Breaking for a consumer that keeps prepared module bodies.
preparedModuleKey names the same file differently: the format id moved from
tabnode-prepared-3 to tabnode-prepared-4 (e86c6e2). The key was a hash the
engine computed over each source in JavaScript; it is now text made of the
format id, the kind and the plain SHA-256 of the file's bytes, so a tree that
already holds a file's digest gives the key with no read and no hash (Rallly
spent 1.78 s of a 4.4 s start hashing sources, and 0.45 s reading them only to
hash them). Bodies prepared under tabnode-prepared-3 are not found by this
engine. Nothing fails: each module is prepared again in the tab on first use,
which is slow and silent. A consumer rebuilds its images when it moves the pin.
Added
- A second entry point,
@volter/tabnode/prepared-key: the prepared body's
key derivation with no import of the engine, for a host that builds an image
and a page that reads one. From the index too:preparedModuleKeyOf,
preparedModuleKind,isPreparedModuleKey,PREPARED_MODULES_FORMAT,
PREPARED_MODULES_KEPT. VirtualFSmay answercontentDigest(path); a tree that does lets a
prepared body be taken without reading its source.NativeStreamTransportgains an optionalpost(operation): a read grant and
a shutdown are sent without waiting for the stream owner's thread where the
transport offers it; a refusal arrives as the handle's failed read or the
shutdown's completion. A stream that stops reading takes an outstanding grant
back with areadStopcall, so an owner never reads ahead for a socket its
realm has paused. Withoutpostevery operation is a call, as before.dist/surface.json, made bynpm run build:surface: every builtin's exports
against Node 24.21.0's (same, differs, wrong type, missing) and the binding
members Node's library asks for, measured from the built engine.- A runtime dependency,
@noble/curves2.3.0, for the curves below. node:crypto: every export name Node 24.21.0 has is present;hkdf,
hkdfSync,scrypt,scryptSyncand synchronous EC (P-256, P-384, P-521)
and Ed25519 key pairs, their PEM, DER and JWK encodings,signandverify
are implemented; what is not implemented throws by name at the call.
createPrivateKeyandcreatePublicKeyrefuse what is not a key.
No option of a key's encoding is dropped: each is read or refused by name.
A private key asked for withcipherandpassphrase(from
KeyObject.export,generateKeyPairorgenerateKeyPairSync) is refused,
ERR_CRYPTO_UNSUPPORTED_OPERATION: this engine encrypts no key, and it does
not hand back the clear one. An encrypted key on the way in is refused as
encrypted (ERR_MISSING_PASSPHRASEwith no passphrase, unsupported with
one). See "Where node:crypto's keys differ from Node's" below.navigator.locks(one process's lock manager),buffer.transcode,
process.release,process.getuid/geteuid/getgid/getegid/getgroups,
process.versions.acorn,process.features.typescript.- Instrument lines on the realm's console, each one line of JSON:
[boot-trace] node-line,[boot-trace] stat-window, the resolver's probe
counts on theprepared-bodiescounts line,[stream-calls]and
[rows-used]at a process's end. installHostNames(names): a host supplies the names that are this host
besideslocalhost(ADR-0002).dns.lookupresolves a held name to the
loopback in the caller's family, anddns.resolve4/resolve6answer its
loopback records. With no host table installed nothing changes.
Changed, for every guestprocess.versionis theNODE_VERSIONits environment names; the library is
Node 24.21.0's whatever it says. On line 20 animport()that first runs an
ES module settles after a loop turn, as Node 20's does (ADR-0005).- A process whose loop drains emits
exit, withprocess.exitCode || 0; only
process.exit()did.beforeExitis still not emitted. A listener that
callsprocess.exit(n)whileexitis being emitted ends the process with
n, as Node does, whether the process was draining or exiting (the code was
dropped: a test runner that reports its failures that way read as passed). - Resolution asks the tree what Node asks: an entry's body runs at require
depth 0 (Module._loadno longer counts as arequirecall), so the stat
cache Node keeps for that body exists; a level with nonode_modules
directory is one probe; a path is asked once in one resolution; a package's
self-reference reads the nearestpackage.jsononly, as Node's does (an
outer package of the same name withexportsno longer answers). fs.watchFile's listener gets the new stat and the old one (it got the same
one twice).os.release()andos.version()are no longer swapped;
os.networkInterfaces()givesfamilyasIPv4/IPv6;fs.statfshas its
eight fields in Node's order.process.features.typescriptfollows the line: absent for a guest told it
is Node 20 (Node 20.20.2 has no such property),"strip"otherwise.process.memoryUsage()is the realm's own heap where the realm reports one
(performance.memory); elsewhere the fixed figures it always gave.--enable-source-mapsandprocess.setSourceMapsEnabledkeep the switch and
warn once (TABNODE_SOURCE_MAPS_NOT_APPLIED): stacks are not remapped.Function.prototype.toStringmakes a function's written source once.
Where node:crypto's keys differ from Node's
Measured with one script of 131 cases (scripts/crypto-keys-compare.mjs),
hostile inputs among them, on Node v24.21.0 and in a guest: 85 answer the
same, and in 20 more both refuse with different error codes (OpenSSL's own
codes are not reproduced). The rest:- Not carried, refused by name where Node does it: encrypting a private key on
export; reading an encrypted key (with a passphrase offered the answer is
"unsupported" whatever the passphrase, right or wrong: none is tried);
paramEncoding: 'explicit'; curves other
than P-256, P-384 and P-521 (secp256k1 among them); signing or hashing with
SHA-512/224, SHA-512/256 or SHA-3 (sha512-256was read as SHA-512). - Stricter than Node, which accepts each of these and this engine refuses: a
DER length not in its shortest form, and bytes after the key; a SEC1 key
whose version is not 1; a public key's bit string with unused bits; a
private key whose stated public half is not its own (OpenSSL keeps the
stated one: it signs with one key and exports another's public half); a
private value of zero or beyond the curve's order; a PKCS#8 key that names
one curve outside and another inside; a JWK whosedis not the private
value of itsxandy(EC) orx(Ed25519). None of these depends on a
key's value, and no tool writes them; one that does is the finding. - Read as Node reads them, because a correct tool writes them for some keys:
a JWK member without its leading zero bytes (one key in 128 a coordinate),
or with one more, or in the standard base64 alphabet; an EC public point in
compressed or hybrid form (openssl ec -conv_form), written back in the
form it was read; a private value shorter than its curve's width; the first
key among several PEM blocks, theEC PARAMETERSblock thatopenssl ecparam -genkeywrites before the key among them. - The same as Node, and not the curve library's default: Ed25519 verification
without the cofactor, refusing small-order keys andR; of the twelve
vectors of "Taming the many EdDSAs" both accept one. Against Node at volume
(scripts/crypto-ed25519-volume.mjs): 3,000 generated keys and messages,
both directions, with flipped bits in signature, message and key, and RFC
8032's first three vectors: 18,018 checks, none differing. - A caller's DER is copied when a key is read; zeroing the buffer afterwards
does not change the key. - A guest's global object is the guest's own, with Node's names and shapes.
for (name in globalThis)gives Node's fifteen names and none of the
realm's;Bufferandperformanceare accessors with a setter,crypto
one without, the rest writable values;atob,btoa,Bufferand
performanceare the objects their modules export. What a program assigns,
defines or deletes there is its own and is what every later read, walk and
descriptor answers: before, an assignment to a timer,queueMicrotaskor
structuredClonechanged the realm's global for every program in it.
The realm'swindow,documentandlocationare absent until a program
makes them, and then are its own:globalThis.window = dom.windowreads
back, and a baredocumentin any module finds what was assigned.process
is answered ahead of the guest's own properties, because the engine finds
the run that is asking by the realm'sprocess. A guest may delete it or
assign another object, which it then reads back; neither reaches the
realm (an assignment used to be written there, where it stood in the
engine's way: every filesystem call after it found no run). The next
module's start puts the run's own process back.scripts/guest-globals.cjs
assigns, defines (whole, attributes only,writableonly), deletes,
restores and fixes each name under Node and in a guest: 73 rows. - A process emits
exitwhen its loop has drained, and what "drained" means
here is the engine's rule for ending a run, which this release does not
change: the entry has returned and, with nothing the engine counts still
pending (timers, ports, handles, a read stdin, held work), no output for
half a second, or two seconds for a program that printed nothing. Work the
engine does not count does not hold a run: WebCrypto and asynchronous
node:cryptojobs, DNS, asynchronous zlib, a pending filesystem request, a
fetchwith no host transport, any other promise of the host's. A program
whose last act is one of those is ended by that rule, as it was on v0.8.0,
and itsexitlisteners now run at that moment, where before they did not
run at all. A tick the process had queued, or queues from anexitlistener,
does not run onceexitis being emitted, as in Node, where the process is
gone by then. A program that emits'exit'itself has only called its own
listeners and goes on running, ticks included. process.nextTickcallbacks queued by a callback the loop entered run when
that callback returns, before any promise it queued, and ticks they queue
run with them (cb, tick, promise, as Node). A callback made while other
code is still running does not drain them, bracketed by the engine or not.
What a stream's callback queues runs before that stream's next callback
(scripts/callback-ticks.cjs, a request dumped by its response).- A child whose
stdionames a descriptor the caller does not hold fails to
start withEBADF(spawnSync:error.code, no pid, no output;spawn
throws), as Node's does. Its output was dropped in silence.
Known cost - A stream that stops reading while a read grant is with its owner makes one
synchronous call to take it back. Under heavy back-pressure (a reader that
pauses and resumes at every chunk) that is a call per pause; how often it
happens in a real server is not measured, and the[stream-calls]line
counts it (readStop).
Known differences from Node, for a guest's globals and its ticks - A bare name reads the value the global had when its module began, for
global,Buffer,atob,btoa,queueMicrotask,structuredCloneand
the six timer functions, andcryptoreads the realm's: after
globalThis.setTimeout = mine,globalThis.setTimeoutismineand a bare
setTimeoutin the same module is still the first. After a delete, a bare
name isundefinedor its first value where Node throws a ReferenceError.
The property, the walk and the descriptor are Node's in every case
(scripts/guest-globals.cjs, thebarecolumn). Not new in this release:
v0.8.0 binds the same names the same way. - The six timer globals are not the functions
require('timers')exports
(the same script,is-owner's). child.kill()emits the child'sexitbefore it returns (exit, after, tickwhere Node givesafter, tick, exit), for code entered any way. The
same on v0.8.0 (scripts/callback-ticks.cjs, section 3).- A tick queued from a promise reaction runs before a promise queued after it
in the same reaction (fs.promises.readFile(f).then(cb):cb, tick, promise); Node finishes the promises first (cb, promise, tick). The same
on v0.8.0 (scripts/callback-ticks.cjs, the one row that differs).
Known differences from Node, for a synchronous child'sstdio
(scripts/sync-child-stdio-differences.cjs, each line read under Node
v24.21.0 and as a guest) - The child's fd 0 given as a descriptor of the caller's (a file opened for
reading, or'inherit'): the child reads an empty stdin and ends; Node's
reads the file ("child read \"from a file\"", here"child read \"\"").
Onlyinputreaches a synchronous child's stdin. - An entry past fd 2 (
stdio[3]as'pipe'or as a file's descriptor): the
child has no fd 3 and its write fails withEBADF;output[3]isnull
and the file stays empty. Node gives the child the descriptor and answers
output[3]. process.stdoutorprocess.stderritself as an entry throws
ERR_INVALID_ARG_VALUEfrom Node's owngetValidStdio: the guest's stream
carries neither a descriptor nor a handle Node recognises. Node passes it
on. A stream that carries a descriptor (fs.createWriteStream) is passed on
here as there.
Fixed- A default export's expression keeps its parentheses when lowered.
node's refusals go where its stderr goes when fd 2 is taken as bytes.- A synchronous child's output goes to the caller's own descriptor when
stdionames one by number, which is whatstdio: 'inherit'is by the time
it reaches the binding:spawnSync/execSyncwith'inherit'printed
nothing and returned the bytes asstdout/stderrinstead, a descriptor of
an open file stayed empty, and passed-on bytes counted towardmaxBuffer.
Both paths (a child the engine runs, a child the host runs) go by the rule an
asynchronous child's stdio goes by (scripts/sync-child-stdio.cjs). - crypto: a base64 signature verifies; the legacy names
pseudoRandomBytes,
prngandrngare accessors as in Node.