ferrijs-std 0.2.0

Node and web standard library for the ferrijs QuickJS runtime: WHATWG Streams, Events, AbortController, Buffer, crypto, fs, os, url, zlib and the capability model they enforce (partly derived from awslabs/llrt, Apache-2.0).
Documentation

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, structuredClone and performance.
  • jsstd::modules::modules() — every Node / web MODULE it serves, each entry carrying its specifiers, the ModuleDef the ES loader declares, and the object require() 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, its require table 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 m in utils:libs/llrt_utils context:libs/llrt_context \
         zlib:modules/llrt_zlib string_decoder:modules/llrt_string_decoder \
         perf_hooks:modules/llrt_perf_hooks tty:modules/llrt_tty \
         navigator:modules/llrt_navigator compression:libs/llrt_compression \
         encoding:libs/llrt_encoding exceptions:modules/llrt_exceptions \
         events:modules/llrt_events abort:modules/llrt_abort \
         os:modules/llrt_os buffer:modules/llrt_buffer \
         fs:modules/llrt_fs \
         json:libs/llrt_json crypto:modules/llrt_crypto \
         url:modules/llrt_url \
         stream_web:modules/llrt_stream_web; do
  name="${m%%:*}"; path="${m##*:}"
  cp -R "$LLRT/$path/src" "src/$name" && mv "src/$name/lib.rs" "src/$name/mod.rs"
done
# `text` takes only llrt_util's four codec files; its own mod.rs stays.
for f in text_encoder text_decoder text_encoder_stream text_decoder_stream; do
  cp "$LLRT/modules/llrt_util/src/$f.rs" "src/text/$f.rs"
done
# per-module first, then the cross-crate rewrite (BSD sed has no \b — use perl)
for name in utils context encoding exceptions events abort os buffer json crypto url text stream_web \
            zlib string_decoder perf_hooks tty navigator compression; do
  find "src/$name" -name '*.rs' | while read -r f; do
    perl -pi -e "s/\bcrate::/crate::${name}::/g" "$f"
  done
done
find src -name '*.rs' | while read -r f; do
  perl -pi -e 's/\bllrt_([a-z_]+)/crate::$1/g' "$f"
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.

  1. 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_write to take _e and ignore it, moving further from the spec. A future re-sync must keep OUR versions of stream_web/transform/{controller,stream}.rs, stream_web/writable/mod.rs and the visibility widenings; taking upstream wholesale reintroduces a hung read() and a JS_FreeRuntime assertion at teardown.

  2. abort/abort_signal.rs — the sleep-tokio arm imports CtxExtension from llrt_utils::ctx, where it does not exist; it lives in llrt_context. Repointed at crate::context. (Upstream only builds the default sleep-timers arm, which is why this never surfaced there.)

  3. stream_web/transform/controller.rsTransformStreamDefaultControllerPerformTransform was missing spec step 3: reacting to the transform promise's rejection by erroring the stream. A transform() that threw left both sides live, so a pending reader.read() never settled.

  4. stream_web/transform/controller.rsTransformStreamErrorWritableAndUnblockWrite was missing WritableStreamDefaultControllerErrorIfNeeded, so an errored transform left its writable in the "writable" state with an unresolved write request. Added stream_web::writable::writable_stream_error_if_needed for it.

  5. Visibility widenings onlySizeAlgorithm / SizeValue / SizeFunction / NativeSizeFunction from pub(super) to pub(crate), and writable_stream_default_controller_error to pub(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.

  6. Feature gatessleep-tokio is on by default. sleep-timers is deliberately not a Cargo feature (the timers module is not vendored, so --all-features would otherwise enable an arm that cannot compile); it is declared to rustc as a known-but-never-set cfg via check-cfg in Cargo.toml, which keeps the upstream cfg arms compiling out silently.

  7. rquickjs half feature — enabled because the synced utils/bytes.rs and stream_web/readable/byob_reader.rs handle Float16Array. Without it PredefinedAtom::Float16Array does not exist and f16 has no TypedArrayItem impl.

  8. Testsabort::abort_signal::tests::test_abort_signal is no longer gated on sleep-timers, so it covers the sleep-tokio path we build. Two regression tests were added to stream_web/transform/tests.rs for deltas 2 and 3.

  9. os/mod.rs — no Windows arm. llrt_os's windows.rs is not vendored: ferridriver targets macOS and Linux, and that arm needs four Windows-only dependencies (whoami, windows-registry, windows-result, windows-version).

  10. os/unix.rsgetpwuid_r instead of the users crate. Upstream read the login name and shell through users 0.11, which has been unmaintained since 2021, and at the 2026-09-06 sync moved to uzers 0.12, the maintained fork. The replacement calls getpwuid_r directly — 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.

  11. os/statistics.rs — real CPU times. Upstream returns times: { 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/stat on Linux and host_processor_info on macOS, which is where libuv reads them for Node. Ticks are converted to milliseconds through _SC_CLK_TCK. Darwin does not account interrupt time, so irq stays 0 there — as it does in libuv.

  12. os/mod.rsfill() / os_object() split. Upstream fills the module's default export inline inside evaluate. ferridriver serves every native module twice, as an ES module and as a synchronous require() namespace, and its loader requires both to read from one place, so the body moved into a function.

  13. os feature gates. Upstream's system / statistics / network features 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 addresses internal: true; Node marks only loopback interfaces internal.
  1. buffer/blob.rs and buffer/file.rs — three fixes. Both files ARE vendored and init defines both classes (an earlier version of this note said neither was taken). Blob::stream copies the bytes out before building the pull closure: upstream captures the JS ArrayBuffer in a native closure, a cycle the collector cannot see, which trips JS_FreeRuntime's list_empty(&rt->gc_obj_list) assertion at teardown. File::from_bytes goes through Blob::from_bytes rather than into_js, which made a JS array of numbers that new Blob([...]) stringified (File.from_bytes(b"hi") read back as "104105"). And buffer/mod.rs chains File.prototype to Blob.prototype after defining both classes, since rquickjs classes do not inherit and upstream leaves file instanceof Blob false.

  2. buffer/class.rs is upstream's buffer.rs, renamed. A buffer module inside a buffer module trips clippy::module_inception, which is on by default and the repo's gate runs -D warnings.

  3. buffer/mod.rsequals and toJSON. Node defines both on Buffer.prototype; upstream defines neither, and the hand-written class this vendoring replaced had both, so not adding them would be a regression. Added after set_prototype rather than inside the vendored file, so class.rs stays a mechanical diff. Upstream candidates.

  4. llrt_encoding's build script is not vendored. It only calls llrt_build::set_nightly_cfg(); this repo pins stable. As of the 2026-09-06 sync upstream has dropped its last rust_nightly arm (bytes_to_utf16_string now uses the stable as_chunks), so no vendored file reads either cfg; rust_nightly and nightly stay declared as known-but-never-set cfgs in Cargo.toml so 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.

  1. crypto/provider/{ring,openssl,graviola}.rs are 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-webcrypto marker (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.rs is provider-independent upstream and is vendored as is.

  2. crypto / json macro imports. iterable_enum and str_enum are #[macro_export]ed, so they live at the crate root rather than under utils — the import lines are repointed at crate::.

  3. Hash crates keep their oid feature. sha1 / sha2 / md-5 are taken with oid (and aes-gcm with hazmat): PKCS#1 v1.5 signing needs AssociatedOid, and WebCrypto allows 32- and 64-bit GCM tags, which are gated behind those features in the 0.11 releases.

  4. url/url_search_params.rs — a non-string, non-object init. Upstream ignores it, so new URLSearchParams(null) and new 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 are null and 42, which is what every browser engine produces. Only undefined (the argument omitted) means empty.

  5. encoding/mod.rswindows-1252 / latin1 / ascii are real. Upstream folds Windows1252 into the UTF-8 arm in every direction, so new TextDecoder('windows-1252').decode([0xE9]) answered U+FFFD and Buffer.from('é', 'latin1') produced the two UTF-8 bytes rather than one. Worse, one label map served both consumers, which cannot be right: Node's latin1 is ISO-8859-1 and its ascii masks the high bit, while the WHATWG Encoding Standard maps BOTH labels to windows-1252. There are now two maps — Encoder::from_str for Buffer, Encoder::from_web_label for TextDecoder — and three single-byte variants (Windows1252 with the real 0x80-0x9F index, Latin1, Ascii) implemented in both directions.

  6. url/mod.rsfileURLToPath decodes and validates. Upstream strips the file:// prefix and hands the rest to PathBuf: the scheme is never checked, a host is silently swallowed, a query or fragment stays in the path, and percent-escapes are NOT decoded, so file:///tmp/a%20b.txt names 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).

  7. url/url_class.rsurlToHttpOptions matches Node's shape. Upstream reports port as a STRING, omits search / hash when they are empty, keeps the brackets on an IPv6 hostname, and joins the raw percent-encoded credentials into auth. Node reports a numeric port, always sets search and hash, hands over a bare IPv6 host (what a socket connect takes) and decodeURIComponents the credentials. URL::inner_url was upstream's only reader for the omitted-hash branch and goes with it; the percent-encoding dep is for the credential decode (url keeps that crate private).

  8. fs/mod.rsexistsSync. 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 a stat rejection.

  9. fs/mod.rs — one namespace per VM. Upstream builds a fresh exports object per module evaluation. Node answers the SAME object for require("fs"), require("node:fs") and fs.promises vs require("fs/promises"), so the namespaces are built once per context and remembered; the fs global is that object too. Without it, identity comparisons are false and a caller who patches a method patches a copy nobody else sees.

  10. zlib — the codec back-ends taken. Upstream's default is compression-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 what CompressionStream already used, and the brotli crate) and zstd-c for zstd, which upstream's own manifest says has no pure-Rust implementation. The zstd crate vendors and compiles the C rather than needing one installed, so the build still has no system dependency — the same posture as rquickjs-sys compiling QuickJS. All six upstream feature names are declared, so its #[cfg] arms stay exactly as written.

  11. compression/streaming.rs is not vendored. Its only consumer upstream is llrt_fetch's response decoder, and this crate does not vendor llrt_fetch (ferridriver's fetch is its own, over reqwest). Carrying it would be dead code that also trips clippy::large_enum_variantStreamingDecoder'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 as buffer/blob.rs, crypto/provider/ring.rs and os/windows.rs.

  12. zlib/codec.rs and string_decoder/decoder.rs are upstream's zlib.rs and string_decoder.rs, renamed. A module with its parent's name trips clippy::module_inception, which is on by default and the repo's gate runs -D warnings. Same fix as delta 14.

  13. zlib/{brotli,codec,zstd}.rs — macro imports. define_sync_function and define_cb_function are #[macro_export]ed, so they live at the crate root rather than under zlib. The use super::{...} lines are split: the two macros come from crate::, the plain items still from super::. Same cause as delta 18.

  14. perf_hooks — the module only, not the global. Upstream's init installs its own Performance class on globalThis, and taking it would be a regression: Performance::now reads llrt_utils::time::now_nanos, which is SystemTime::now() — the WALL clock. High Resolution Time exists to give a monotonic reading, so upstream's now() steps backwards whenever the system clock does, and saturating_sub clamps that to 0 rather than surfacing it. web::init's reads Instant::elapsed, which is monotonic by construction. Upstream also leaves origin_nanos() at 0 until a host calls time::init(), and an unset origin makes now() return the whole Unix epoch in milliseconds.

    Upstream's MODULE body only reads globalThis.performance and re-exports it, so dropping init (and performance.rs with it) leaves perf_hooks serving ferridriver's own.

    The two things upstream's class had over the old plain object — toJSON() and being a real class instance — are in web::performance now, along with the User Timing and Performance Timeline surface neither side had. See "performance" below.

  15. navigator — this runtime's name. Upstream hardcodes userAgent to llrt <version>. Shipping that verbatim would answer every user-agent sniffer with the wrong runtime. It reads ferridriver/<version> here, which is the shape Node 21+ uses.

    Worth knowing before relying on it: a navigator global is still how some libraries decide they are in a browser, the mirror image of the process.versions.node check. It is Node parity, not a browser claim, but a package that misroutes on it is misrouting for this reason.

  16. stream/web registered as a specifier. The implementation was vendored from the start but only ever installed as globals, so import { 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.rs now 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 Timenow(), timeOrigin, toJSON(). now() reads Instant::elapsed; the wall clock appears exactly once, as timeOrigin, which is what the monotonic readings are relative to.
  • User Timingmark(), measure(), clearMarks(), clearMeasures(), and the PerformanceMark / PerformanceMeasure classes with their detail. measure implements all three overloads (bare name, start-mark, options bag) and refuses the two combinations the spec calls out: an options bag together with a trailing endMark, and start + end + duration all at once.
  • Performance TimelinegetEntries(), getEntriesByName(), getEntriesByType(), sorted chronologically by startTime rather than by insertion, because mark(name, { startTime }) can backdate an entry. The sort is stable, so entries sharing a startTime keep 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.

  1. crypto/subtle/digest.rs — a synchronous validation failure is kept. Upstream validates the algorithm before returning the future, so the exception is thrown while subtle_digest is still returning Ok(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 with ctx.catch() at that point and re-thrown inside the future, where the rejection is built. Upstream candidate.