diff --git a/changelog.d/7029-stdlib-cherry-pick.md b/changelog.d/7029-stdlib-cherry-pick.md new file mode 100644 index 0000000000..be50e3f52d --- /dev/null +++ b/changelog.d/7029-stdlib-cherry-pick.md @@ -0,0 +1,6 @@ +**Stdlib cherry-pick: auto-optimized binaries only link the stdlib subsystems the program actually uses** — two coarse inclusion points split into finer-grained units: + +- **`crypto` is no longer force-enabled for every stdlib-linking build.** The auto-optimize driver used to insert perry-stdlib's `crypto` feature unconditionally (a leftover safety net from the old `perry/updater` → `js_crypto_ed25519_verify` extern), so a program that only imported `node:zlib` still linked RSA/EC/Ed25519/Ed448/ML-KEM/x509/JWT/bcrypt/argon2. The feature now joins only via the import mapping, the `crypto.*`-builtin/WebCrypto HIR gates, or a new codegen prefix net: any emitted `js_crypto_*` / `js_webcrypto_*` call recorded by `perry_codegen::ext_registry` flips the feature (with an object-cache-replayable marker, so warm caches reproduce the flip). An `async-runtime` floor stays forced — the always-on worker_threads/readline bridge needs it, exactly as it (transitively) always did. +- **`compression` split per codec**: `compression-gzip` (flate2 base, gates `pub mod zlib`), `compression-brotli`, and `compression-zstd` (each implying the base); the `compression` umbrella remains as their union, so default/`full` builds and `--features compression` callers are unchanged. `import 'node:zlib'` selects only the gzip/deflate family; Brotli/zstd back-ends are added when HIR usage detection sees a matching API token (`brotli*`/`BROTLI_*`, `zstd*`/`Zstd*`/`ZSTD_*`), or unconditionally when deferred dynamic code could name a codec at runtime. When the well-known flip routes `node:zlib` to `perry-ext-zlib`, the sub-features are dropped along with the base so the ext crate's `js_zlib_*` symbols can't be duplicated. + +Measured on macOS arm64 (stripped release binaries, auto-optimize path): gzip-only `node:zlib` program 9,629,736 B → 6,618,416 B (−31.3%); `node:zlib` + `node:events` mid-size program 9,662,968 B → 6,668,160 B (−31.0%); `crypto.randomUUID()` builtin program unchanged at 7.86 MB (correctly still links crypto); hello-world unchanged (runtime-only link, no stdlib). Feature strings feed `auto_optimized_cache_key`, so the new sets produce fresh build stamps automatically. diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index 7ebca1341b..1cf57df2cb 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -626,6 +626,36 @@ pub(crate) fn record_ffi_call(symbol: &str) { return; } } + + // Prefix safety net (stdlib cherry-pick): the auto-optimize driver no + // longer force-enables perry-stdlib's `crypto` feature for every build, + // so ANY codegen-emitted call into the node:crypto / WebCrypto surface + // must flip the feature here — the import mapping and the + // `uses_crypto_builtins` HIR gate cover the known shapes, and this rule + // covers everything else (compiled-package lowering, future HIR + // variants) without maintaining a per-symbol table: every FFI defined + // by perry-stdlib's crypto/webcrypto modules carries one of these two + // prefixes. `js_webcrypto_illegal_constructor` is excluded — it lives + // in perry-runtime (object/global_this/ctor_thunks.rs), so emitting it + // needs no stdlib feature. The MODULE_CAPTURE marker is the prefix + // itself: replaying it through this function (object-cache manifest, + // #6439) re-enters this arm and reproduces the same owner. + if symbol.starts_with("js_crypto_") + || (symbol.starts_with("js_webcrypto_") && symbol != "js_webcrypto_illegal_constructor") + { + let owner = OwnerKind::Stdlib { + feature: Some("crypto"), + }; + { + let mut guard = USED_PROVIDERS.lock().expect("USED_PROVIDERS poisoned"); + guard.get_or_insert_with(HashSet::new).insert(owner); + } + MODULE_CAPTURE.with(|cell| { + if let Some(set) = cell.borrow_mut().as_mut() { + set.insert("js_crypto_"); + } + }); + } } /// Start capturing this thread's registry-symbol emissions. Call @@ -872,6 +902,67 @@ mod tests { } } + /// Stdlib cherry-pick: the driver no longer force-enables perry-stdlib's + /// `crypto` feature, so every codegen-emitted `js_crypto_*` / + /// `js_webcrypto_*` call must flip it through the prefix net — including + /// the `"js_crypto_"` marker persisted to (and replayed from) the object + /// cache's FFI manifest. `js_webcrypto_illegal_constructor` lives in + /// perry-runtime and must NOT flip the feature. + #[test] + fn emitted_crypto_symbols_route_to_stdlib_crypto_feature() { + let _guard = PROVIDER_TEST_LOCK + .lock() + .expect("provider test lock poisoned"); + let crypto_owner = OwnerKind::Stdlib { + feature: Some("crypto"), + }; + for symbol in [ + "js_crypto_sha256", + "js_crypto_create_hash", + "js_crypto_ed25519_verify", + "js_webcrypto_digest", + // Object-cache replay marker (see record_ffi_call). + "js_crypto_", + ] { + assert_symbol_routes_to(symbol, crypto_owner); + } + + let _ = take_used_providers(); + record_ffi_call("js_webcrypto_illegal_constructor"); + let got = take_used_providers(); + assert!( + !got.contains(&crypto_owner), + "js_webcrypto_illegal_constructor is a perry-runtime thunk and must \ + not flip the stdlib crypto feature, got {got:?}" + ); + } + + /// The prefix net must persist a replayable marker in the per-module + /// capture so a warm object cache reproduces the same feature flip + /// (#6439 shape). + #[test] + fn crypto_prefix_net_marker_survives_module_capture_replay() { + let _guard = PROVIDER_TEST_LOCK + .lock() + .expect("provider test lock poisoned"); + let _ = take_used_providers(); + + begin_module_capture(); + record_ffi_call("js_crypto_pbkdf2"); + let captured = take_module_capture(); + assert_eq!(captured, vec!["js_crypto_"]); + + let _ = take_used_providers(); + replay_ffi_symbols(captured); + let got = take_used_providers(); + assert!( + got.contains(&OwnerKind::Stdlib { + feature: Some("crypto") + }), + "replaying the captured marker must reproduce the crypto flip, got {got:?}" + ); + } + /// #5140 regression: `new EventEmitter()` / `.on` / `.emit` / /// `.removeAllListeners` lower to `js_event_emitter_*` helpers off the /// class name alone, so a program that imports `EventEmitter` from a diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 74ed041bde..40358b36a8 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -248,10 +248,29 @@ bundled-jsonwebtoken = ["dep:jsonwebtoken", "dep:p256", "dep:rsa", "dep:spki"] # routes to perry-ext-ethers when `import 'ethers'` is detected. bundled-ethers = [] -# Compression (zlib + brotli) -# `brotli` rides under the same `compression` umbrella; needed by -# axios's `zlib.createBrotliDecompress` feature-check (#axios e2e). -compression = ["dep:flate2", "dep:brotli", "dep:zstd"] +# Compression (node:zlib) — per-codec split (stdlib cherry-pick). +# `compression-gzip` is the base codec family (gzip/deflate/inflate/ +# unzip/crc32 via flate2) and gates `pub mod zlib` itself; the Brotli +# and zstd codecs layer on top and imply it (the zlib module's stream +# framework, options validation and pump are shared). The auto-optimize +# feature computation maps `import 'node:zlib'` to `compression-gzip` +# and adds `compression-brotli` / `compression-zstd` only when the HIR +# references a Brotli / zstd API token (see +# `collect_modules/feature_detect.rs`), falling back to the full +# umbrella when deferred dynamic code could call codecs by runtime +# strings. The `compression` umbrella stays as the union for +# backwards compatibility (`--features compression` and the default +# `full` build are byte-identical to the pre-split archive). +# `async-runtime` is explicit here (not inherited): zlib's deferred +# stream/one-shot events are queued through `common::async_bridge`'s +# pump, which only compiles under `async-runtime`. Historically the +# auto-optimize path always enabled it transitively (the force-added +# `crypto` feature implied it), so `--features compression` never +# compiled standalone — now it does. +compression-gzip = ["dep:flate2", "async-runtime"] +compression-brotli = ["compression-gzip", "dep:brotli"] +compression-zstd = ["compression-gzip", "dep:zstd"] +compression = ["compression-gzip", "compression-brotli", "compression-zstd"] # Email (nodemailer) — `email` umbrella retained for # backwards-compat; v0.5.558's well-known flip toggles diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index 56ae4dcf41..652e3d3f7f 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -653,7 +653,7 @@ pub extern "C" fn js_stdlib_process_pending() -> i32 { // buffer input across `.write()` and queue 'data'/'end' on `.end()`; // drained + dispatched to listeners (and forwarded to `.pipe()` dests) // here on the main thread. Bundled path (perry-stdlib's own zlib mod): - #[cfg(feature = "compression")] + #[cfg(feature = "compression-gzip")] { count += unsafe { crate::zlib::js_zlib_process_pending() }; } @@ -862,7 +862,7 @@ pub extern "C" fn js_stdlib_has_active_handles() -> i32 { // 'data'/'end' events are still waiting to be drained, so a purely- // synchronous `createGzip().write(x).end()` program doesn't exit before // its listeners fire. Bundled path: - #[cfg(feature = "compression")] + #[cfg(feature = "compression-gzip")] { if crate::zlib::js_zlib_has_active_handles() != 0 { return 1; diff --git a/crates/perry-stdlib/src/common/dispatch.rs b/crates/perry-stdlib/src/common/dispatch.rs index 62c9efe736..800e1ace1e 100644 --- a/crates/perry-stdlib/src/common/dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch.rs @@ -45,7 +45,7 @@ pub(crate) use fastify_net_zlib::dispatch_external_net_socket; not(target_os = "android") ))] pub(crate) use fastify_net_zlib::dispatch_net_socket; -#[cfg(feature = "compression")] +#[cfg(feature = "compression-gzip")] pub(crate) use fastify_net_zlib::dispatch_zlib_stream; pub(crate) type EventEmitterOn = unsafe extern "C" fn(i64, i64, i64) -> i64; diff --git a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs index 8524ab3d8b..490aebb8fe 100644 --- a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs +++ b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs @@ -81,7 +81,7 @@ pub(crate) unsafe fn dispatch_net_socket(handle: i64, method: &str, args: &[f64] /// `.close` lose their static type and arrive here. Compression is synchronous /// and buffered in the runtime: `.write()` accumulates input, `.end()` runs the /// codec and queues 'data'/'end' onto the deferred-event pump. -#[cfg(feature = "compression")] +#[cfg(feature = "compression-gzip")] pub(crate) unsafe fn dispatch_zlib_stream(handle: i64, method: &str, args: &[f64]) -> f64 { fn unbox_to_i64(v: f64) -> i64 { (v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64 diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index a7f3ed06b4..1d1b5659c6 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -658,7 +658,7 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { // address inherits the dead key's material). #[cfg(feature = "crypto")] perry_runtime::buffer::js_set_crypto_key_death_hook(crate::webcrypto::crypto_key_buffer_died); - #[cfg(feature = "compression")] + #[cfg(feature = "compression-gzip")] perry_runtime::js_set_native_zlib_dispatch(crate::zlib::js_zlib_native_dispatch); perry_runtime::js_set_native_querystring_dispatch( crate::querystring::js_querystring_native_dispatch, diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index 3385c3a2c4..12270318a9 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -416,7 +416,7 @@ pub unsafe extern "C" fn js_handle_method_dispatch( // Gated on the registry AND the method vocabulary so a handle-id reused // across another subsystem's registry can't misroute (handle id-spaces // aren't unified — see the long comment above). - #[cfg(feature = "compression")] + #[cfg(feature = "compression-gzip")] if matches!( method_name, "write" diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index 3f893dec17..852e390154 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -66,7 +66,7 @@ pub unsafe extern "C" fn js_handle_property_dispatch( // "function". The actual call dispatch is HANDLE_METHOD_DISPATCH // (above), but feature-checks read through the property table — we // bind a closure here so the typeof short-circuit sees "function". - #[cfg(feature = "compression")] + #[cfg(feature = "compression-gzip")] if crate::zlib::is_zlib_stream_handle(handle) { if property_name == "bytesWritten" { return crate::zlib::zlib_stream_bytes_written(handle); diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 944176c634..7af014eef6 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -300,9 +300,13 @@ pub mod crypto_e2e; pub use crypto_e2e::*; // === Compression === -#[cfg(feature = "compression")] +// Gated on `compression-gzip` (the base codec family) rather than the +// `compression` umbrella so the auto-optimize rebuild can cherry-pick +// codecs: `compression-brotli` / `compression-zstd` imply +// `compression-gzip`, and `compression` is the union of all three. +#[cfg(feature = "compression-gzip")] pub mod zlib; -#[cfg(feature = "compression")] +#[cfg(feature = "compression-gzip")] pub use zlib::*; // === Email === diff --git a/crates/perry-stdlib/src/zlib.rs b/crates/perry-stdlib/src/zlib.rs index 9601e52ff5..2a9031358a 100644 --- a/crates/perry-stdlib/src/zlib.rs +++ b/crates/perry-stdlib/src/zlib.rs @@ -212,17 +212,16 @@ pub unsafe extern "C" fn js_zlib_deflate_sync(data_bits: i64, opts: f64) -> *mut // dead-strips them from the stdlib archive, breaking the link of any program // that uses zlib (surfaced by Next.js's resume-data-cache `inflateSync` once the // #5437 live-import fix makes that module reachable). `#[used]` keeps them. -struct KeepZlibFfi(#[allow(dead_code)] [*const (); 32]); // link-time keepalive anchor; field never read, only #[used] to retain FFI symbols - // SAFETY: a link-time keepalive anchor only — the pointers are never read or - // dereferenced, so cross-thread sharing of the raw pointers is sound. -unsafe impl Sync for KeepZlibFfi {} +// Split per codec feature (stdlib cherry-pick): the Brotli / zstd anchors only +// exist when their codecs are compiled in. +struct KeepZlibFfi( + #[allow(dead_code)] [*const (); N], // link-time keepalive anchor; field never read +); +// SAFETY: a link-time keepalive anchor only — the pointers are never read or +// dereferenced, so cross-thread sharing of the raw pointers is sound. +unsafe impl Sync for KeepZlibFfi {} #[used] -static KEEP_ZLIB_FFI: KeepZlibFfi = KeepZlibFfi([ - js_zlib_brotli_compress as *const (), - js_zlib_brotli_compress_sync as *const (), - js_zlib_brotli_decompress as *const (), - js_zlib_brotli_decompress_sync as *const (), - js_zlib_create_brotli_compress as *const (), +static KEEP_ZLIB_FFI: KeepZlibFfi<21> = KeepZlibFfi([ js_zlib_create_deflate as *const (), js_zlib_create_deflate_raw as *const (), js_zlib_create_gunzip as *const (), @@ -230,8 +229,6 @@ static KEEP_ZLIB_FFI: KeepZlibFfi = KeepZlibFfi([ js_zlib_create_inflate as *const (), js_zlib_create_inflate_raw as *const (), js_zlib_create_unzip as *const (), - js_zlib_create_zstd_compress as *const (), - js_zlib_create_zstd_decompress as *const (), js_zlib_deflate as *const (), js_zlib_deflate_raw as *const (), js_zlib_deflate_raw_sync as *const (), @@ -246,6 +243,22 @@ static KEEP_ZLIB_FFI: KeepZlibFfi = KeepZlibFfi([ js_zlib_inflate_sync as *const (), js_zlib_unzip as *const (), js_zlib_unzip_sync as *const (), +]); +#[cfg(feature = "compression-brotli")] +#[used] +static KEEP_ZLIB_BROTLI_FFI: KeepZlibFfi<6> = KeepZlibFfi([ + js_zlib_brotli_compress as *const (), + js_zlib_brotli_compress_sync as *const (), + js_zlib_brotli_decompress as *const (), + js_zlib_brotli_decompress_sync as *const (), + js_zlib_create_brotli_compress as *const (), + js_zlib_create_brotli_decompress as *const (), +]); +#[cfg(feature = "compression-zstd")] +#[used] +static KEEP_ZLIB_ZSTD_FFI: KeepZlibFfi<6> = KeepZlibFfi([ + js_zlib_create_zstd_compress as *const (), + js_zlib_create_zstd_decompress as *const (), js_zlib_zstd_compress as *const (), js_zlib_zstd_compress_sync as *const (), js_zlib_zstd_decompress as *const (), @@ -359,12 +372,17 @@ enum Codec { DeflateRaw, InflateRaw, Unzip, + #[cfg(feature = "compression-brotli")] BrotliCompress, + #[cfg(feature = "compression-brotli")] BrotliDecompress, + #[cfg(feature = "compression-zstd")] ZstdCompress, + #[cfg(feature = "compression-zstd")] ZstdDecompress, } +#[cfg(feature = "compression-zstd")] const ZSTD_DEFAULT_LEVEL: i32 = 3; fn run_one_shot_codec(codec: Codec, input: &[u8]) -> std::io::Result> { @@ -395,15 +413,19 @@ fn run_one_shot_codec(codec: Codec, input: &[u8]) -> std::io::Result> { ZlibDecoder::new(input).read_to_end(&mut out)?; } } + #[cfg(feature = "compression-brotli")] Codec::BrotliCompress => { out = brotli_compress_bytes(input); } + #[cfg(feature = "compression-brotli")] Codec::BrotliDecompress => { out = brotli_decompress_bytes(input)?; } + #[cfg(feature = "compression-zstd")] Codec::ZstdCompress => { out = zstd_compress_bytes(input)?; } + #[cfg(feature = "compression-zstd")] Codec::ZstdDecompress => { out = zstd_decompress_bytes(input)?; } @@ -469,12 +491,13 @@ pub unsafe extern "C" fn js_zlib_unzip(data_value: f64, callback_value: f64) { // ============================================================================ // Brotli one-shot functions (#1843 cluster 2) // -// The `brotli` crate is already a `compression`-feature dep. Use its +// The `brotli` crate is a `compression-brotli`-feature dep. Use its // reader-based codecs for one-shot compress/decompress, mirroring the // flate2 `*Sync`/async wrappers above. Quality 11 / window 22 are Node's // defaults for `brotliCompressSync`. // ============================================================================ +#[cfg(feature = "compression-brotli")] fn brotli_compress_bytes(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut reader = brotli::CompressorReader::new(data, 4096, 11, 22); @@ -482,6 +505,7 @@ fn brotli_compress_bytes(data: &[u8]) -> Vec { out } +#[cfg(feature = "compression-brotli")] fn brotli_decompress_bytes(data: &[u8]) -> std::io::Result> { let mut out = Vec::new(); let mut reader = brotli::Decompressor::new(data, 4096); @@ -491,6 +515,7 @@ fn brotli_decompress_bytes(data: &[u8]) -> std::io::Result> { /// `zlib.brotliCompressSync(data)` -> Buffer /// +#[cfg(feature = "compression-brotli")] #[no_mangle] pub unsafe extern "C" fn js_zlib_brotli_compress_sync(data_bits: i64) -> *mut BufferHeader { let data = codec_bytes(f64::from_bits(data_bits as u64)); @@ -499,6 +524,7 @@ pub unsafe extern "C" fn js_zlib_brotli_compress_sync(data_bits: i64) -> *mut Bu } /// `zlib.brotliDecompressSync(data)` -> Buffer +#[cfg(feature = "compression-brotli")] #[no_mangle] pub unsafe extern "C" fn js_zlib_brotli_decompress_sync(data_bits: i64) -> *mut BufferHeader { let data = codec_bytes(f64::from_bits(data_bits as u64)); @@ -509,12 +535,14 @@ pub unsafe extern "C" fn js_zlib_brotli_decompress_sync(data_bits: i64) -> *mut } /// `zlib.brotliCompress(data, callback)` -> undefined +#[cfg(feature = "compression-brotli")] #[no_mangle] pub unsafe extern "C" fn js_zlib_brotli_compress(data_value: f64, callback_value: f64) { queue_zlib_callback(Codec::BrotliCompress, data_value, callback_value); } /// `zlib.brotliDecompress(data, callback)` -> undefined +#[cfg(feature = "compression-brotli")] #[no_mangle] pub unsafe extern "C" fn js_zlib_brotli_decompress(data_value: f64, callback_value: f64) { queue_zlib_callback(Codec::BrotliDecompress, data_value, callback_value); @@ -524,15 +552,18 @@ pub unsafe extern "C" fn js_zlib_brotli_decompress(data_value: f64, callback_val // Zstd one-shot functions (#2510) // ============================================================================ +#[cfg(feature = "compression-zstd")] fn zstd_compress_bytes(data: &[u8]) -> std::io::Result> { zstd::stream::encode_all(data, ZSTD_DEFAULT_LEVEL) } +#[cfg(feature = "compression-zstd")] fn zstd_decompress_bytes(data: &[u8]) -> std::io::Result> { zstd::stream::decode_all(data) } /// `zlib.zstdCompressSync(data)` -> Buffer +#[cfg(feature = "compression-zstd")] #[no_mangle] pub unsafe extern "C" fn js_zlib_zstd_compress_sync( data_value: f64, @@ -546,6 +577,7 @@ pub unsafe extern "C" fn js_zlib_zstd_compress_sync( } /// `zlib.zstdDecompressSync(data)` -> Buffer +#[cfg(feature = "compression-zstd")] #[no_mangle] pub unsafe extern "C" fn js_zlib_zstd_decompress_sync( data_value: f64, @@ -559,12 +591,14 @@ pub unsafe extern "C" fn js_zlib_zstd_decompress_sync( } /// `zlib.zstdCompress(data, callback)` -> undefined +#[cfg(feature = "compression-zstd")] #[no_mangle] pub unsafe extern "C" fn js_zlib_zstd_compress(data_value: f64, callback_value: f64) { queue_zlib_callback(Codec::ZstdCompress, data_value, callback_value); } /// `zlib.zstdDecompress(data, callback)` -> undefined +#[cfg(feature = "compression-zstd")] #[no_mangle] pub unsafe extern "C" fn js_zlib_zstd_decompress(data_value: f64, callback_value: f64) { queue_zlib_callback(Codec::ZstdDecompress, data_value, callback_value); @@ -715,6 +749,7 @@ unsafe fn stream_factory_level(opts: f64) -> Compression { /// Warn once when a Brotli/zstd factory receives an options object: their /// option shape (`params` quality/window knobs) is not wired up yet (#4917). +#[cfg(any(feature = "compression-brotli", feature = "compression-zstd"))] unsafe fn warn_ignored_codec_params(opts: f64, name: &'static str) { if JSValue::from_bits(opts.to_bits()).is_pointer() { perry_runtime::stub_diag::perry_stub_warn( @@ -786,6 +821,7 @@ pub unsafe extern "C" fn js_zlib_create_unzip(opts: f64) -> i64 { /// # Safety /// FFI entry; `opts` is the NaN-boxed options object (Brotli `params` are not /// wired up yet — a warn-once fires when an options object is passed). +#[cfg(feature = "compression-brotli")] #[no_mangle] pub unsafe extern "C" fn js_zlib_create_brotli_compress(opts: f64) -> i64 { warn_ignored_codec_params(opts, "zlib.createBrotliCompress options"); @@ -799,6 +835,7 @@ pub unsafe extern "C" fn js_zlib_create_brotli_compress(opts: f64) -> i64 { /// # Safety /// FFI entry; `opts` is the NaN-boxed options object (decompression params /// are not wired up yet — a warn-once fires when an options object is passed). +#[cfg(feature = "compression-brotli")] #[no_mangle] pub unsafe extern "C" fn js_zlib_create_brotli_decompress(opts: f64) -> i64 { warn_ignored_codec_params(opts, "zlib.createBrotliDecompress options"); @@ -807,6 +844,7 @@ pub unsafe extern "C" fn js_zlib_create_brotli_decompress(opts: f64) -> i64 { /// # Safety /// FFI entry; `opts` is the NaN-boxed options object (zstd params are not /// wired up yet — a warn-once fires when an options object is passed). +#[cfg(feature = "compression-zstd")] #[no_mangle] pub unsafe extern "C" fn js_zlib_create_zstd_compress(opts: f64) -> i64 { warn_ignored_codec_params(opts, "zlib.createZstdCompress options"); @@ -815,6 +853,7 @@ pub unsafe extern "C" fn js_zlib_create_zstd_compress(opts: f64) -> i64 { /// # Safety /// FFI entry; `opts` is the NaN-boxed options object (zstd params are not /// wired up yet — a warn-once fires when an options object is passed). +#[cfg(feature = "compression-zstd")] #[no_mangle] pub unsafe extern "C" fn js_zlib_create_zstd_decompress(opts: f64) -> i64 { warn_ignored_codec_params(opts, "zlib.createZstdDecompress options"); @@ -852,15 +891,19 @@ fn run_codec(codec: Codec, input: &[u8]) -> std::io::Result> { ZlibDecoder::new(input).read_to_end(&mut out)?; } } + #[cfg(feature = "compression-brotli")] Codec::BrotliCompress => { out = brotli_compress_bytes(input); } + #[cfg(feature = "compression-brotli")] Codec::BrotliDecompress => { out = brotli_decompress_bytes(input)?; } + #[cfg(feature = "compression-zstd")] Codec::ZstdCompress => { out = zstd_compress_bytes(input)?; } + #[cfg(feature = "compression-zstd")] Codec::ZstdDecompress => { out = zstd_decompress_bytes(input)?; } @@ -881,7 +924,9 @@ enum CodecState { ZlibDec(flate2::write::ZlibDecoder>), DeflateEnc(flate2::write::DeflateEncoder>), DeflateDec(flate2::write::DeflateDecoder>), + #[cfg(feature = "compression-brotli")] BrotliEnc(brotli::CompressorWriter>), + #[cfg(feature = "compression-brotli")] BrotliDec(brotli::DecompressorWriter>), } @@ -894,7 +939,9 @@ impl CodecState { CodecState::ZlibDec(w) => w.write_all(data), CodecState::DeflateEnc(w) => w.write_all(data), CodecState::DeflateDec(w) => w.write_all(data), + #[cfg(feature = "compression-brotli")] CodecState::BrotliEnc(w) => w.write_all(data), + #[cfg(feature = "compression-brotli")] CodecState::BrotliDec(w) => w.write_all(data), } } @@ -907,7 +954,9 @@ impl CodecState { CodecState::ZlibDec(w) => w.flush(), CodecState::DeflateEnc(w) => w.flush(), CodecState::DeflateDec(w) => w.flush(), + #[cfg(feature = "compression-brotli")] CodecState::BrotliEnc(w) => w.flush(), + #[cfg(feature = "compression-brotli")] CodecState::BrotliDec(w) => w.flush(), } } @@ -920,7 +969,9 @@ impl CodecState { CodecState::ZlibDec(w) => std::mem::take(w.get_mut()), CodecState::DeflateEnc(w) => std::mem::take(w.get_mut()), CodecState::DeflateDec(w) => std::mem::take(w.get_mut()), + #[cfg(feature = "compression-brotli")] CodecState::BrotliEnc(w) => std::mem::take(w.get_mut()), + #[cfg(feature = "compression-brotli")] CodecState::BrotliDec(w) => std::mem::take(w.get_mut()), } } @@ -933,7 +984,9 @@ impl CodecState { CodecState::ZlibDec(w) => w.finish(), CodecState::DeflateEnc(w) => w.finish(), CodecState::DeflateDec(w) => w.finish(), + #[cfg(feature = "compression-brotli")] CodecState::BrotliEnc(w) => Ok(w.into_inner()), + #[cfg(feature = "compression-brotli")] CodecState::BrotliDec(w) => Ok(w.into_inner().unwrap_or_else(|v| v)), } } @@ -948,15 +1001,19 @@ fn make_codec_state(codec: Codec, level: Compression) -> Option { Codec::Inflate => CodecState::ZlibDec(write::ZlibDecoder::new(Vec::new())), Codec::DeflateRaw => CodecState::DeflateEnc(write::DeflateEncoder::new(Vec::new(), level)), Codec::InflateRaw => CodecState::DeflateDec(write::DeflateDecoder::new(Vec::new())), + #[cfg(feature = "compression-brotli")] Codec::BrotliCompress => { CodecState::BrotliEnc(brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22)) } + #[cfg(feature = "compression-brotli")] Codec::BrotliDecompress => { CodecState::BrotliDec(brotli::DecompressorWriter::new(Vec::new(), 4096)) } - // Zstd streams stay buffer-until-end for this compatibility cut, - // matching the existing `createUnzip` path. - Codec::Unzip | Codec::ZstdCompress | Codec::ZstdDecompress => return None, + // `createUnzip` buffers until `.end()` (gzip/zlib auto-detect); Zstd + // streams stay buffer-until-end for this compatibility cut too. + Codec::Unzip => return None, + #[cfg(feature = "compression-zstd")] + Codec::ZstdCompress | Codec::ZstdDecompress => return None, }) } @@ -1407,13 +1464,17 @@ pub unsafe extern "C" fn js_zlib_native_dispatch( "deflateRawSync" => ptr_to_f64(js_zlib_deflate_raw_sync(arg(0), arg(1)) as *const u8), "inflateRawSync" => ptr_to_f64(js_zlib_inflate_raw_sync(arg(0)) as *const u8), "unzipSync" => ptr_to_f64(js_zlib_unzip_sync(arg(0)) as *const u8), + #[cfg(feature = "compression-brotli")] "brotliCompressSync" => { ptr_to_f64(js_zlib_brotli_compress_sync(arg(0).to_bits() as i64) as *const u8) } + #[cfg(feature = "compression-brotli")] "brotliDecompressSync" => { ptr_to_f64(js_zlib_brotli_decompress_sync(arg(0).to_bits() as i64) as *const u8) } + #[cfg(feature = "compression-zstd")] "zstdCompressSync" => ptr_to_f64(js_zlib_zstd_compress_sync(arg(0), arg(1)) as *const u8), + #[cfg(feature = "compression-zstd")] "zstdDecompressSync" => { ptr_to_f64(js_zlib_zstd_decompress_sync(arg(0), arg(1)) as *const u8) } @@ -1450,23 +1511,29 @@ pub unsafe extern "C" fn js_zlib_native_dispatch( js_zlib_unzip(arg(0), arg(1)); undefined } + #[cfg(feature = "compression-brotli")] "brotliCompress" => { js_zlib_brotli_compress(arg(0), arg(1)); undefined } + #[cfg(feature = "compression-brotli")] "brotliDecompress" => { js_zlib_brotli_decompress(arg(0), arg(1)); undefined } + #[cfg(feature = "compression-zstd")] "zstdCompress" => { js_zlib_zstd_compress(arg(0), arg(1)); undefined } + #[cfg(feature = "compression-zstd")] "zstdDecompress" => { js_zlib_zstd_decompress(arg(0), arg(1)); undefined } + #[cfg(feature = "compression-zstd")] "createZstdCompress" => ptr_to_f64(js_zlib_create_zstd_compress(arg(0)) as *const u8), + #[cfg(feature = "compression-zstd")] "createZstdDecompress" => ptr_to_f64(js_zlib_create_zstd_decompress(arg(0)) as *const u8), _ => undefined, } @@ -1517,6 +1584,7 @@ mod stream_tests { assert_eq!(run_codec(Codec::Inflate, &c).unwrap(), b"AAAABBBB"); } + #[cfg(feature = "compression-brotli")] #[test] fn brotli_stream_roundtrips() { let c = stream_compress(Codec::BrotliCompress, &[b"brotli ", b"stream ", b"test"]); @@ -1526,6 +1594,7 @@ mod stream_tests { ); } + #[cfg(feature = "compression-zstd")] #[test] fn zstd_one_shot_roundtrips() { let c = run_one_shot_codec(Codec::ZstdCompress, b"zstd one-shot test").unwrap(); @@ -1536,6 +1605,7 @@ mod stream_tests { ); } + #[cfg(feature = "compression-zstd")] #[test] fn zstd_buffer_until_end_stream_roundtrips() { assert!(make_codec_state(Codec::ZstdCompress, Compression::default()).is_none()); diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index 2c9fd1ce0b..ec6b59aaf5 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -29,6 +29,27 @@ fn debug_hir_uses_regex(hir_debug: &str) -> bool { || hir_debug.contains("property: \"globSync\"") } +/// zlib per-codec cherry-pick (stdlib cherry-pick): a `node:zlib` import +/// only selects the gzip/deflate base (`compression-gzip`); the Brotli and +/// zstd backends are linked when a matching API token appears anywhere in +/// the lowered HIR. Method calls surface as `method: "brotliCompressSync"` +/// / `NativeMethodCall { … }` tokens, factory calls as +/// `createBrotliCompress` / `createZstdDecompress`, constants as +/// `BROTLI_*` / `ZSTD_*` property reads. A bare substring match +/// over-includes (a user identifier containing "brotli" links the codec — +/// a size, not a correctness, cost); the rule is zero false negatives for +/// statically-lowered call sites. Fully dynamic access (`zlib[name]`) is +/// covered by the deferred-dynamic-code fallback in +/// `build_optimized_libs`, which enables the full `compression` umbrella. +fn debug_hir_uses_zlib_brotli(hir_debug: &str) -> bool { + hir_debug.contains("rotli") || hir_debug.contains("BROTLI") +} + +/// See [`debug_hir_uses_zlib_brotli`] — same contract for the zstd family. +fn debug_hir_uses_zlib_zstd(hir_debug: &str) -> bool { + hir_debug.contains("zstd") || hir_debug.contains("Zstd") || hir_debug.contains("ZSTD") +} + fn debug_hir_uses_get_builtin_module(hir_debug: &str) -> bool { hir_debug.contains("property: \"getBuiltinModule\"") || (hir_debug.contains("module: \"process\"") @@ -149,6 +170,24 @@ pub(super) fn detect_optional_feature_usage( } } + // zlib per-codec cherry-pick: flag Brotli / zstd API usage so + // `build_optimized_libs` can add `compression-brotli` / + // `compression-zstd` on top of the `compression-gzip` base that a + // `node:zlib` import selects. Scan classes too — a codec call inside a + // static method body must not be stripped from an auto-optimized build. + { + let hir_debug: String = format!( + "{:?}{:?}{:?}", + &hir_module.init, &hir_module.functions, &hir_module.classes + ); + if debug_hir_uses_zlib_brotli(&hir_debug) { + ctx.uses_zlib_brotli = true; + } + if debug_hir_uses_zlib_zstd(&hir_debug) { + ctx.uses_zlib_zstd = true; + } + } + // Detect whether this module needs the regex engine. The engine // (`regex`/`fancy-regex`, ~1.2 MB) is gated behind `perry-runtime/ // regex-engine` and the RegExp object's identity/display layer stays @@ -470,7 +509,8 @@ pub(super) fn detect_optional_feature_usage( #[cfg(test)] mod tests { use super::{ - debug_hir_uses_get_builtin_module, debug_hir_uses_regex, imports_fs_promises_glob, + debug_hir_uses_get_builtin_module, debug_hir_uses_regex, debug_hir_uses_zlib_brotli, + debug_hir_uses_zlib_zstd, imports_fs_promises_glob, }; use perry_hir::{Import, ImportSpecifier, Module, ModuleKind}; @@ -484,6 +524,35 @@ mod tests { )); } + #[test] + fn zlib_codec_gates_detect_static_and_dynamic_tokens() { + // Direct native-table lowering. + assert!(debug_hir_uses_zlib_brotli( + r#"NativeMethodCall { module: "zlib", method: "brotliCompressSync", args: [] }"# + )); + // Factory + constants spellings. + assert!(debug_hir_uses_zlib_brotli( + r#"NativeMethodCall { module: "zlib", method: "createBrotliDecompress" }"# + )); + assert!(debug_hir_uses_zlib_brotli( + r#"PropertyGet { property: "BROTLI_PARAM_QUALITY" }"# + )); + assert!(debug_hir_uses_zlib_zstd( + r#"NativeMethodCall { module: "zlib", method: "zstdCompressSync" }"# + )); + assert!(debug_hir_uses_zlib_zstd( + r#"NativeMethodCall { module: "zlib", method: "createZstdCompress" }"# + )); + assert!(debug_hir_uses_zlib_zstd( + r#"PropertyGet { property: "ZSTD_c_compressionLevel" }"# + )); + // A gzip-only program keeps both codec gates off — that's the size win. + let gzip_only = + r#"NativeMethodCall { module: "zlib", method: "gzipSync" } method: "gunzipSync""#; + assert!(!debug_hir_uses_zlib_brotli(gzip_only)); + assert!(!debug_hir_uses_zlib_zstd(gzip_only)); + } + #[test] fn get_builtin_module_gate_detects_direct_and_extracted_calls() { assert!(debug_hir_uses_get_builtin_module( diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 9f853a26f5..7a033f3f45 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -290,15 +290,28 @@ pub(crate) fn build_optimized_libs( if original_features.contains(&"bundled-net") { features.insert("external-net-pump"); } - // #1843 — when the flip strips `compression` and routes - // `node:zlib` to perry-ext-zlib, activate `external-zlib-pump` - // so perry-stdlib's main-thread pump + active-handles gate drain - // perry-ext-zlib's deferred stream-event queue and route - // `gz.write()`/`.on()`/`.pipe()` (lost-static-type) calls into its - // `js_ext_zlib_dispatch_method`. Without this the events stay - // queued forever (`createGzip().on('data')` never fires). - if original_features.contains(&"compression") { + // #1843 — when the flip strips the compression base feature and + // routes `node:zlib` to perry-ext-zlib, activate + // `external-zlib-pump` so perry-stdlib's main-thread pump + + // active-handles gate drain perry-ext-zlib's deferred + // stream-event queue and route `gz.write()`/`.on()`/`.pipe()` + // (lost-static-type) calls into its `js_ext_zlib_dispatch_method`. + // Without this the events stay queued forever + // (`createGzip().on('data')` never fires). `module_to_features` + // maps `zlib` to `compression-gzip` since the per-codec split; + // keep matching the legacy `compression` umbrella too so a + // future mapping change can't silently drop the pump. + if original_features.contains(&"compression-gzip") + || original_features.contains(&"compression") + { features.insert("external-zlib-pump"); + // The per-codec add-ons imply `compression-gzip` at the Cargo + // level, so leaving them enabled would compile the bundled + // zlib module back in and duplicate perry-ext-zlib's + // `js_zlib_*` symbols at link. The ext crate carries all + // codecs, so nothing is lost by dropping them here. + features.remove("compression-brotli"); + features.remove("compression-zstd"); } // Closes #606 — same shape for ws. When the well-known flip // strips `bundled-ws` and routes to perry-ext-ws, activate @@ -387,16 +400,49 @@ pub(crate) fn build_optimized_libs( if ctx.needs_ui { features.insert("async-runtime"); } + // zlib per-codec cherry-pick: `import 'node:zlib'` only selected the + // gzip/deflate base above (`compression-gzip`). Layer the Brotli / zstd + // backends on when HIR usage detection saw a matching API token, and + // fall back to the full set when a deferred dynamic-code site could + // name a codec from a runtime string (`zlib[name](...)` after `eval`). + // Gated on the base feature still being present: if the well-known flip + // routed `node:zlib` to perry-ext-zlib, the sub-features would imply + // `compression-gzip` back on and duplicate the ext crate's `js_zlib_*` + // symbols at link. + if features.contains("compression-gzip") { + if ctx.uses_zlib_brotli || perry_hir::has_deferred_dynamic_code_sites() { + features.insert("compression-brotli"); + } + if ctx.uses_zlib_zstd || perry_hir::has_deferred_dynamic_code_sites() { + features.insert("compression-zstd"); + } + } // perry-stdlib unconditionally re-bundles perry-updater (so user code // calling `perry/updater` resolves at link time without extra wiring). // perry-updater used to reference the extern `js_crypto_ed25519_verify` - // from perry-stdlib's `crypto` feature, which is why `crypto` is forced - // on here. The updater now verifies in-crate via ed25519-dalek (the - // extern is gone — it broke the Windows CLI link, LNK2019), so this - // force is no longer load-bearing for the updater; it is kept - // conservatively until the no-crypto auto-optimize path is audited - // separately for other stragglers. - features.insert("crypto"); + // from perry-stdlib's `crypto` feature, which is why `crypto` used to be + // force-inserted here for EVERY auto-optimized build. The updater now + // verifies in-crate via ed25519-dalek, and the remaining crypto entry + // points are covered by three detection layers, so the force is gone + // (stdlib cherry-pick — non-crypto programs save the whole crypto + // surface: RSA/EC/Ed25519/Ed448/ML-KEM/x509/JWT/bcrypt/argon2 + tokio): + // 1. `import 'node:crypto'` / bcrypt / jsonwebtoken / … → + // `module_to_features` (compute_required_features above); + // 2. bare `crypto.*` builtins and the WebCrypto namespace → + // `ctx.uses_crypto_builtins` (collect_modules/feature_detect.rs); + // 3. any OTHER codegen-emitted `js_crypto_*` / `js_webcrypto_*` call + // (compiled-package lowering with no import in the entry module) → + // the prefix rule in `perry_codegen::ext_registry::record_ffi_call` + // feeds `ctx.extra_stdlib_features`, unioned in above. + // + // `async-runtime` stays force-on: perry-stdlib's ALWAYS-ON modules + // (worker_threads' promise bridge, readline's pump) compile against + // `common::async_bridge`, which is `async-runtime`-gated — a bare + // `--no-default-features` stdlib has never compiled, and the crypto + // force used to satisfy the gate transitively. Tokio was therefore in + // every stdlib-linking binary before the cherry-pick too; the win here + // is dropping the crypto/codec crates, not the runtime bridge. + features.insert("async-runtime"); let feature_arg = features_to_cargo_arg(&features); // panic = "abort" is safe whenever no `catch_unwind` callers are diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 608e4d57de..dcfcee0fa1 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -82,15 +82,17 @@ fn build_optimized_libs_reuses_fresh_auto_archives_without_cargo() { // the archives we plant (instead of hardcoding a key string that drifts // whenever the cache-key inputs change). // Mirror build_optimized_libs's feature derivation for this import-free - // ctx: it always force-adds `crypto` (perry-stdlib's crypto module is - // unconditionally linked into the auto-optimize rebuild), and the - // import-/fetch-driven unions don't fire for a fresh ctx. + // ctx: since the stdlib cherry-pick, `crypto` is no longer force-added + // (it only joins via imports, `uses_crypto_builtins`, or the codegen + // `js_crypto_*` prefix net); only the `async-runtime` floor (required + // by the always-on worker_threads/readline async bridge) is forced, and + // the import-/fetch-driven unions don't fire for a fresh ctx. let mut features = compute_required_features( &ctx.native_module_imports, ctx.uses_fetch, ctx.uses_crypto_builtins, ); - features.insert("crypto"); + features.insert("async-runtime"); let feature_arg = features_to_cargo_arg(&features); let panic_abort_safe = !ctx.needs_ui && !ctx.needs_thread && !ctx.needs_plugins && !ctx.needs_geisterhand; diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index c9cf7b027d..8992ae4e3f 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -669,6 +669,16 @@ pub struct CompilationContext { /// `CryptoSha256`/`CryptoMd5` which dispatch to runtime symbols that /// live behind the perry-stdlib `crypto` feature. pub uses_crypto_builtins: bool, + /// Whether any TS module references a `node:zlib` Brotli API token + /// (`brotliCompressSync`, `createBrotliDecompress`, `BROTLI_*` + /// constants, …). Adds `compression-brotli` on top of the + /// `compression-gzip` base a `node:zlib` import selects, so gzip-only + /// programs never link the Brotli tables (stdlib cherry-pick). + pub uses_zlib_brotli: bool, + /// Same as `uses_zlib_brotli`, for the zstd codec family + /// (`zstdCompressSync`, `createZstdCompress`, `ZSTD_*`, …) → + /// `compression-zstd` (the bundled zstd C library). + pub uses_zlib_zstd: bool, /// Whether any TS module needs the regular-expression engine — a regex /// literal / `RegExp`, a regex-coercing string method (`.match` / /// `.matchAll` / `.search`), or a glob API (`path.matchesGlob` / @@ -1078,6 +1088,8 @@ impl CompilationContext { native_module_imports: BTreeSet::new(), uses_fetch: false, uses_crypto_builtins: false, + uses_zlib_brotli: false, + uses_zlib_zstd: false, uses_regex: false, uses_temporal: false, uses_event_emitter: false, diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index ed3c9de799..85c2e64b15 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -116,7 +116,18 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { "perry/updater" => &["crypto"], // ── Compression (zlib) ──────────────────────────────────────── - "zlib" => &["compression"], + // Per-codec split (stdlib cherry-pick): a `node:zlib` import only + // guarantees the gzip/deflate family (`compression-gzip`, which + // gates `pub mod zlib` itself). The Brotli / zstd codec backends + // (`compression-brotli` / `compression-zstd`, each implying + // `compression-gzip`) are added by `build_optimized_libs` when + // HIR usage detection saw a Brotli / zstd API token + // (`ctx.uses_zlib_brotli` / `ctx.uses_zlib_zstd`, set in + // `collect_modules/feature_detect.rs`) — and unconditionally + // when deferred dynamic code could name a codec at runtime. + // The `compression` umbrella (= all three) stays for + // backwards-compat `--features compression` callers. + "zlib" => &["compression-gzip"], // ── Email (lettre) ──────────────────────────────────────────── // `email` umbrella retained for backwards-compat; per-binding @@ -261,6 +272,44 @@ pub fn features_to_cargo_arg(features: &BTreeSet<&'static str>) -> String { mod tests { use super::*; + #[test] + fn zlib_import_maps_to_gzip_base_only() { + // Per-codec cherry-pick: `node:zlib` selects the gzip/deflate base; + // Brotli / zstd are layered on by `build_optimized_libs` from the + // `ctx.uses_zlib_brotli` / `ctx.uses_zlib_zstd` HIR gates. The + // legacy `compression` umbrella must NOT be selected here — it + // would defeat the split by always pulling all three codecs. + assert_eq!(module_to_features("zlib"), &["compression-gzip"]); + assert_eq!(module_to_features("node:zlib"), &["compression-gzip"]); + + let mut imports = BTreeSet::new(); + imports.insert("zlib".to_string()); + let features = compute_required_features(&imports, false, false); + assert!(features.contains("compression-gzip")); + assert!(!features.contains("compression")); + assert!(!features.contains("compression-brotli")); + assert!(!features.contains("compression-zstd")); + } + + #[test] + fn crypto_features_join_only_on_crypto_usage() { + // The stdlib cherry-pick removed the driver's unconditional + // `crypto` force — the feature must now come exclusively from the + // import mapping or the builtin-usage flag (plus the codegen + // `js_crypto_*` prefix net, tested in perry-codegen). + let no_imports = BTreeSet::new(); + let features = compute_required_features(&no_imports, false, false); + assert!(!features.contains("crypto")); + + let features = compute_required_features(&no_imports, false, true); + assert!(features.contains("crypto")); + + let mut imports = BTreeSet::new(); + imports.insert("crypto".to_string()); + let features = compute_required_features(&imports, false, false); + assert!(features.contains("crypto")); + } + #[test] fn stream_web_imports_enable_bundled_streams() { assert_eq!(module_to_features("stream/web"), &["bundled-streams"]);