From 3b45a5bdcc2fe812e9396d08f783b2976b4f0997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 11:41:31 +0200 Subject: [PATCH 1/2] fix(runtime): match Node fs readFile prototype --- changelog.d/release-r23-node-function-prototype.md | 3 +++ .../object/native_module/constructor_exports.rs | 8 ++------ .../issue_5268_native_ctor_prototype_undefined.rs | 14 +++++++------- ...t_issue_5268_native_ctor_prototype_undefined.ts | 10 +++++++--- 4 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 changelog.d/release-r23-node-function-prototype.md diff --git a/changelog.d/release-r23-node-function-prototype.md b/changelog.d/release-r23-node-function-prototype.md new file mode 100644 index 0000000000..7ef8ca2fe4 --- /dev/null +++ b/changelog.d/release-r23-node-function-prototype.md @@ -0,0 +1,3 @@ +### Fixed + +- Match Node's prototype surface for callback-style native module exports such as `fs.readFile`. diff --git a/crates/perry-runtime/src/object/native_module/constructor_exports.rs b/crates/perry-runtime/src/object/native_module/constructor_exports.rs index 14fdd8066f..402cd26e41 100644 --- a/crates/perry-runtime/src/object/native_module/constructor_exports.rs +++ b/crates/perry-runtime/src/object/native_module/constructor_exports.rs @@ -64,10 +64,6 @@ pub(crate) fn is_native_module_constructor_export(module: &str, property: &str) "crypto.KeyObject" => property == "from", "dns" | "dns/promises" => property == "getServers", "events" => property == "once", - // Node's callback-style fs helpers are callable but not constructors. - // Keep this exact: class exports such as ReadStream/WriteStream must - // still materialize a real prototype for userland subclassing. - "fs" => property == "readFile", "http" => property == "setMaxIdleHTTPParsers", "inspector" => matches!(property, "close" | "url"), "inspector.DOMStorage" => matches!( @@ -199,8 +195,8 @@ mod tests { use super::is_native_module_constructor_export; #[test] - fn fs_read_file_is_not_a_constructor_but_stream_classes_are() { - assert!(!is_native_module_constructor_export("fs", "readFile")); + fn fs_read_file_and_stream_classes_expose_prototypes() { + assert!(is_native_module_constructor_export("fs", "readFile")); assert!(is_native_module_constructor_export("fs", "ReadStream")); assert!(is_native_module_constructor_export( "node:fs", diff --git a/crates/perry/tests/issue_5268_native_ctor_prototype_undefined.rs b/crates/perry/tests/issue_5268_native_ctor_prototype_undefined.rs index 6a0d58a8ba..70f52d796c 100644 --- a/crates/perry/tests/issue_5268_native_ctor_prototype_undefined.rs +++ b/crates/perry/tests/issue_5268_native_ctor_prototype_undefined.rs @@ -24,9 +24,9 @@ //! `Object.create(undefined)` / `Object.setPrototypeOf(x, undefined)` then hit //! the spec TypeError. Fix: recognize constructor-cased bound-native exports //! (leading uppercase, not flagged non-constructable) and let the -//! synthetic-class path materialize a stable `.prototype` object — while -//! keeping non-constructor exports (`fs.readFile`, …) at `prototype === -//! undefined`, matching Node's built-in non-constructor functions. +//! synthetic-class path materialize a stable `.prototype` object. Node also +//! exposes a real prototype on callback-style JavaScript wrapper exports such +//! as `fs.readFile`, despite the lower-case export name. use std::path::PathBuf; use std::process::Command; @@ -97,13 +97,13 @@ const prototype: any = { child() { return null; } }; Object.setPrototypeOf(prototype, (EventEmitter as any).prototype); console.log("setproto:", true); -// Non-constructor native exports keep prototype === undefined (no spurious -// synthesis), matching Node's built-in non-constructor functions. -console.log("nonctor:", (fs as any).readFile.prototype === undefined); +// Node's callback-style JavaScript wrapper exports also expose a prototype. +const readFileProto = (fs as any).readFile.prototype; +console.log("readFile:", typeof readFileProto === "object" && readFileProto !== null); "#, ); assert_eq!( stdout, - "rs: true\nws: true\nee: true\ncreate: true\nchain: true\nsetproto: true\nnonctor: true\n" + "rs: true\nws: true\nee: true\ncreate: true\nchain: true\nsetproto: true\nreadFile: true\n" ); } diff --git a/test-files/test_issue_5268_native_ctor_prototype_undefined.ts b/test-files/test_issue_5268_native_ctor_prototype_undefined.ts index 0b7e1d89f2..58a8440850 100644 --- a/test-files/test_issue_5268_native_ctor_prototype_undefined.ts +++ b/test-files/test_issue_5268_native_ctor_prototype_undefined.ts @@ -42,6 +42,10 @@ const prototype: any = { child() { return null; } }; Object.setPrototypeOf(prototype, (EventEmitter as any).prototype); console.log("setPrototypeOf(obj, EventEmitter.prototype) ok:", true); -// 4. Non-constructor native exports keep `prototype === undefined`, matching -// Node's built-in non-constructor functions (no spurious synthesis). -console.log("fs.readFile.prototype is undefined:", (fs as any).readFile.prototype === undefined); +// 4. Node also exposes a real prototype on callback-style JavaScript wrapper +// exports such as fs.readFile, despite the lower-case export name. +const readFileProto = (fs as any).readFile.prototype; +console.log( + "fs.readFile.prototype is object:", + typeof readFileProto === "object" && readFileProto !== null, +); From 12724f33e2d3e7ea720428f8ec85d7637e73859f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 11:48:53 +0200 Subject: [PATCH 2/2] chore: name r23 changelog for PR --- ...n-prototype.md => 8877-release-r23-node-function-prototype.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{release-r23-node-function-prototype.md => 8877-release-r23-node-function-prototype.md} (100%) diff --git a/changelog.d/release-r23-node-function-prototype.md b/changelog.d/8877-release-r23-node-function-prototype.md similarity index 100% rename from changelog.d/release-r23-node-function-prototype.md rename to changelog.d/8877-release-r23-node-function-prototype.md