ferridriver-jsstd
Vendored subset of awslabs/llrt (Apache
License 2.0), providing the WHATWG Streams implementation, the node:os
module, and the pieces they depend on for the ferridriver QuickJS runtime.
Upstream: 0.9.0-beta, re-synced against awslabs/llrt@0a10758 (main,
2026-09-06). Every module is taken from that one commit.
| upstream crate | module here |
|---|---|
llrt_utils |
utils |
llrt_context |
context |
llrt_exceptions |
exceptions |
llrt_events |
events |
llrt_abort |
abort |
llrt_encoding |
encoding |
llrt_buffer |
buffer |
llrt_json |
json |
llrt_crypto |
crypto |
llrt_os |
os |
llrt_fs |
fs |
llrt_path |
pathutil |
llrt_stream_web |
stream_web |
llrt_zlib |
zlib |
llrt_compression |
compression |
llrt_string_decoder |
string_decoder |
llrt_perf_hooks |
perf_hooks |
llrt_tty |
tty |
llrt_navigator |
navigator |
llrt_url |
url |
llrt_util |
text |
llrt_test |
test (dev) |
llrt_stream is NOT vendored, and not because we chose against it: llrt has
no node stream module. llrt_stream registers no ModuleDef and answers
no specifier — it is an internal trait library (Readable / Writable /
SteamEvents) that llrt's own fs, net and child_process implement.
llrt_stream_web is the module, and it is stream/web. There is likewise
no querystring anywhere in llrt.
The rest of llrt — its hyper/fetch stack, timers, console — is deliberately
not vendored: ferridriver has its own, over reqwest. fs is vendored
because it IS Node's fs, which is what a suite expects; only the Rust
path helpers of llrt_path come with it (pathutil) — the path MODULE
stays ferridriver's. os is vendored
because ferridriver has nothing equivalent and the module is pure host
introspection with no overlap with the automation stack. From llrt_util
only the four text codecs are taken (TextEncoder, TextDecoder and their
stream forms); its format / inherits / inspect are node::util's,
which are richer.
What a host installs
Two entry points, and nothing else to remember:
jsstd::init(ctx)— every global this crate provides:DOMException,Event/EventTarget,AbortController/AbortSignal, the Streams surface,Buffer/Blob/File,crypto,TextEncoder/TextDecoder(+ their stream forms),URL/URLSearchParams,atob/btoa,structuredCloneandperformance.jsstd::modules::modules()— every Node / web MODULE it serves, each entry carrying its specifiers, theModuleDefthe ES loader declares, and the objectrequire()returns:path,buffer,os,util,events,assert(+/strict),url,process,timers(+/promises),crypto,zlib,string_decoder,perf_hooks,tty,stream/web. The host merges that list into its own loader, itsrequiretable and its bundler's external list, so the three cannot drift apart.
src/node/ — ferridriver-authored
Not everything Node exposes has a usable upstream in llrt. llrt_util is
TextEncoder/TextDecoder plus format and inherits (no promisify, no
inspect, no types), and llrt_assert is a single ok. Those modules are
written here instead, under src/node/, so the runtime still has exactly one
implementation of each surface:
| module | why it is ours |
|---|---|
node::inspect |
The util.inspect / util.format renderer, moved out of ferridriver-script's console so console.log, util.format and util.inspect cannot drift apart |
node::deep_equal |
Structural equality for util.isDeepStrictEqual (and assert.deepStrictEqual when it lands) |
node::util |
The util module |
node::assert |
The assert module (upstream llrt_assert is a single ok) |
node::process |
A sandbox-safe process — inert identity and timing, with env and cwd() supplied by the host — and its module form |
node::timers |
The module form of web::timers, plus timers/promises |
node::path |
The path module, moved out of ferridriver-script's node_compat |
node::bytes |
The one JS-value-to-Vec<u8> walk: BufferSource, Buffer, byte arrays, encoded strings. crypto, the compression streams, Buffer.from and setInputFiles all read through it — there were three separate walks before |
src/node/ carries its own rustfmt.toml re-enabling formatting (the crate
disables it for the vendored subtree) and follows the repo's house style. It
is compiled under this crate's relaxed lints because pedantic's
needless_pass_by_value cannot be satisfied by an rquickjs callback, which
must take owned JS values.
src/web/ — ferridriver-authored
Web-platform globals llrt has no upstream for. Same formatting rules as
src/node/.
| module | what it is |
|---|---|
web (mod.rs) |
atob / btoa (the WHATWG forgiving-base64 algorithm, which base64::STANDARD does not implement), structuredClone, and performance.now() / timeOrigin over a monotonic base. llrt_buffer's module form reads atob / btoa off the globals, so installing them here is what makes require('buffer').atob resolve |
web::form_data |
FormData. It holds entries and nothing else: a host serializes them with its own multipart writer and hands parsed bodies back through from_entries |
web::compression |
CompressionStream / DecompressionStream (gzip, deflate, deflate-raw) over the vendored TransformStream |
web::timers |
setTimeout / setInterval / clearTimeout / clearInterval / setImmediate / queueMicrotask. NOT installed by init: a host supplies a CallbackPolicy so ambient state (ferridriver carries an allow.net grant) survives from arming the timer to running the callback |
web::blob_bytes |
The bytes-and-type read of a Blob / File value |
web::js_iterator |
The live-iterator protocol object FormData's entries / keys / values return |
Keeping it re-syncable
Sources are kept byte-close to upstream, including upstream's 4-space
formatting, so a re-sync against a newer llrt stays a mechanical diff. The
crate therefore does not inherit the workspace lints (see its
Cargo.toml), and cargo fmt must not be pointed at it.
Re-sync recipe (from a checkout of llrt):
for; do
name=""; path=""
&&
done
# `text` takes only llrt_util's four codec files; its own mod.rs stays.
for; do
done
# per-module first, then the cross-crate rewrite (BSD sed has no \b — use perl)
for; do
| while ; do
done
done
| while ; do
done
Then re-apply the local deltas below.
Local deltas
Everything here is a fix or a visibility widening, never a behaviour change for ferridriver's convenience. Upstream candidates.
-
Upstream regressions we do NOT take. Still true at the 2026-09-06 main sync: upstream still ships the two transform-stream bugs listed in deltas 2 and 3 below — and has since changed
transform_stream_error_writable_and_unblock_writeto take_eand ignore it, moving further from the spec. A future re-sync must keep OUR versions ofstream_web/transform/{controller,stream}.rs,stream_web/writable/mod.rsand the visibility widenings; taking upstream wholesale reintroduces a hungread()and aJS_FreeRuntimeassertion at teardown. -
abort/abort_signal.rs— thesleep-tokioarm importsCtxExtensionfromllrt_utils::ctx, where it does not exist; it lives inllrt_context. Repointed atcrate::context. (Upstream only builds the defaultsleep-timersarm, which is why this never surfaced there.) -
stream_web/transform/controller.rs—TransformStreamDefaultControllerPerformTransformwas missing spec step 3: reacting to the transform promise's rejection by erroring the stream. Atransform()that threw left both sides live, so a pendingreader.read()never settled. -
stream_web/transform/controller.rs—TransformStreamErrorWritableAndUnblockWritewas missingWritableStreamDefaultControllerErrorIfNeeded, so an errored transform left its writable in the"writable"state with an unresolved write request. Addedstream_web::writable::writable_stream_error_if_neededfor it. -
Visibility widenings only —
SizeAlgorithm/SizeValue/SizeFunction/NativeSizeFunctionfrompub(super)topub(crate), andwritable_stream_default_controller_errortopub(crate). Upstream these were crate-visible because each module was its own crate; nesting them under one crate narrowed them below what their own public API needs. -
Feature gates —
sleep-tokiois on by default.sleep-timersis deliberately not a Cargo feature (the timers module is not vendored, so--all-featureswould otherwise enable an arm that cannot compile); it is declared to rustc as a known-but-never-set cfg viacheck-cfginCargo.toml, which keeps the upstreamcfgarms compiling out silently. -
rquickjshalffeature — enabled because the syncedutils/bytes.rsandstream_web/readable/byob_reader.rshandleFloat16Array. Without itPredefinedAtom::Float16Arraydoes not exist andf16has noTypedArrayItemimpl. -
Tests —
abort::abort_signal::tests::test_abort_signalis no longer gated onsleep-timers, so it covers thesleep-tokiopath we build. Two regression tests were added tostream_web/transform/tests.rsfor deltas 2 and 3. -
os/mod.rs— no Windows arm.llrt_os'swindows.rsis not vendored: ferridriver targets macOS and Linux, and that arm needs four Windows-only dependencies (whoami,windows-registry,windows-result,windows-version). -
os/unix.rs—getpwuid_rinstead of theuserscrate. Upstream read the login name and shell throughusers0.11, which has been unmaintained since 2021, and at the 2026-09-06 sync moved touzers0.12, the maintained fork. The replacement callsgetpwuid_rdirectly — the same call either crate makes — including its ERANGE grow-the-buffer protocol, so the one-line upstream swap is not taken and neither crate is a dependency here. -
os/statistics.rs— real CPU times. Upstream returnstimes: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }for every CPU, with the comment "cannot be obtained at this time". sysinfo does not expose them, but the kernel does:/proc/staton Linux andhost_processor_infoon macOS, which is where libuv reads them for Node. Ticks are converted to milliseconds through_SC_CLK_TCK. Darwin does not account interrupt time, soirqstays 0 there — as it does in libuv. -
os/mod.rs—fill()/os_object()split. Upstream fills the module's default export inline insideevaluate. ferridriver serves every native module twice, as an ES module and as a synchronousrequire()namespace, and its loader requires both to read from one place, so the body moved into a function. -
osfeature gates. Upstream'ssystem/statistics/networkfeatures are declared here too (all on by default) so the#[cfg]arms stay exactly as upstream wrote them.
Known gaps against Node
os.constants(signal, errno, priority and dlopen tables) is not implemented upstream and is not added here.networkInterfaces()marks link-local and multicast addressesinternal: true; Node marks only loopback interfaces internal.
-
buffer/blob.rsandbuffer/file.rs— three fixes. Both files ARE vendored andinitdefines both classes (an earlier version of this note said neither was taken).Blob::streamcopies the bytes out before building the pull closure: upstream captures the JSArrayBufferin a native closure, a cycle the collector cannot see, which tripsJS_FreeRuntime'slist_empty(&rt->gc_obj_list)assertion at teardown.File::from_bytesgoes throughBlob::from_bytesrather thaninto_js, which made a JS array of numbers thatnew Blob([...])stringified (File.from_bytes(b"hi")read back as"104105"). Andbuffer/mod.rschainsFile.prototypetoBlob.prototypeafter defining both classes, since rquickjs classes do not inherit and upstream leavesfile instanceof Blobfalse. -
buffer/class.rsis upstream'sbuffer.rs, renamed. Abuffermodule inside abuffermodule tripsclippy::module_inception, which is on by default and the repo's gate runs-D warnings. -
buffer/mod.rs—equalsandtoJSON. Node defines both onBuffer.prototype; upstream defines neither, and the hand-written class this vendoring replaced had both, so not adding them would be a regression. Added afterset_prototyperather than inside the vendored file, soclass.rsstays a mechanical diff. Upstream candidates. -
llrt_encoding's build script is not vendored. It only callsllrt_build::set_nightly_cfg(); this repo pins stable. As of the 2026-09-06 sync upstream has dropped its lastrust_nightlyarm (bytes_to_utf16_stringnow uses the stableas_chunks), so no vendored file reads either cfg;rust_nightlyandnightlystay declared as known-but-never-set cfgs inCargo.tomlso a future upstream arm compiles out silently rather than warning.
Known gaps against Node — Buffer
Buffer is a real Uint8Array subclass, so every typed-array method
works and index access reads bytes. Missing against Node: the
string-aware overrides of includes / indexOf / lastIndexOf / fill
(the Uint8Array versions are inherited, so they take byte values, not
strings), swap16 / swap32 / swap64, compare, and Buffer.poolSize.
-
crypto/provider/{ring,openssl,graviola}.rsare not vendored. Only the pure-Rust provider (crypto-rust, upstream's own default) is taken; the other three back-ends would each add a system dependency. Their feature names are declared as known-but-unset cfgs. Upstream's_modern-webcryptomarker (ML-DSA, ML-KEM, the hybrid KEMs, ChaCha20-Poly1305, SHA-3 / cSHAKE / TurboSHAKE,supports,getPublicKey,encapsulate*/decapsulate*) is declared and on: every upstream provider enables it, and its back-ends (ml-dsa,ml-kem,chacha20poly1305,sha3,shake,cshake,keccak,sponge-cursor,ctutils) are all pure Rust.provider/modern.rsis provider-independent upstream and is vendored as is. -
crypto/jsonmacro imports.iterable_enumandstr_enumare#[macro_export]ed, so they live at the crate root rather than underutils— the import lines are repointed atcrate::. -
Hash crates keep their
oidfeature.sha1/sha2/md-5are taken withoid(andaes-gcmwithhazmat): PKCS#1 v1.5 signing needsAssociatedOid, and WebCrypto allows 32- and 64-bit GCM tags, which are gated behind those features in the 0.11 releases. -
url/url_search_params.rs— a non-string, non-object init. Upstream ignores it, sonew URLSearchParams(null)andnew URLSearchParams(42)both build an EMPTY query. WebIDL's init union is not nullable, so anything that is neither a sequence nor a record converts to USVString: the queries arenulland42, which is what every browser engine produces. Onlyundefined(the argument omitted) means empty. -
encoding/mod.rs—windows-1252/latin1/asciiare real. Upstream foldsWindows1252into the UTF-8 arm in every direction, sonew TextDecoder('windows-1252').decode([0xE9])answered U+FFFD andBuffer.from('é', 'latin1')produced the two UTF-8 bytes rather than one. Worse, one label map served both consumers, which cannot be right: Node'slatin1is ISO-8859-1 and itsasciimasks the high bit, while the WHATWG Encoding Standard maps BOTH labels towindows-1252. There are now two maps —Encoder::from_strfor Buffer,Encoder::from_web_labelforTextDecoder— and three single-byte variants (Windows1252with the real 0x80-0x9F index,Latin1,Ascii) implemented in both directions. -
url/mod.rs—fileURLToPathdecodes and validates. Upstream strips thefile://prefix and hands the rest toPathBuf: the scheme is never checked, a host is silently swallowed, a query or fragment stays in the path, and percent-escapes are NOT decoded, sofile:///tmp/a%20b.txtnames a file whose name literally contains%20. Node checks the scheme, refuses a host it cannot address locally, drops query and fragment, decodes the escapes and refuses an ENCODED separator (which would otherwise change which file is named). -
url/url_class.rs—urlToHttpOptionsmatches Node's shape. Upstream reportsportas a STRING, omitssearch/hashwhen they are empty, keeps the brackets on an IPv6hostname, and joins the raw percent-encoded credentials intoauth. Node reports a numeric port, always setssearchandhash, hands over a bare IPv6 host (what a socket connect takes) anddecodeURIComponents the credentials.URL::inner_urlwas upstream's only reader for the omitted-hash branch and goes with it; thepercent-encodingdep is for the credential decode (urlkeeps that crate private). -
fs/mod.rs—existsSync. Node has it and a large share of real code calls it; upstream ships neither it nor a callback API, so without it the only way to ask whether a file is there is to catch astatrejection. -
fs/mod.rs— one namespace per VM. Upstream builds a fresh exports object per module evaluation. Node answers the SAME object forrequire("fs"),require("node:fs")andfs.promisesvsrequire("fs/promises"), so the namespaces are built once per context and remembered; thefsglobal is that object too. Without it, identity comparisons are false and a caller who patches a method patches a copy nobody else sees. -
zlib— the codec back-ends taken. Upstream's default iscompression-c, which links a system zlib-ng, brotli and zstd. This crate takes the pure-Rust back-end wherever one exists (flate2/rust_backend, which is also whatCompressionStreamalready used, and thebrotlicrate) andzstd-cfor zstd, which upstream's own manifest says has no pure-Rust implementation. Thezstdcrate vendors and compiles the C rather than needing one installed, so the build still has no system dependency — the same posture asrquickjs-syscompiling QuickJS. All six upstream feature names are declared, so its#[cfg]arms stay exactly as written. -
compression/streaming.rsis not vendored. Its only consumer upstream isllrt_fetch's response decoder, and this crate does not vendorllrt_fetch(ferridriver'sfetchis its own, overreqwest). Carrying it would be dead code that also tripsclippy::large_enum_variant—StreamingDecoder's zstd variant is ~336 bytes larger than the next — and boxing a variant to satisfy a lint in code nothing calls is worse than not taking the file. Same reasoning asbuffer/blob.rs,crypto/provider/ring.rsandos/windows.rs. -
zlib/codec.rsandstring_decoder/decoder.rsare upstream'szlib.rsandstring_decoder.rs, renamed. A module with its parent's name tripsclippy::module_inception, which is on by default and the repo's gate runs-D warnings. Same fix as delta 14. -
zlib/{brotli,codec,zstd}.rs— macro imports.define_sync_functionanddefine_cb_functionare#[macro_export]ed, so they live at the crate root rather than underzlib. Theuse super::{...}lines are split: the two macros come fromcrate::, the plain items still fromsuper::. Same cause as delta 18. -
perf_hooks— the module only, not the global. Upstream'sinitinstalls its ownPerformanceclass onglobalThis, and taking it would be a regression:Performance::nowreadsllrt_utils::time::now_nanos, which isSystemTime::now()— the WALL clock. High Resolution Time exists to give a monotonic reading, so upstream'snow()steps backwards whenever the system clock does, andsaturating_subclamps that to0rather than surfacing it.web::init's readsInstant::elapsed, which is monotonic by construction. Upstream also leavesorigin_nanos()at0until a host callstime::init(), and an unset origin makesnow()return the whole Unix epoch in milliseconds.Upstream's MODULE body only reads
globalThis.performanceand re-exports it, so droppinginit(andperformance.rswith it) leavesperf_hooksserving ferridriver's own.The two things upstream's class had over the old plain object —
toJSON()and being a real class instance — are inweb::performancenow, along with the User Timing and Performance Timeline surface neither side had. See "performance" below. -
navigator— this runtime's name. Upstream hardcodesuserAgenttollrt <version>. Shipping that verbatim would answer every user-agent sniffer with the wrong runtime. It readsferridriver/<version>here, which is the shape Node 21+ uses.Worth knowing before relying on it: a
navigatorglobal is still how some libraries decide they are in a browser, the mirror image of theprocess.versions.nodecheck. It is Node parity, not a browser claim, but a package that misroutes on it is misrouting for this reason. -
stream/webregistered as a specifier. The implementation was vendored from the start but only ever installed as globals, soimport { ReadableStream } from 'node:stream/web'— Node's own name for it, and how a library reaches it without assuming a browser — resolved to nothing.modules.rsnow serves it, reading the classes back off the globals so there is still one implementation.
performance
web::performance is ferridriver's, not vendored, and covers three
specs rather than the now() / timeOrigin pair it started as:
- High Resolution Time —
now(),timeOrigin,toJSON().now()readsInstant::elapsed; the wall clock appears exactly once, astimeOrigin, which is what the monotonic readings are relative to. - User Timing —
mark(),measure(),clearMarks(),clearMeasures(), and thePerformanceMark/PerformanceMeasureclasses with theirdetail.measureimplements all three overloads (bare name, start-mark, options bag) and refuses the two combinations the spec calls out: an options bag together with a trailingendMark, andstart+end+durationall at once. - Performance Timeline —
getEntries(),getEntriesByName(),getEntriesByType(), sorted chronologically bystartTimerather than by insertion, becausemark(name, { startTime })can backdate an entry. The sort is stable, so entries sharing astartTimekeep the order they were recorded in.
PerformanceMark and PerformanceMeasure chain their prototype to
PerformanceEntry, so mark instanceof PerformanceEntry holds.
Constructibility follows the IDL: PerformanceMark takes
(name, options) and does NOT buffer (only performance.mark()
records), while PerformanceEntry, PerformanceMeasure and
Performance throw Illegal constructor.
performance.now() and process.hrtime() count from ONE base
(performance::monotonic_base), so the two are correlatable the way
Node's are — it derives both from a single libuv hrtime. Two separate
Instant::now() calls would put a constant, invisible skew between
them; a test asserts they agree within a millisecond.
Not implemented: PerformanceObserver (it needs a task-queue hook this
runtime has no equivalent of), the resource and navigation entry types
(no document), Node's eventLoopUtilization / nodeTiming, and a
buffer size limit — nothing evicts, so a program marking in a hot loop
grows the buffer until it calls clearMarks.
Intl is absent, and llrt cannot fill it
QuickJS-ng as vendored by rquickjs-sys contains no ECMA-402 at all —
no Intl object, and no build flag that would add one. react-intl and
anything like it fails with Intl is not defined.
llrt_intl does not close this. It is Intl.DateTimeFormat plus
supportedValuesOf and Date.prototype.toLocaleString, aimed at
timezone support, and it carries a jiff dependency and ~3k lines of
bundled CLDR data. There is no NumberFormat, PluralRules or
Collator — which is the part a formatting library actually reaches
for.
Until that changes, a suite that needs Intl supplies a JS polyfill
through [bundler.alias], which keeps the choice (and its weight) in
the extension that needs it.
crypto/subtle/digest.rs— a synchronous validation failure is kept. Upstream validates the algorithm before returning the future, so the exception is thrown whilesubtle_digestis still returningOk(future); by the time the future runs, the pending exception is gone and the promise rejects with an uninitialized value (typeof e === "unknown"). The thrown value is now taken withctx.catch()at that point and re-thrown inside the future, where the rejection is built. Upstream candidate.