From 9f8875f83bb08b31dd44a77015d24f88da862793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 11:05:58 +0000 Subject: [PATCH 1/2] fix(runtime): make the stream module value the legacy Stream constructor Node's `require('stream')` and `import Stream from "node:stream"` are the legacy `Stream` constructor itself: the module exports hang off it as statics, and both `Stream` and `Stream.prototype` inherit from EventEmitter. Perry handed back a separate namespace object instead, so `x instanceof Stream` threw "Right-hand side of 'instanceof' is not callable" (node-fetch), `Stream !== NamedStream`, and nothing reached EventEmitter: `require('stream').EventEmitter` was undefined and `class X extends stream.EventEmitter` threw at definition (redis). - The CommonJS module value (`cjs_default_export_value("stream")`) and the default import binding's value both resolve to the named `Stream` export. - `Stream` carries every module export as an own static, its [[Prototype]] is EventEmitter and `Stream.prototype`'s is `EventEmitter.prototype`. - `new Stream()` builds an instance of `Stream.prototype`, and a dynamic `class X extends require('stream')` gets the EventEmitter parent edge and EventEmitter initialisation on `super()`. --- crates/perry-hir/src/lower/expr_new.rs | 31 ++ crates/perry-hir/src/lower/lower_expr.rs | 2 + .../perry-hir/src/lower/lower_expr/helpers.rs | 20 ++ .../lower_expr/stream_module_value_tests.rs | 92 ++++++ .../src/object/class_registry/construct.rs | 31 ++ .../object/class_registry/parent_static.rs | 7 +- .../perry-runtime/src/object/native_module.rs | 9 +- .../src/object/native_module_stream.rs | 201 +++++++++++-- ...est_gap_10430_stream_module_constructor.ts | 267 ++++++++++++++++++ 9 files changed, 638 insertions(+), 22 deletions(-) create mode 100644 crates/perry-hir/src/lower/lower_expr/stream_module_value_tests.rs create mode 100644 test-files/test_gap_10430_stream_module_constructor.ts diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 8eeac99c36..1e89e3d192 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -140,6 +140,37 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R args, }); } + // #10430: `new Stream()` for the legacy `node:stream` `Stream` + // constructor — the named export (any alias) or the default import, + // which IS that constructor. The by-name `Expr::New { "Stream" }` + // fallback built a prototype-less placeholder with no `on`/`emit`; + // construct the export value instead, so the runtime makes the + // instance inherit `Stream.prototype` (and through it EventEmitter). + // A namespace import keeps its builtin-module alias and is excluded. + let callee_name = callee_ident.sym.as_ref(); + let is_stream_constructor_value = ctx.lookup_local(callee_name).is_none() + && match ctx.lookup_native_module(callee_name) { + Some(("stream" | "node:stream", Some("Stream"))) => true, + Some(("stream" | "node:stream", None)) => { + ctx.lookup_builtin_module_alias(callee_name).is_none() + } + _ => false, + }; + let has_spread_arg = new_expr + .args + .as_deref() + .is_some_and(|args| args.iter().any(|arg| arg.spread.is_some())); + if is_stream_constructor_value && !has_spread_arg { + return Ok(Expr::NewDynamic { + callee: Box::new(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::NativeModuleRef("stream".to_string())), + property: "Stream".to_string(), + }), + args: lower_optional_args(ctx, new_expr.args.as_deref())?, + byte_offset: new_byte_offset, + }); + } // #4995: `new EE()` where `EE` is the events module *value* — the // default import (`import EE from 'events'`) or a CJS alias // (`var EE = require('events')`). Node's `events` module exports the diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 3409a534d4..a2ca2aa9ad 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -31,6 +31,8 @@ mod assignment; mod helpers; mod json_literal; mod reactive_text; +#[cfg(test)] +mod stream_module_value_tests; pub(crate) use arm_bin::lower_bin_expr; pub(crate) use arm_class::lower_class_expr; diff --git a/crates/perry-hir/src/lower/lower_expr/helpers.rs b/crates/perry-hir/src/lower/lower_expr/helpers.rs index 6c4babbd35..1b9b985d8b 100644 --- a/crates/perry-hir/src/lower/lower_expr/helpers.rs +++ b/crates/perry-hir/src/lower/lower_expr/helpers.rs @@ -426,6 +426,26 @@ pub(crate) fn native_module_binding_value(ctx: &LoweringContext, name: &str) -> property: "default".to_string(), }; } + // #10431: `import Stream from "node:stream"` binds `module.exports`, and + // for `stream` that is the legacy `Stream` constructor itself (which also + // carries every export as a static) — not a namespace object. Read the + // `default` export so the binding's VALUE is that constructor: + // `x instanceof Stream` needs a callable right-hand side and + // `Stream === NamedStream` needs the same object. Member reads/calls + // (`Stream.Readable`, `Stream.pipeline(…)`) are lowered from the binding, + // not from this value, and keep their static dispatch. A namespace import + // (`import * as ns`) registers a builtin-module alias and stays the + // namespace object; this is the same discriminator the `typeof` fold in + // `arm_unary` uses to report "function" for the default binding only. + if matches!(module_name, "stream" | "node:stream") + && ctx.lookup_builtin_module_alias(name).is_none() + { + return Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::NativeModuleRef("stream".to_string())), + property: "default".to_string(), + }; + } // Native module reference (e.g., mysql from 'mysql2/promise') Expr::NativeModuleRef(module_name.to_string()) } diff --git a/crates/perry-hir/src/lower/lower_expr/stream_module_value_tests.rs b/crates/perry-hir/src/lower/lower_expr/stream_module_value_tests.rs new file mode 100644 index 0000000000..8b772a5c27 --- /dev/null +++ b/crates/perry-hir/src/lower/lower_expr/stream_module_value_tests.rs @@ -0,0 +1,92 @@ +//! #10430 / #10431: the `node:stream` module value is the legacy `Stream` +//! constructor. The default binding's VALUE must read the `default` export +//! (the constructor) rather than evaluate to the namespace object, while a +//! namespace import stays the namespace; `new` of either constructor binding +//! must construct the export value instead of the by-name placeholder. + +use crate::ir::{Expr, Stmt}; + +fn lower(source: &str) -> crate::Module { + let ast = perry_parser::parse_typescript(source, "main.ts").unwrap(); + let hir = crate::lower::lower_module(&ast, "main", "main.ts").unwrap(); + crate::ir::clear_current_module_source(); + hir +} + +fn let_init<'a>(hir: &'a crate::Module, binding: &str) -> &'a Expr { + hir.init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { + name, + init: Some(init), + .. + } if name == binding => Some(init), + _ => None, + }) + .unwrap_or_else(|| panic!("no `let {binding}` with an initializer")) +} + +fn is_stream_export_read(expr: &Expr, export: &str) -> bool { + matches!( + expr, + Expr::PropertyGet { object, property, .. } + if property == export + && matches!(object.as_ref(), Expr::NativeModuleRef(module) if module == "stream") + ) +} + +#[test] +fn default_import_value_is_the_stream_constructor() { + let hir = lower( + r#" + import Stream from "node:stream"; + import Bare from "stream"; + import * as ns from "node:stream"; + const fromNodeSpecifier: any = Stream; + const fromBareSpecifier: any = Bare; + const namespace: any = ns; + const check = ({} as any) instanceof Stream; + "#, + ); + assert!(is_stream_export_read( + let_init(&hir, "fromNodeSpecifier"), + "default" + )); + assert!(is_stream_export_read( + let_init(&hir, "fromBareSpecifier"), + "default" + )); + assert!( + matches!(let_init(&hir, "namespace"), Expr::NativeModuleRef(module) if module == "stream"), + "a namespace import must stay the namespace object" + ); + let Expr::InstanceOf { + ty_expr: Some(rhs), .. + } = let_init(&hir, "check") + else { + panic!("expected a dynamic instanceof"); + }; + assert!( + is_stream_export_read(rhs, "default"), + "`x instanceof Stream` needs the callable constructor on the right-hand side" + ); +} + +#[test] +fn new_of_a_stream_constructor_binding_constructs_the_export_value() { + let hir = lower( + r#" + import Stream from "node:stream"; + import { Stream as Aliased } from "node:stream"; + const fromDefault = new Stream(); + const fromAlias = new Aliased(); + "#, + ); + for binding in ["fromDefault", "fromAlias"] { + let Expr::NewDynamic { callee, .. } = let_init(&hir, binding) else { + panic!("`{binding}` must construct through NewDynamic"); + }; + assert!(is_stream_export_read(callee, "Stream"), "{binding}"); + } +} diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index aefa9579b1..496f837518 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -231,6 +231,37 @@ pub(crate) unsafe fn nm_ctor_stream( _ => unreachable!(), }); } + // #10430: `new Stream()` (legacy `Stream`, i.e. `new (require('stream'))()`) + // is an ordinary instance of `Stream.prototype`, whose EventEmitter methods + // act on the receiver. Build it the way an ordinary function constructor's + // instance is built (the constructor's stable synthetic class id plus a + // class-default link to its `prototype`), not via `Object.create`, which + // mints a fresh synthetic class per call. Without this arm the instance + // had no `on`/`emit` and was not `instanceof Stream`. + if method == "Stream" { + let scope = crate::gc::RuntimeHandleScope::new(); + let ctor = scope.root_nanbox_f64(crate::object::bound_native_callable_export_value( + "stream", "Stream", + )); + let cid = synthetic_class_id_for_function(ctor.get_nanbox_f64()); + let instance = scope.root_raw_mut_ptr(js_object_alloc(cid, 0)); + let proto = crate::closure::closure_get_dynamic_prop( + (ctor.get_nanbox_u64() & crate::value::POINTER_MASK) as usize, + "prototype", + ); + if crate::value::JSValue::from_bits(proto.to_bits()).is_pointer() { + instance.with_mut_ptr::(|obj| { + super::super::prototype_chain::object_link_class_default_prototype( + obj as usize, + proto.to_bits(), + ) + }); + } + return Some( + instance + .with_mut_ptr::(|obj| crate::value::js_nanbox_pointer(obj as i64)), + ); + } None } diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index e193539c0f..b4813599ad 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -166,9 +166,12 @@ pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, mut parent_val register_class(class_id, parent); } } - if module == "events" { + if module == "events" || (module == "stream" && method == "Stream") { + // #10430: the legacy `Stream` constructor extends EventEmitter, so a + // `class X extends require('stream')` subclass inherits the same + // EventEmitter parent edge (`new X() instanceof EventEmitter`). let parent = match method.as_str() { - "EventEmitter" => 0xFFFF0076, + "EventEmitter" | "Stream" => 0xFFFF0076, "EventEmitterAsyncResource" => 0xFFFF0077, _ => 0, }; diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 1ae26fe3e7..3c2d38b9c9 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -487,8 +487,10 @@ unsafe fn nm_ee_dynamic_super( args_len: usize, ) -> Option { let (module, method) = bound_native_callable_module_and_method(func_value)?; - if module.trim_start_matches("node:") == "events" - && (method == "EventEmitter" || method == "EventEmitterAsyncResource") + let module = module.trim_start_matches("node:"); + // #10430: legacy `Stream` is `function Stream(opts) { EE.call(this, opts) }`. + if (module == "events" && (method == "EventEmitter" || method == "EventEmitterAsyncResource")) + || (module == "stream" && method == "Stream") { let this_val = super::js_implicit_this_get(); if crate::value::JSValue::from_bits(this_val.to_bits()).is_pointer() { @@ -691,6 +693,9 @@ pub(crate) fn cjs_default_export_value(module_name: &str) -> Option { match module_name { "assert" | "assert/strict" => Some(callable_exports::assert_cjs_export_value(module_name)), "events" => Some(bound_native_callable_export_value("events", "EventEmitter")), + // #10431: `stream`'s `module.exports` IS the legacy `Stream` constructor + // (exports hang off it: `attach_stream_legacy_prototype`). + "stream" => Some(bound_native_callable_export_value("stream", "Stream")), // #3687: `node:cluster` default import is a distinct EventEmitter-shaped // `cluster.default` namespace (its `on`/`emit`/… reads diverge from the // bare `import * as` namespace). diff --git a/crates/perry-runtime/src/object/native_module_stream.rs b/crates/perry-runtime/src/object/native_module_stream.rs index 98532f8b1e..fe5b2dfbed 100644 --- a/crates/perry-runtime/src/object/native_module_stream.rs +++ b/crates/perry-runtime/src/object/native_module_stream.rs @@ -28,41 +28,142 @@ pub(crate) fn scan_stream_event_emitter_prototype_roots_mut( }); } +/// The own properties Node hangs off `require('stream')` — which IS the legacy +/// `Stream` constructor (`lib/stream.js`: `module.exports = Stream`, then +/// `Stream.Readable = …` etc.) — in Node's own-key order. `Stream` is the +/// constructor itself and is filled in by the caller, not re-resolved. +const STREAM_MODULE_EXPORT_KEYS: &[&str] = &[ + "isDestroyed", + "isDisturbed", + "isErrored", + "isReadable", + "isWritable", + "Readable", + "Writable", + "Duplex", + "Transform", + "PassThrough", + "duplexPair", + "pipeline", + "addAbortSignal", + "finished", + "destroy", + "compose", + "setDefaultHighWaterMark", + "getDefaultHighWaterMark", + "promises", + "Stream", + "_isArrayBufferView", + "_isUint8Array", + "_uint8ArrayToBuffer", +]; + +fn closure_addr_of(value: f64) -> usize { + (value.to_bits() & crate::value::POINTER_MASK) as usize +} + +/// The NaN-boxed value of a rooted object pointer, read at the call site. +fn proto_value(proto: &crate::gc::RuntimeHandle<'_>) -> f64 { + proto.with_mut_ptr::(|p| crate::value::js_nanbox_pointer(p as i64)) +} + pub(crate) fn attach_stream_legacy_prototype(constructor_value: f64) { - let proto = js_object_alloc_with_shape( + // Every step below allocates (the prototype object, its EventEmitter + // method closures, each export value, the EventEmitter constructor), so + // both the constructor and the prototype are re-read from their handles at + // each use rather than held as raw addresses across a collection point. + let scope = crate::gc::RuntimeHandleScope::new(); + let constructor = scope.root_nanbox_f64(constructor_value); + let proto = scope.root_raw_mut_ptr(js_object_alloc_with_shape( 0x7FFF_FF33, 1, b"constructor\0".as_ptr(), b"constructor\0".len() as u32, - ); - js_object_set_field(proto, 0, JSValue::from_bits(constructor_value.to_bits())); + )); + proto.with_mut_ptr::(|p| { + js_object_set_field(p, 0, JSValue::from_bits(constructor.get_nanbox_u64())) + }); // readable-stream's `Readable.prototype.on` borrows `Stream.prototype.on` // via `.call(this)`; expose the EventEmitter methods on the legacy // `Stream.prototype` as receiver-from-`this` values so the borrow works. - crate::node_stream::install_event_emitter_prototype_methods(proto); - let proto_value = crate::value::js_nanbox_pointer(proto as i64); + // (The installer roots `proto` itself.) + proto.with_mut_ptr::(|p| { + crate::node_stream::install_event_emitter_prototype_methods(p) + }); + let proto_bits = proto_value(&proto).to_bits(); STREAM_EVENT_EMITTER_PROTOTYPES.with(|protos| { let mut protos = protos.borrow_mut(); - if !protos.contains(&proto_value.to_bits()) { - protos.push(proto_value.to_bits()); + if !protos.contains(&proto_bits) { + protos.push(proto_bits); } }); crate::closure::closure_set_dynamic_prop( - (constructor_value.to_bits() & crate::value::POINTER_MASK) as usize, + closure_addr_of(constructor.get_nanbox_f64()), "prototype", - proto_value, + proto_value(&proto), ); - let closure = (constructor_value.to_bits() & crate::value::POINTER_MASK) as usize; - for name in [ - "_isArrayBufferView", - "_isUint8Array", - "_uint8ArrayToBuffer", - "isDestroyed", - ] { + + // #10431: the module value (`require('stream')`, `import Stream from + // "node:stream"`) is this constructor, so it must also carry every module + // export — `Stream.Readable`, `Stream.pipeline`, `Stream.promises`, and + // `Stream.Stream === Stream`. Resolve each through the namespace resolver + // `import * as ns` reads use, so `Stream.pipeline === ns.pipeline`. + for &name in STREAM_MODULE_EXPORT_KEYS { + let value = match name { + "Stream" => constructor.get_nanbox_f64(), + // The `stream_promises` submodule namespace — what + // `require('stream').promises` resolved to while the module value + // was a namespace object — whose `pipeline`/`finished` are the + // promise-returning implementations. + "promises" => unsafe { + let submodule = b"stream_promises"; + crate::node_submodules::js_node_submodule_namespace( + submodule.as_ptr(), + submodule.len() as u32, + ) + }, + _ => unsafe { + let module = b"stream"; + super::js_native_module_property_by_name( + module.as_ptr(), + module.len(), + name.as_ptr(), + name.len(), + ) + }, + }; + if JSValue::from_bits(value.to_bits()).is_undefined() { + continue; + } crate::closure::closure_set_dynamic_prop( - closure, + closure_addr_of(constructor.get_nanbox_f64()), name, - bound_native_callable_export_value("stream", name), + value, + ); + } + + // #10430: Node's `lib/internal/streams/legacy.js` does + // `ObjectSetPrototypeOf(Stream.prototype, EE.prototype)` and + // `ObjectSetPrototypeOf(Stream, EE)`. The constructor edge is what makes + // the inherited statics resolve (`require('stream').EventEmitter === + // require('events')`, `.defaultMaxListeners`, `.once`, …); the prototype + // edge makes `Object.create(Stream.prototype) instanceof EventEmitter`. + // Arm the events attach first: minting `EventEmitter` before it would + // cache a constructor without its statics for the whole process. + super::native_module_registry::js_nm_install_events(); + let event_emitter = + scope.root_nanbox_f64(bound_native_callable_export_value("events", "EventEmitter")); + crate::object::js_object_set_prototype_of( + constructor.get_nanbox_f64(), + event_emitter.get_nanbox_f64(), + ); + let event_emitter_proto = scope.root_nanbox_f64( + crate::object::js_function_prototype_value_for_read(event_emitter.get_nanbox_f64()), + ); + if JSValue::from_bits(event_emitter_proto.get_nanbox_u64()).is_pointer() { + crate::object::js_object_set_prototype_of( + proto_value(&proto), + event_emitter_proto.get_nanbox_f64(), ); } } @@ -288,6 +389,70 @@ mod tests { ); } + /// #10430 / #10431: `require('stream')` IS the legacy `Stream` + /// constructor. It carries the module exports as statics, extends + /// EventEmitter on both the constructor and the prototype edge, and + /// `new Stream()` inherits `Stream.prototype`. + #[test] + fn stream_module_value_is_the_legacy_constructor_extending_event_emitter() { + let _global = crate::gc::global_side_table_test_lock(); + // Mint a fresh constructor so its decoration runs inside this test + // instead of being served from an earlier test's cache entry. + NATIVE_CALLABLE_EXPORTS.with(|c| c.borrow_mut().remove("stream\0Stream")); + let stream_ctor = bound_native_callable_export_value("stream", "Stream"); + let ctor_ptr = closure_addr(stream_ctor); + assert_ne!(ctor_ptr, 0); + + assert_eq!( + cjs_default_export_value("stream").map(f64::to_bits), + Some(stream_ctor.to_bits()), + "the CommonJS module value must be the Stream constructor itself" + ); + assert_eq!( + crate::closure::closure_get_dynamic_prop(ctor_ptr, "Stream").to_bits(), + stream_ctor.to_bits(), + "Stream.Stream === Stream" + ); + for name in [ + "Readable", + "PassThrough", + "pipeline", + "finished", + "promises", + ] { + let value = crate::closure::closure_get_dynamic_prop(ctor_ptr, name); + assert!( + JSValue::from_bits(value.to_bits()).is_pointer(), + "Stream.{name} must be an own static of the module value" + ); + } + assert_eq!( + crate::closure::closure_get_dynamic_prop(ctor_ptr, "Readable").to_bits(), + bound_native_callable_export_value("stream", "Readable").to_bits() + ); + + let event_emitter = bound_native_callable_export_value("events", "EventEmitter"); + assert_eq!( + js_object_get_prototype_of(stream_ctor).to_bits(), + event_emitter.to_bits(), + "Object.getPrototypeOf(Stream) must be EventEmitter" + ); + let stream_proto = crate::closure::closure_get_dynamic_prop(ctor_ptr, "prototype"); + assert_eq!( + js_object_get_prototype_of(stream_proto).to_bits(), + js_function_prototype_value_for_read(event_emitter).to_bits(), + "Object.getPrototypeOf(Stream.prototype) must be EventEmitter.prototype" + ); + + super::super::native_module_registry::js_nm_install_stream(); + let instance = unsafe { js_new_function_construct(stream_ctor, std::ptr::null(), 0) }; + assert_eq!( + js_object_get_prototype_of(instance).to_bits(), + stream_proto.to_bits(), + "new Stream() must inherit Stream.prototype" + ); + } + #[test] fn stream_constructors_expose_static_method_values() { // CLOSURE_PROPS is PROCESS-global and the gc test guards' state reset diff --git a/test-files/test_gap_10430_stream_module_constructor.ts b/test-files/test_gap_10430_stream_module_constructor.ts new file mode 100644 index 0000000000..d36238d527 --- /dev/null +++ b/test-files/test_gap_10430_stream_module_constructor.ts @@ -0,0 +1,267 @@ +// #10430 / #10431: Node's `stream` module value — `require('stream')` and the +// default import `import Stream from "node:stream"` — IS the legacy `Stream` +// constructor, and that constructor extends EventEmitter +// (`lib/internal/streams/legacy.js`: `ObjectSetPrototypeOf(Stream.prototype, +// EE.prototype)` + `ObjectSetPrototypeOf(Stream, EE)`). The module's exports +// (`Readable`, `pipeline`, `promises`, …) hang off it as statics. +// +// Perry used to hand back a separate namespace object: `typeof` said +// "function", but `x instanceof Stream` threw "Right-hand side of 'instanceof' +// is not callable" (node-fetch's `body instanceof Stream`), `Stream !== +// NamedStream`, and nothing inherited from EventEmitter — +// `require('stream').EventEmitter` was undefined, so redis's +// `class ClientSideCacheProvider extends stream_1.EventEmitter` threw +// "Class extends value is not a constructor" at module init. +import { createRequire } from "node:module"; +import StreamDefault, { + Duplex, + PassThrough, + Readable, + Stream, + Stream as AliasedStream, + Transform, + Writable, + pipeline, +} from "node:stream"; +import BareStreamDefault from "stream"; +import * as streamNs from "node:stream"; +import EventsDefault, { EventEmitter } from "node:events"; + +const req = createRequire(import.meta.url); +const cjsStream: any = req("stream"); +const cjsEvents: any = req("events"); +const S: any = StreamDefault; + +const t = (name: string, f: () => unknown) => { + try { + console.log(name, f()); + } catch (e) { + console.log(name, "THREW", (e as Error).message); + } +}; + +// ── 1. the module value is the named `Stream` constructor (#10431) ── +t("typeof default import:", () => typeof StreamDefault); +t("typeof require('stream'):", () => typeof cjsStream); +t("typeof namespace:", () => typeof streamNs); +t("default === Stream:", () => StreamDefault === Stream); +t("bare default === Stream:", () => BareStreamDefault === Stream); +t("aliased === Stream:", () => AliasedStream === Stream); +t("require('stream') === Stream:", () => cjsStream === Stream); +t("require('stream') === default:", () => cjsStream === StreamDefault); +t("ns.default === Stream:", () => (streamNs as any).default === Stream); +t("ns.Stream === Stream:", () => streamNs.Stream === Stream); +t("require('stream').Stream === require('stream'):", () => cjsStream.Stream === cjsStream); +t("default.Stream === default:", () => S.Stream === S); +t("typeof Stream.prototype:", () => typeof S.prototype); +t("Stream.prototype.constructor === Stream:", () => S.prototype.constructor === Stream); + +// ── 2. instanceof with the module value on the right-hand side ── +const readable = new Readable({ read() {} }); +const writable = new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, +}); +const duplex = new Duplex({ + read() {}, + write(_chunk, _enc, cb) { + cb(); + }, +}); +const transform = new Transform({ + transform(chunk, _enc, cb) { + cb(null, chunk); + }, +}); +const passThrough = new PassThrough(); +const instances = [ + ["Readable", readable], + ["Writable", writable], + ["Duplex", duplex], + ["Transform", transform], + ["PassThrough", passThrough], +] as const; +for (const [name, value] of instances) { + t(`${name} instanceof default:`, () => value instanceof StreamDefault); + t(`${name} instanceof require('stream'):`, () => value instanceof cjsStream); + t(`${name} instanceof Stream:`, () => value instanceof Stream); + t(`${name} instanceof EventEmitter:`, () => value instanceof EventEmitter); +} +t("null instanceof default:", () => (null as any) instanceof StreamDefault); +t("{} instanceof require('stream'):", () => ({}) instanceof cjsStream); +t("EventEmitter instance instanceof Stream:", () => new EventEmitter() instanceof StreamDefault); +t("inline instanceof require:", () => passThrough instanceof req("stream")); + +// ── 3. `new Stream()` is an EventEmitter-backed legacy stream ── +function listen(name: string, emitter: any) { + let got = 0; + try { + emitter.on("tick", (n: number) => { + got += n; + }); + emitter.emit("tick", 2); + emitter.emit("tick", 3); + } catch (e) { + console.log(name, "THREW", (e as Error).message); + return; + } + console.log( + name, + got, + emitter instanceof Stream, + emitter instanceof cjsStream, + emitter instanceof EventEmitter, + ); +} +listen("new Stream():", new Stream()); +listen("new default():", new StreamDefault()); +listen("new aliased():", new AliasedStream()); +listen("new require('stream')():", new cjsStream()); +listen("new (any local)():", new S()); +listen("Object.create(Stream.prototype):", Object.create(S.prototype)); + +// ── 4. EventEmitter inheritance (#10430) ── +t("getPrototypeOf(require('stream')) === require('events'):", () => + Object.getPrototypeOf(cjsStream) === cjsEvents, +); +t("getPrototypeOf(default) === EventEmitter:", () => Object.getPrototypeOf(StreamDefault) === EventEmitter); +t("require('stream').EventEmitter === require('events'):", () => cjsStream.EventEmitter === cjsEvents); +t("default.EventEmitter === EventEmitter:", () => S.EventEmitter === EventEmitter); +t("typeof default.EventEmitter:", () => typeof (StreamDefault as any).EventEmitter); +t("hasOwn(require('stream'), 'EventEmitter'):", () => Object.hasOwn(cjsStream, "EventEmitter")); +t("typeof require('stream').defaultMaxListeners:", () => typeof cjsStream.defaultMaxListeners); +t("require('stream').once === require('events').once:", () => cjsStream.once === cjsEvents.once); +t("getPrototypeOf(Stream.prototype) === EventEmitter.prototype:", () => + Object.getPrototypeOf(S.prototype) === EventEmitter.prototype, +); +t("Stream.prototype instanceof EventEmitter:", () => S.prototype instanceof EventEmitter); + +// ── 5. subclassing the module value ── +for (const [name, make] of [ + [ + "class extends require('stream'):", + () => { + class Legacy extends req("stream") {} + return new Legacy(); + }, + ], + [ + "class extends default import:", + () => { + class Legacy extends StreamDefault {} + return new Legacy(); + }, + ], + [ + "class extends named Stream:", + () => { + class Legacy extends Stream {} + return new Legacy(); + }, + ], + [ + "class extends (any local):", + () => { + class Legacy extends S {} + return new Legacy(); + }, + ], + [ + "class extends require('stream').EventEmitter:", + () => { + // redis `@redis/client/dist/lib/client/cache.js` shape. + const stream_1 = req("stream"); + class ClientSideCacheProvider extends stream_1.EventEmitter {} + return new ClientSideCacheProvider(); + }, + ], +] as const) { + try { + const emitter: any = make(); + let got = 0; + emitter.on("tick", (n: number) => { + got += n; + }); + emitter.emit("tick", 4); + console.log(name, got, typeof emitter.once, emitter instanceof EventEmitter); + } catch (e) { + console.log(name, "THREW", (e as Error).message); + } +} +{ + class Legacy extends req("stream") {} + const legacy = new Legacy(); + t("subclass instanceof require('stream'):", () => legacy instanceof cjsStream); + t("subclass instanceof default:", () => legacy instanceof StreamDefault); +} + +// ── 6. module exports reached through the constructor ── +for (const key of [ + "Readable", + "Writable", + "Duplex", + "Transform", + "PassThrough", + "pipeline", + "finished", + "compose", + "addAbortSignal", + "isReadable", + "getDefaultHighWaterMark", + "_isUint8Array", +] as const) { + t(`require('stream').${key} === ns.${key}:`, () => cjsStream[key] === (streamNs as any)[key]); + t(`default.${key} === ns.${key}:`, () => S[key] === (streamNs as any)[key]); +} +t("default.Readable === Readable (static member):", () => StreamDefault.Readable === Readable); +t("default.pipeline === pipeline (static member):", () => StreamDefault.pipeline === pipeline); +t("typeof require('stream').promises:", () => typeof cjsStream.promises); +t("typeof require('stream').promises.pipeline:", () => typeof cjsStream.promises.pipeline); +t("typeof default.promises.finished:", () => typeof S.promises.finished); +t("keys include module exports:", () => + ["Readable", "pipeline", "promises", "Stream", "_isArrayBufferView"].every((k) => + Object.keys(cjsStream).includes(k), + ), +); + +// ── 7. the module value drives real streams ── +const collected: string[] = []; +await new Promise((resolve) => { + cjsStream.pipeline( + cjsStream.Readable.from(["a", "b", "c"]), + new cjsStream.Transform({ + transform(chunk: any, _enc: string, cb: (err: Error | null, data?: string) => void) { + cb(null, String(chunk).toUpperCase()); + }, + }), + new S.Writable({ + write(chunk: any, _enc: string, cb: () => void) { + collected.push(String(chunk)); + cb(); + }, + }), + (err: Error | null | undefined) => { + console.log("callback pipeline:", err ?? null, collected.join("")); + resolve(); + }, + ); +}); +const promised: string[] = []; +await cjsStream.promises.pipeline( + S.Readable.from(["x", "y"]), + new cjsStream.Writable({ + write(chunk: any, _enc: string, cb: () => void) { + promised.push(String(chunk)); + cb(); + }, + }), +); +console.log("promises pipeline:", promised.join("")); +console.log("default.Readable.from:", (await StreamDefault.Readable.from([1, 2, 3]).toArray()).join(",")); + +// ── 8. controls: `events` already behaved ── +t("events default === EventEmitter:", () => EventsDefault === EventEmitter); +t("require('events') === EventEmitter:", () => cjsEvents === EventEmitter); +t("require('events').EventEmitter === require('events'):", () => cjsEvents.EventEmitter === cjsEvents); +t("typeof require('events').defaultMaxListeners:", () => typeof cjsEvents.defaultMaxListeners); From e7985fb52201f3c20262dc696bf56bab66d2ee01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 14:42:03 +0000 Subject: [PATCH 2/2] docs(changelog): add fragment for #10551 --- .../10551-stream-module-constructor.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 changelog.d/10551-stream-module-constructor.md diff --git a/changelog.d/10551-stream-module-constructor.md b/changelog.d/10551-stream-module-constructor.md new file mode 100644 index 0000000000..a492e4a766 --- /dev/null +++ b/changelog.d/10551-stream-module-constructor.md @@ -0,0 +1,37 @@ +`require('stream')` and `import Stream from "node:stream"` are now the legacy +`Stream` constructor, as in Node, instead of a separate namespace object +(#10430, #10431). Before, `x instanceof Stream` threw "Right-hand side of +'instanceof' is not callable" for both forms (node-fetch's `body instanceof +Stream`), `Stream !== NamedStream`, and nothing inherited from EventEmitter: +`require('stream').EventEmitter` was undefined, so redis's +`class ClientSideCacheProvider extends stream_1.EventEmitter` threw "Class +extends value is not a constructor" at module init. `new Stream()` also built an +empty placeholder with no `on`/`emit`, and `class X extends require('stream')` +instances had no EventEmitter methods. + +Root cause: `cjs_default_export_value` had no `stream` arm, so the CommonJS +module value fell back to the namespace object; the HIR lowered the default +import's value to the bare `NativeModuleRef("stream")` (only its `typeof` was +folded to "function"); and `attach_stream_legacy_prototype` never linked +`Stream`/`Stream.prototype` to EventEmitter or hung the exports on the +constructor. + +Fix: the CommonJS value and the default binding's value both resolve to the +named `Stream` export. The constructor carries every module export as an own +static (Node's own-key order, `Stream.Stream === Stream`), and gets Node's two +`ObjectSetPrototypeOf` edges (`Stream` → `EventEmitter`, `Stream.prototype` → +`EventEmitter.prototype`). `new Stream()` builds an instance of +`Stream.prototype`, and a dynamic `extends` of `Stream` gets the EventEmitter +parent edge and EventEmitter init on `super()`. The attach now roots the +constructor and prototype across its allocations. A namespace import stays the +namespace object, and member reads and calls on the default binding keep their +static lowering. + +Validation: `test_gap_10430_stream_module_constructor` differs from Node on +7661bc05fe and matches it here, in both no-auto and auto-optimize modes. HIR and +runtime unit tests were added. The full gap suite matches the snapshot, with the +same 6 known mismatches as the baseline. The stream + events node-suite is +868/872 on both baseline and fix, with zero per-test deltas. Stream data paths +are flat in `instructions:u` (+0.05% and +0.22%). `instanceof Stream` alone is ++0.56–0.71% (median of 7), within the ±1% band. Startup cost is +1.1 M +instructions, paid once when the constructor is first minted.