From 79089bf5c042eeae8545bee13aef61543844680e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 21 Sep 2026 09:33:13 +0200 Subject: [PATCH] fix(modules): preserve named Node builtin re-exports --- changelog.d/10802-node-builtin-reexports.md | 6 +++ crates/perry-codegen/src/codegen/artifacts.rs | 50 +++++++++++++++++++ crates/perry-codegen/src/codegen/mod.rs | 18 ++++++- crates/perry-hir/src/lower/module_decl.rs | 46 +++++++++++++++++ .../tests/node_named_export_hygiene.rs | 32 ++++++++++++ .../src/commands/compile/run_pipeline.rs | 27 ++++++++++ .../tests/source_graph_export_regressions.rs | 2 + .../issue_10432.rs | 33 ++++++++++++ 8 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 changelog.d/10802-node-builtin-reexports.md create mode 100644 crates/perry/tests/source_graph_export_regressions/issue_10432.rs diff --git a/changelog.d/10802-node-builtin-reexports.md b/changelog.d/10802-node-builtin-reexports.md new file mode 100644 index 0000000000..82d4089591 --- /dev/null +++ b/changelog.d/10802-node-builtin-reexports.md @@ -0,0 +1,6 @@ +Named exports from Node builtins now survive a local facade module. Both +`export { createHash } from "node:crypto"` and the equivalent import-then-export +form publish a live getter for the builtin ESM export value, instead of linking +to a nonexistent local function or returning an `undefined` stub. This unblocks +ethers' crypto facade and other packages that wrap Node builtins (#10432, +#10802). diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index dc26a7fe2b..3e770dbc5c 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -761,6 +761,56 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { progress.checkpoint("class methods, constructors, and statics"); + // Node builtin named imports are runtime callable/property values rather + // than functions compiled into this module. When one is exported (either + // `import { x } from "node:m"; export { x }` or the synthetic Import + + // Named pair used for `export { x } from "node:m"`), publish a zero-arg + // getter that reads the live builtin ESM export cell. Importers classify + // this as a variable export and invoke the returned callable value, rather + // than linking a nonexistent `perry_fn___x` body. + for export in &hir.exports { + let perry_hir::Export::Named { local, exported } = export else { + continue; + }; + let native_origin = hir.imports.iter().find_map(|import| { + if !import.is_native || !perry_api_manifest::is_node_core_module(&import.source) { + return None; + } + import + .specifiers + .iter() + .find_map(|specifier| match specifier { + perry_hir::ImportSpecifier::Named { + imported, + local: import_local, + } if import_local == local => Some((import.source.as_str(), imported.as_str())), + _ => None, + }) + }); + let Some((source, imported)) = native_origin else { + continue; + }; + let getter_name = format!("perry_fn_{}__{}", module_prefix, sanitize(exported)); + if llmod.has_function(&getter_name) { + continue; + } + let source_idx = strings.intern(source); + let imported_idx = strings.intern(imported); + let source_handle = format!("@{}", strings.entry(source_idx).handle_global); + let imported_handle = format!("@{}", strings.entry(imported_idx).handle_global); + let getter = llmod.define_function(&getter_name, DOUBLE, vec![]); + let _ = getter.create_block("entry"); + let blk = getter.block_mut(0).unwrap(); + let source_value = blk.load(DOUBLE, &source_handle); + let imported_value = blk.load(DOUBLE, &imported_handle); + let value = blk.call( + DOUBLE, + "js_native_module_named_esm_export_value", + &[(DOUBLE, &source_value), (DOUBLE, &imported_value)], + ); + blk.ret(DOUBLE, &value); + } + // Emit FuncRef-as-value wrappers. For each user function, generate // a thin wrapper `__perry_wrap_` whose signature matches the // closure-call ABI: `double(i64 this_closure, double arg0, double diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 252f81b782..9cfbe86308 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -3612,7 +3612,23 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .exports .iter() .filter_map(|e| match e { - perry_hir::Export::Named { exported, .. } => Some(exported.clone()), + perry_hir::Export::Named { local, exported } + if !hir.imports.iter().any(|import| { + import.is_native + && perry_api_manifest::is_node_core_module(&import.source) + && import.specifiers.iter().any(|specifier| { + matches!( + specifier, + perry_hir::ImportSpecifier::Named { + local: import_local, + .. + } if import_local == local + ) + }) + }) => + { + Some(exported.clone()) + } _ => None, }) .collect(); diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index a7290e222a..3ec5552750 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1459,6 +1459,52 @@ pub(crate) fn lower_module_decl( } }) .unwrap_or_else(|| local.clone()); + + // A Node builtin has no compiled source module for the + // driver to follow through a normal ReExport edge. Model + // the forwarding binding as a synthetic named import so + // codegen can publish a live getter for the builtin ESM + // export cell. The synthetic local is compiler-private: + // `export { x } from "node:m"` does not introduce `x` + // into this module's lexical scope. + let native_source = canonicalize_native_import_source(&source); + if perry_api_manifest::is_node_core_module(&native_source) { + if !perry_api_manifest::module_has_public_named_export( + &native_source, + &local, + ) { + crate::lower_bail!( + named.span, + "The requested module '{}' does not provide an export named '{}'", + source, + local + ); + } + let synthetic_local = + format!("__perry_builtin_reexport_{}", ctx.fresh_local()); + init_named_cell(module, &native_source, &local, Some(&local)); + module.imports.push(Import { + source: native_source, + specifiers: vec![ImportSpecifier::Named { + imported: local, + local: synthetic_local.clone(), + }], + is_native: true, + module_kind: ModuleKind::NativeRust, + resolved_path: None, + type_only: false, + runtime_erased: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); + module.exports.push(Export::Named { + local: synthetic_local, + exported, + }); + continue; + } module.exports.push(Export::ReExport { source: source.clone(), imported: local, diff --git a/crates/perry-hir/tests/node_named_export_hygiene.rs b/crates/perry-hir/tests/node_named_export_hygiene.rs index 6866025c27..5f266837de 100644 --- a/crates/perry-hir/tests/node_named_export_hygiene.rs +++ b/crates/perry-hir/tests/node_named_export_hygiene.rs @@ -174,6 +174,38 @@ fn valid_node_named_imports_keep_compiling() { ); } +#[test] +fn node_named_reexports_lower_to_synthetic_native_imports() { + let module = lower_result(r#"export { createHash as hash } from "node:crypto";"#) + .expect("valid builtin re-export should lower"); + + assert!(module.imports.iter().any(|import| { + import.is_native + && import.source == "crypto" + && matches!( + import.specifiers.as_slice(), + [perry_hir::ImportSpecifier::Named { imported, local }] + if imported == "createHash" + && local.starts_with("__perry_builtin_reexport_") + ) + })); + assert!(module.exports.iter().any(|export| { + matches!( + export, + perry_hir::Export::Named { local, exported } + if local.starts_with("__perry_builtin_reexport_") && exported == "hash" + ) + })); + assert!(format!("{module:#?}").contains("js_native_module_named_esm_export_value")); +} + +#[test] +fn invalid_node_named_reexports_are_rejected() { + let error = lower_result(r#"export { definitelyMissing } from "node:crypto";"#) + .expect_err("invalid builtin re-export should fail during lowering"); + assert!(error.contains("does not provide an export named 'definitelyMissing'")); +} + #[test] fn worker_threads_parent_port_call_keeps_property_call_shape() { let module = lower_result( diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 557f9b1a4d..44a18a1628 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1783,6 +1783,33 @@ pub fn run_with_parse_cache( let key = (path_str.clone(), obj_name.clone()); exported_var_names.insert(key); } + + // Named imports from Node builtins are runtime values, including when + // this module only forwards them. They have no user `Let`, so they do + // not appear in `exported_objects`; classify their public names as + // getter-backed exports explicitly. Codegen emits the corresponding + // live builtin-cell getter from the HIR Import + Export pair. + for export in &hir_module.exports { + let perry_hir::Export::Named { local, exported } = export else { + continue; + }; + let is_named_builtin_import = hir_module.imports.iter().any(|import| { + import.is_native + && perry_api_manifest::is_node_core_module(&import.source) + && import.specifiers.iter().any(|specifier| { + matches!( + specifier, + perry_hir::ImportSpecifier::Named { + local: import_local, + .. + } if import_local == local + ) + }) + }); + if is_named_builtin_import { + exported_var_names.insert((path_str.clone(), exported.clone())); + } + } } // Build a map of all exports from all modules: module_path -> HashMap diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index 58df217fed..5128619aae 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -972,3 +972,5 @@ mod issue_10180; mod issue_10197; #[path = "source_graph_export_regressions/issue_10258.rs"] mod issue_10258; +#[path = "source_graph_export_regressions/issue_10432.rs"] +mod issue_10432; diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10432.rs b/crates/perry/tests/source_graph_export_regressions/issue_10432.rs new file mode 100644 index 0000000000..b6d27a0b98 --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10432.rs @@ -0,0 +1,33 @@ +use super::{compile_and_run, write}; + +#[test] +fn named_node_builtin_exports_survive_local_modules() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "direct.ts", + "export { createHash } from 'node:crypto';\n", + ); + write( + dir.path(), + "local.ts", + "import { join } from 'node:path';\n\ + export { join as pathJoin };\n", + ); + write( + dir.path(), + "main.ts", + "import { createHash } from './direct';\n\ + import { pathJoin } from './local';\n\ + console.log(typeof createHash, typeof pathJoin);\n\ + console.log(createHash('sha256').update('x').digest('hex'));\n\ + console.log(pathJoin('a', 'b'));\n", + ); + + assert_eq!( + compile_and_run(dir.path(), "main.ts"), + "function function\n\ + 2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\n\ + a/b\n" + ); +}