From df41ed4b111cbbcf071c7c809bcfc7132a75de8c Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 17 Aug 2026 20:54:02 -0400 Subject: [PATCH] fix(cjs-wrap): resolve Node.js built-in requires via createRequire at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CJS-to-ESM wrap hoists require("process") and other Node.js built-in requires as static ESM imports. The codegen does not initialize native-module import bindings inside CJS-wrapped modules, so the hoisted binding is undefined at runtime — causing ReferenceError when the module tries to use it. Three changes in wrap.rs: 1. Don't adopt aliases for built-in specs. Keeping the alias un-adopted means the declaration (e.g. let node_process = require("process")) stays in the IIFE body and goes through the synthetic require function. 2. Don't blank built-in alias declarations in the hoisted-classes path. Same rationale: the declaration must survive so the synthetic require handles it. 3. Use createRequire for built-in modules in the synthetic require function. Both the per-spec cases and a runtime fallback check __perry_cjs_require_is_builtin and resolve via __perry_cjs_create_require(path)(specifier), which calls js_create_native_module_namespace under the hood. Also fixes circular-dependency detection to use globalThis.process?.emitWarning?.() instead of process.emitWarning(), which crashes when process is not a global. Verified: a standalone CJS file with require("process"), require("os"), and require("path") now compiles and runs correctly, printing platform/os/path values. --- .../src/commands/compile/cjs_wrap/wrap.rs | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index b54187335e..7cfdaacce5 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -152,7 +152,20 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( if !dead_platform_requires.is_empty() { require_specs.retain(|spec| !dead_platform_requires.contains(spec)); } - + // #sdxgen: Identify Node.js built-in requires (`require("process")`, + // `require("os")`, etc.) so the synthetic `require` function can resolve + // them via `createRequire` at runtime instead of relying on the hoisted + // static import binding (which the codegen does not initialize for + // native modules inside CJS-wrapped modules). + let builtin_requires: Vec = require_specs + .iter() + .filter(|spec| { + let normalized = spec.strip_prefix("node:").unwrap_or(spec); + let base = normalized.split('/').next().unwrap_or(normalized); + perry_hir::is_node_builtin_module(base) + }) + .cloned() + .collect(); // Issue #652: hoist top-level `class X { ... }` declarations OUT of the // IIFE so the consumer's `import { X } from "pkg"` resolves to the real // class instead of a runtime property access on `_cjs.X`. @@ -284,6 +297,17 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // Don't adopt a function-local alias — keep it lazy (see above). continue; } + // #sdxgen: Don't adopt aliases for Node.js built-in modules. The + // codegen doesn't initialize native-module import bindings inside + // CJS-wrapped modules, so an adopted alias would be undefined at + // runtime. Keeping the alias un-adopted means the declaration stays + // in the IIFE body and `require("process")` goes through the + // synthetic require, which resolves builtins via createRequire. + let normalized = spec.strip_prefix("node:").unwrap_or(spec); + let base = normalized.split('/').next().unwrap_or(normalized); + if perry_hir::is_node_builtin_module(base) { + continue; + } if import_local_names.iter().any(|n| n == alias) { continue; } @@ -391,7 +415,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( .into_iter() .map(|property| { format!( - "if (childBefore && childBefore.loaded === false) process.emitWarning(\"Accessing non-existent property '{property}' of module exports inside circular dependency\"); " + "if (childBefore && childBefore.loaded === false) globalThis.process?.emitWarning?.(\"Accessing non-existent property '{property}' of module exports inside circular dependency\"); " ) }) .collect::() @@ -406,7 +430,14 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( } else { None }; - let required_value = if needs_runtime_record { + let required_value = if builtin_requires.contains(spec) { + // #sdxgen: For Node.js built-in modules, resolve via createRequire + // at runtime instead of the hoisted import binding (which the + // codegen does not initialize for native modules in CJS-wrapped + // modules). createRequire calls js_create_native_module_namespace + // under the hood — the same path Node.js uses for require("process"). + format!("{link_child}return __perry_cjs_create_require({:?})(specifier);", source_path.to_string_lossy()) + } else if needs_runtime_record { runtime_require.clone().unwrap_or_else(|| format!("return {local};")) } else { format!("{link_child}return {local};") @@ -722,6 +753,14 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( .into_iter() .filter(|(_, spec, _)| require_specs.iter().any(|s| s == spec)) .filter(|(alias, _, _)| !identifier_is_reassigned(source, alias)) + // #sdxgen: Don't blank alias declarations for Node.js built-in + // modules — let them stay in the IIFE body and resolve through + // the synthetic require (which uses createRequire for builtins). + .filter(|(_, spec, _)| { + let normalized = spec.strip_prefix("node:").unwrap_or(spec); + let base = normalized.split('/').next().unwrap_or(normalized); + !perry_hir::is_node_builtin_module(base) + }) .map(|(_, _, range)| range) .collect::>(); (lines, ranges) @@ -902,6 +941,13 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( if (typeof specifier !== 'string') throw __perry_cjs_require_error('type', 'ERR_INVALID_ARG_TYPE', 'The "id" argument must be of type string.'); if (specifier === '') throw __perry_cjs_require_error('type', 'ERR_INVALID_ARG_VALUE', 'The argument "id" must be a non-empty string.'); {require_cases} + // #sdxgen: Node.js built-in modules that were NOT hoisted as static + // imports (see the builtin_requires filter above). Resolve them via + // createRequire at runtime, which calls js_create_native_module_namespace + // under the hood — the same path Node.js uses for require("process"). + if (__perry_cjs_require_is_builtin(specifier)) {{ + return __perry_cjs_create_require({module_path_literal})(specifier); + }} // Runtime `require(path)` of a module Perry AOT-compiled but that is // only reachable via a computed path. Next's webpack runtime uses both // absolute page paths and relative chunk paths (`./chunks/` + id).