diff --git a/changelog.d/7312-node-module-node26-parity.md b/changelog.d/7312-node-module-node26-parity.md new file mode 100644 index 0000000000..d818378bea --- /dev/null +++ b/changelog.d/7312-node-module-node26-parity.md @@ -0,0 +1,4 @@ +**Complete Node.js 26.5.0 `node:module` parity (#6769):** Perry now matches the +full tested Module/CommonJS surface, including resolution and cache lifecycle, +loader hooks, SourceMap behavior, TypeScript stripping, descriptors, and +builtin default/named export identity and synchronization. diff --git a/crates/perry-codegen/src/collectors/cjs_scaffolding.rs b/crates/perry-codegen/src/collectors/cjs_scaffolding.rs index cb0f89538d..9d19158972 100644 --- a/crates/perry-codegen/src/collectors/cjs_scaffolding.rs +++ b/crates/perry-codegen/src/collectors/cjs_scaffolding.rs @@ -336,13 +336,15 @@ impl CjsPreamble { Stmt::Let { id, .. } => id == record, Stmt::Expr(expr) => match expr { Expr::ObjectDefineProperty(..) => scaffolding.exempts_shape_barrier(expr), - Expr::PutValueSet { target, key, .. } => { + Expr::PutValueSet { + target, key, value, .. + } => { matches!( (target.as_ref(), key.as_ref()), (Expr::LocalGet(id), Expr::String(k)) if scaffolding.require.contains(id) && REQUIRE_LITERAL_KEYS.contains(&k.as_str()) - ) + ) && matches!(value.as_ref(), Expr::New { .. }) } _ => false, }, @@ -484,18 +486,36 @@ fn record_binding(stmt: &Stmt) -> Option { if !class_name.starts_with(ANON_SHAPE_PREFIX) { return None; } - // Exactly one field, whose value is an argument-less object literal. A - // record with more fields, or a non-literal field value, is not the - // template's `{ exports: {} }` and keeps its candidacy. - let [Expr::New { + // The first field is the argument-less `exports` object literal. Lowering + // may fold the wrapper's seven subsequent fixed fields into the same + // anonymous constructor, but no other multi-field record is scaffolding. + let Some(Expr::New { class_name: inner, args: inner_args, .. - }] = args.as_slice() + }) = args.first() else { return None; }; - (inner.starts_with(ANON_SHAPE_PREFIX) && inner_args.is_empty()).then_some(*id) + if !inner.starts_with(ANON_SHAPE_PREFIX) || !inner_args.is_empty() { + return None; + } + let folded_template = matches!( + args.as_slice(), + [ + _, + Expr::Bool(true), + factory, + Expr::String(id_value), + Expr::String(_path), + Expr::String(filename), + Expr::Bool(false), + Expr::Array(children), + ] if matches!(factory, Expr::LocalGet(_) | Expr::Undefined) + && id_value == filename + && children.is_empty() + ); + (args.len() == 1 || folded_template).then_some(*id) } /// R4: `var module = __cjs_module;` at the region's top level. diff --git a/crates/perry-codegen/src/dialect/mod.rs b/crates/perry-codegen/src/dialect/mod.rs index b8a34583b6..24ac4eb79a 100644 --- a/crates/perry-codegen/src/dialect/mod.rs +++ b/crates/perry-codegen/src/dialect/mod.rs @@ -35,6 +35,7 @@ mod tests; /// native path pre-declares every define before reading any body — calls to /// module-internal functions are forward references at module scope, exactly /// like registers are at function scope. +#[cfg(test)] pub(crate) fn predeclare_function_from_text<'ctx>( context: &'ctx Context, module: &Module<'ctx>, @@ -104,6 +105,7 @@ impl<'ctx, 'm> FnStream<'ctx, 'm> { /// Parse `fn_text` (a complete `define ... { ... }`) and build it into /// `module`. Returns the number of instructions constructed. +#[cfg(test)] pub(crate) fn add_function_from_text<'ctx>( context: &'ctx Context, module: &Module<'ctx>, diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index a73ff6713c..575a8bcf65 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -414,6 +414,60 @@ fn emit_i18n_row_value( Ok(ctx.block().load(DOUBLE, &result_slot)) } +/// Materialize a compiled-module namespace through the same runtime constructor +/// used by dynamic import. Namespace locals may also have a default-export +/// prefix for direct-call compatibility, so whole-value reads must take this +/// path before generic imported function/variable lowering. +fn materialize_compiled_namespace(ctx: &mut FnCtx<'_>, name: &str) -> Result> { + let mut members: Vec = ctx + .namespace_member_prefixes + .keys() + .filter(|(namespace, _)| namespace == name) + .map(|(_, member)| member.clone()) + .collect(); + if members.is_empty() { + return Ok(None); + } + members.sort(); + members.dedup(); + let count = (members.len() as u32).to_string(); + let zero = "0".to_string(); + let object = ctx + .block() + .call(I64, "js_object_alloc", &[(I32, &zero), (I32, &count)]); + let rooted = super::temp_root::rooted_handle_begin(ctx, &object, true); + for member in &members { + let member_get = Expr::PropertyGet { + byte_offset: 0, + object: Box::new(Expr::ExternFuncRef { + name: name.to_string(), + param_types: Vec::new(), + return_type: HirType::Any, + }), + property: member.clone(), + }; + let value = lower_expr(ctx, &member_get)?; + let key_index = ctx.strings.intern(member); + let key_global = format!("@{}", ctx.strings.entry(key_index).handle_global); + let object = super::temp_root::rooted_handle_get(ctx, &rooted); + let block = ctx.block(); + let key = block.load(DOUBLE, &key_global); + let key_bits = block.bitcast_double_to_i64(&key); + let key = block.and(I64, &key_bits, POINTER_MASK_I64); + block.call_void( + "js_object_set_field_by_name", + &[(I64, &object), (I64, &key), (DOUBLE, &value)], + ); + } + let object = super::temp_root::rooted_handle_get(ctx, &rooted); + let value = nanbox_pointer_inline(ctx.block(), &object); + let value = ctx + .block() + .call(DOUBLE, "js_finalize_namespace", &[(DOUBLE, &value)]); + super::temp_root::rooted_handle_release(ctx, rooted); + Ok(Some(value)) +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::WorkerNew { @@ -753,6 +807,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ], )); } + if ctx.namespace_imports.contains(name) { + if let Some(namespace) = materialize_compiled_namespace(ctx, name)? { + return Ok(namespace); + } + } if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { // Next.js lazy-require: a `_lazyreq_N` binding is the CJS require // shim's handle to a FUNCTION-LOCAL `require('S')`. S is @@ -880,101 +939,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // dedicated arms above; this catch-all only fires for // names with no resolution at all. if ctx.namespace_imports.contains(name) { - // A namespace import used as a whole VALUE (passed to a - // function, iterated by `Object.keys`/`for…in`/`Object.entries`, - // spread, …) must be a real object whose OWN ENUMERABLE - // properties are the source module's exports — not the empty - // `js_unresolved_namespace_stub`. Drizzle's - // `drizzle(pool, { schema })` (with `import * as schema`) and - // Stripe's `_prepResources` (`for (const name in resources)` - // over `import * as resources`) both enumerate the namespace and - // silently saw zero members otherwise. Materialize the object by - // resolving each exported member through the SAME per-member - // `ns.member` PropertyGet lowering (functions → closure - // singletons, consts → getters, classes → class refs). - let mut members: Vec = ctx - .namespace_member_prefixes - .keys() - .filter(|(ns, _)| ns == name) - .map(|(_, m)| m.clone()) - .collect(); - if !members.is_empty() { - members.sort(); - members.dedup(); - let n_str = (members.len() as u32).to_string(); - let zero_str = "0".to_string(); - let handle = ctx.block().call( - I64, - "js_object_alloc", - &[(I32, &zero_str), (I32, &n_str)], - ); - // #7280: root the half-built namespace object. - // - // Every other lowering that allocates an object and then - // fills it in carries this contract — `Expr::Object` since - // #6951, `Expr::ObjectSpread`, the class-object lowering - // since #7211. This one was added for a different reason - // (#629, Drizzle/Stripe namespace enumeration) and never - // got it, and it builds by far the LARGEST object in a - // dependency-scale program: one property per export of the - // imported module, materialized at every use site. - // - // Both halves of the loop are collection points, on every - // iteration: - // - // * `lower_expr(member_get)` is a full `ns.member` - // PropertyGet. For a const export that is a CALL into - // the exporting module's accessor — arbitrary user - // code; for a function it allocates a closure - // singleton; for a class it resolves a class ref. - // * `js_object_set_field_by_name` performs the keys-array - // transition, which allocates. - // - // With `handle` in a bare SSA register the object is - // reachable from NO root for the whole build, so a minor - // does not merely relocate it — it reclaims it, and the - // remaining stores land in recycled memory. The caller then - // receives a namespace whose members read back as garbage, - // which surfaces as `TypeError: is not a function` at - // the first member call, arbitrarily far away. - // - // Measured on #7280's stock-zod reproducer: `import * as - // core` materializes 269 members here, and the emitted IR - // carried ZERO `js_gc_temp_root_*` calls beside its 269 - // allocating stores. - let rooted = super::temp_root::rooted_handle_begin(ctx, &handle, true); - for member in &members { - let member_get = Expr::PropertyGet { - byte_offset: 0, - object: Box::new(Expr::ExternFuncRef { - name: name.clone(), - param_types: Vec::new(), - return_type: HirType::Any, - }), - property: member.clone(), - }; - let v_box = lower_expr(ctx, &member_get)?; - let key_idx = ctx.strings.intern(member); - let key_handle_global = - format!("@{}", ctx.strings.entry(key_idx).handle_global); - // Re-read AFTER the member resolution: that is the - // collection point, so a register captured before it is - // the stale one. - let handle = super::temp_root::rooted_handle_get(ctx, &rooted); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); - blk.call_void( - "js_object_set_field_by_name", - &[(I64, &handle), (I64, &key_raw), (DOUBLE, &v_box)], - ); - } - let handle = super::temp_root::rooted_handle_get(ctx, &rooted); - let boxed = nanbox_pointer_inline(ctx.block(), &handle); - super::temp_root::rooted_handle_release(ctx, rooted); - return Ok(boxed); - } return Ok(ctx .block() .call(DOUBLE, "js_unresolved_namespace_stub", &[])); diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index fe54039b18..2959dcc7fc 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -596,8 +596,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Issue #649: PropertyGet on a native-module reference (`fs`, // `os`, `crypto`, `path`, ...). `NativeModuleRef` lowers to a // literal `0.0`, so the generic PropertyGet path can't see the - // namespace. Short-circuit to `js_native_module_property_by_name` - // which consults the constants dispatcher directly. For chained + // namespace. Short-circuit to the snapshot-backed ESM export + // lookup, which consults the constants dispatcher on first read + // and is refreshed by `syncBuiltinESMExports`. For chained // access like `fs.constants.F_OK` only the inner read fires // here — `constants` returns a real NATIVE_MODULE_CLASS_ID // ObjectHeader, and the outer PropertyGet routes through @@ -640,11 +641,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(nanbox_string_inline(blk, &handle)); } let mod_idx = ctx.strings.intern(module_name); - let mod_bytes_global = format!("@{}", ctx.strings.entry(mod_idx).bytes_global); - let mod_len_str = module_name.len().to_string(); let prop_idx = ctx.strings.intern(property); - let prop_bytes_global = format!("@{}", ctx.strings.entry(prop_idx).bytes_global); - let prop_len_str = property.len().to_string(); // The value read of a native-module callable export (`const f = // util.inherits`) mints a BOUND_METHOD closure that, when invoked // indirectly, dispatches through the per-module `NM_DISPATCH_REGISTRY` @@ -659,15 +656,30 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(install_sym) = crate::nm_install::nm_install_symbol(module_name) { ctx.block().call_void(install_sym, &[]); } + if module_name == "fs" && property == "promises" { + let mod_bytes_global = format!("@{}", ctx.strings.entry(mod_idx).bytes_global); + let prop_bytes_global = + format!("@{}", ctx.strings.entry(prop_idx).bytes_global); + return Ok(ctx.block().call( + DOUBLE, + "js_native_module_property_by_name", + &[ + (PTR, &mod_bytes_global), + (I64, &module_name.len().to_string()), + (PTR, &prop_bytes_global), + (I64, &property.len().to_string()), + ], + )); + } + let mod_handle_global = format!("@{}", ctx.strings.entry(mod_idx).handle_global); + let prop_handle_global = format!("@{}", ctx.strings.entry(prop_idx).handle_global); + let blk = ctx.block(); + let module_value = blk.load(DOUBLE, &mod_handle_global); + let property_value = blk.load(DOUBLE, &prop_handle_global); return Ok(ctx.block().call( DOUBLE, - "js_native_module_property_by_name", - &[ - (PTR, &mod_bytes_global), - (I64, &mod_len_str), - (PTR, &prop_bytes_global), - (I64, &prop_len_str), - ], + "js_native_module_esm_export_value", + &[(DOUBLE, &module_value), (DOUBLE, &property_value)], )); } // Cross-module static field access. When `Base` is an imported diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index e60e61bf43..ead2b72c58 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -32,6 +32,7 @@ pub(crate) fn is_other_builtin_constructor_name(name: &str) -> bool { | "Set" | "WeakMap" | "WeakSet" + | "EventTarget" | "Array" | "ArrayBuffer" | "SharedArrayBuffer" diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index 1ccfdd4d5d..34b1561367 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -82,6 +82,7 @@ const ELF_SECTION: &str = ".perry_gcmap,\"awR\",@progbits"; const COFF_SECTION: &str = ".pgcmap,\"dw\""; /// What the runtime looks for in a PE image. Must match `COFF_SECTION`'s name /// and stay within eight bytes. +#[cfg(test)] pub(crate) const COFF_SECTION_NAME: &str = ".pgcmap"; /// LLVM stack-map v3 location kinds. Only these two describe a frame slot; diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index d394bf5314..09947286f4 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -713,11 +713,14 @@ fn compile_ll_inprocess_in( policy: TempFilePolicy, ) -> Result> { let (paths, _pid, _nonce) = llvm_temp_paths(tmp_dir, ll_text); + // The in-process backend still needs a companion clang for structural + // analysis metadata and, for statepoints, final assembly. + let companion_clang = find_clang().unwrap_or_else(|| PathBuf::from("(in-process)")); // Same decision inputs as the clang path — opt level (#4880 fallback // included), CPU tuning, inlinehint threshold — via the same plan // constructor, so the backends cannot drift on a decision independently. let plan = build_clang_compile_plan( - PathBuf::from("(in-process)"), + companion_clang, paths.ll_path.clone(), paths.obj_path.clone(), target_triple, @@ -767,8 +770,9 @@ fn compile_ll_inprocess_in( } fs::write(asm_path, &bytes) .with_context(|| format!("Failed to write {}", asm_path.display()))?; - // `plan.clang` is the literal `(in-process)` placeholder here, so - // resolve a real assembler. Using the system clang for this step is + // Resolve the assembler again so a missing companion recorded as + // `(in-process)` above still gets a precise error here. Using the + // system clang for this step is // sound: the version skew that motivated the in-process backend was // an *IR* parse failure (`unterminated attribute group`), and by // this point the IR is gone — what is being assembled is text this diff --git a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs index 064fe7c176..9500fa5ade 100644 --- a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs @@ -173,6 +173,13 @@ pub fn lower_native_module_dispatch( } } } + // `findPackageJSON()` distinguishes a missing first argument from an + // explicitly supplied `undefined`, although both otherwise lower to the + // same NaN-box value. Preserve the source call arity for the runtime. + if sig.runtime == "js_module_find_package_json" { + llvm_args.push((DOUBLE, double_literal(args.len() as f64))); + arg_types.push(DOUBLE); + } // Determine return type for the declare let ret_type = match sig.ret { diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs index 3fab9de38a..933bb24c9b 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs @@ -13,7 +13,7 @@ pub(crate) const NODE_CORE_MODULE_SEA_TLS_TEST_ROWS: &[NativeModSig] = &[ // codegen can't emit precise per-module installs). Mirrors // js_process_get_builtin_module_devirt. runtime: "js_module_create_require_devirt", - args: &[NA_F64], + args: &[NA_F64, NA_F64], ret: NR_F64, }, NativeModSig { @@ -157,6 +157,15 @@ pub(crate) const NODE_CORE_MODULE_SEA_TLS_TEST_ROWS: &[NativeModSig] = &[ method: "SourceMap", class_filter: None, runtime: "js_module_source_map_new", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "module", + has_receiver: false, + method: "findSourceMap", + class_filter: None, + runtime: "js_module_find_source_map", args: &[NA_F64], ret: NR_F64, }, diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 70cd18c90d..164357ca75 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -250,6 +250,11 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { DOUBLE, &[PTR, I64, PTR, I64], ); + module.declare_function( + "js_native_module_esm_export_value", + DOUBLE, + &[DOUBLE, DOUBLE], + ); // Issue #894: materialize a NATIVE_MODULE_CLASS_ID-tagged namespace // object for `Expr::NativeModuleRef` when it reaches the value-form // fallback path (the require-call-result-then-member-access shape diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 472589e1e5..9fa0562dc1 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -926,7 +926,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_process_source_maps_enabled", DOUBLE, &[]); module.declare_function("js_process_set_source_maps_enabled", DOUBLE, &[DOUBLE]); module.declare_function("js_module_is_builtin", DOUBLE, &[DOUBLE]); - module.declare_function("js_module_find_package_json", DOUBLE, &[DOUBLE, DOUBLE]); + module.declare_function( + "js_module_find_package_json", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE], + ); + module.declare_function("js_module_find_source_map", DOUBLE, &[DOUBLE]); + module.declare_function("js_module_source_map_new", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_module_register", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]); module.declare_function("js_module_register_hooks", DOUBLE, &[DOUBLE]); module.declare_function("js_process_next_tick", VOID, &[I64, I64]); diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index ff2fd7c670..3998a5b4a7 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -886,6 +886,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // (returned wrapped in `js_promise_resolved`). See // `crates/perry-runtime/src/object.rs::js_create_namespace`. module.declare_function("js_create_namespace", DOUBLE, &[I32, PTR, PTR, PTR]); + module.declare_function("js_finalize_namespace", DOUBLE, &[DOUBLE]); module.declare_function("js_promise_then", I64, &[I64, I64, I64]); module.declare_function("js_promise_resolved_then", I64, &[DOUBLE, I64, I64]); module.declare_function("js_promise_finally", I64, &[I64, I64]); diff --git a/crates/perry-codegen/src/statepoint_report.rs b/crates/perry-codegen/src/statepoint_report.rs index 01c9b4ea77..23fdb7b717 100644 --- a/crates/perry-codegen/src/statepoint_report.rs +++ b/crates/perry-codegen/src/statepoint_report.rs @@ -11,7 +11,6 @@ //! knob with no CI arm, so that spelling was deleted under CLAUDE.md's GC knob //! kill policy. `gc-native-roots.yml` exercises the report through the flag. -use std::collections::BTreeMap; use std::fmt::Write as _; use std::sync::{Mutex, OnceLock}; @@ -164,21 +163,6 @@ fn totals(records: &[FunctionRecord]) -> Totals { out } -fn render_ranked_map(out: &mut String, heading: &str, values: &BTreeMap) { - if values.is_empty() { - return; - } - let mut rows: Vec<_> = values.iter().collect(); - rows.sort_by(|(name_a, count_a), (name_b, count_b)| { - count_b.cmp(count_a).then_with(|| name_a.cmp(name_b)) - }); - let _ = writeln!(out, "{heading}"); - for (name, count) in rows.into_iter().take(25) { - let _ = writeln!(out, " {count:>6} {name}"); - } - out.push('\n'); -} - pub fn render_text(records: &[FunctionRecord]) -> String { render_text_with(records, take_gc_map()) } diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index e12564daf7..134636c0ae 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -592,6 +592,9 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result<()> { for id in ids { + if ctx.module_globals.contains_key(id) { + continue; + } if ctx.locals.contains_key(id) { // A previous PreallocateBoxes (or an unusual nesting) // already set this up -- skip to keep the existing slot. diff --git a/crates/perry-hir/src/dynamic_import.rs b/crates/perry-hir/src/dynamic_import.rs index e450fff0c3..e138676eba 100644 --- a/crates/perry-hir/src/dynamic_import.rs +++ b/crates/perry-hir/src/dynamic_import.rs @@ -27,7 +27,9 @@ use std::collections::{HashMap, HashSet}; /// to. Over-cap produces a compile error per D2 (issue #100). pub const DYNAMIC_IMPORT_PATH_CAP: usize = 64; +mod top_level_await; mod visitors; +pub use top_level_await::detect_top_level_await; pub use visitors::{ for_each_dynamic_import, for_each_dynamic_import_mut, for_each_worker_new, for_each_worker_new_mut, @@ -609,36 +611,57 @@ pub fn collect_dynamic_import_local_candidate_literals>( collect_local_candidate_defs_expr(expr, &mut defs, &mut invalid); } - let mut out: HashMap> = HashMap::new(); - for (id, exprs) in defs { - if invalid.contains(&id) { - continue; + // Async lowering moves locals into the generated step closure and emits a + // leading `LocalSet(id, undefined)` before replaying the source-level + // assignments. Ignore only that leading initializer; a later undefined + // assignment still invalidates the candidate set. + for exprs in defs.values_mut() { + if exprs.len() > 1 && matches!(exprs.first(), Some(Expr::Undefined)) { + exprs.remove(0); } - let mut candidates: Vec = Vec::new(); - let mut ok = true; - for expr in exprs { - let mut visiting = HashSet::new(); - match resolve_import_path_with_consts_and_params( - expr, - consts, - param_literals, - &mut visiting, - ) { - Resolution::Set(paths) => { - for path in paths { - if !candidates.contains(&path) { - candidates.push(path); + } + + // Resolve to a fixed point so one candidate local can feed another (for + // example `name = flag ? "a" : "b"; path = `./${name}.ts``). The old + // one-pass map walk could not resolve these chains and was order-dependent. + let mut out: HashMap> = HashMap::new(); + loop { + let mut changed = false; + for (&id, exprs) in &defs { + if invalid.contains(&id) || out.contains_key(&id) { + continue; + } + let mut candidates: Vec = Vec::new(); + let mut ok = true; + for expr in exprs { + let mut visiting = HashSet::new(); + match resolve_import_path_with_context( + expr, + consts, + param_literals, + &out, + &mut visiting, + ) { + Resolution::Set(paths) => { + for path in paths { + if !candidates.contains(&path) { + candidates.push(path); + } } } + Resolution::Unresolved(_) => { + ok = false; + break; + } } - Resolution::Unresolved(_) => { - ok = false; - break; - } + } + if ok && !candidates.is_empty() { + out.insert(id, candidates); + changed = true; } } - if ok && !candidates.is_empty() { - out.insert(id, candidates); + if !changed { + break; } } out @@ -1391,6 +1414,15 @@ pub fn resolve_import_path_with_context>( ) -> Resolution { match arg { Expr::String(s) => Resolution::Set(vec![s.clone()]), + // Template interpolation lowers through StringCoerce even when the + // wrapped local has a finite string candidate set. + Expr::StringCoerce(value) => resolve_import_path_with_context( + value, + consts, + param_literals, + local_literals, + visiting, + ), Expr::Call { callee, args, .. } => match static_string_replace_target(callee, args) { Some(string) => resolve_string_replace_parts( string, @@ -1895,106 +1927,5 @@ fn split_static_path_prefix(path: &str) -> (&str, &str) { ("", path) } -/// Scan `module.init` for an `await` expression outside any function/ -/// closure body and set `module.has_top_level_await` accordingly. -/// -/// Idempotent — safe to call multiple times. Closure bodies are NOT -/// descended into because awaits inside them belong to the closure's -/// own async scope, not the module's top level. -pub fn detect_top_level_await(module: &mut Module) { - let mut found = false; - for stmt in &module.init { - if stmt_has_top_level_await(stmt) { - found = true; - break; - } - } - module.has_top_level_await = found; -} - -fn stmt_has_top_level_await(stmt: &Stmt) -> bool { - match stmt { - Stmt::Let { init, .. } => init.as_ref().is_some_and(expr_has_top_level_await), - Stmt::Expr(e) => expr_has_top_level_await(e), - Stmt::Return(opt) => opt.as_ref().is_some_and(expr_has_top_level_await), - Stmt::If { - condition, - then_branch, - else_branch, - } => { - expr_has_top_level_await(condition) - || then_branch.iter().any(stmt_has_top_level_await) - || else_branch - .as_ref() - .is_some_and(|b| b.iter().any(stmt_has_top_level_await)) - } - Stmt::While { condition, body } => { - expr_has_top_level_await(condition) || body.iter().any(stmt_has_top_level_await) - } - Stmt::DoWhile { body, condition } => { - body.iter().any(stmt_has_top_level_await) || expr_has_top_level_await(condition) - } - Stmt::For { - init, - condition, - update, - body, - } => { - init.as_deref().is_some_and(stmt_has_top_level_await) - || condition.as_ref().is_some_and(expr_has_top_level_await) - || update.as_ref().is_some_and(expr_has_top_level_await) - || body.iter().any(stmt_has_top_level_await) - } - Stmt::Labeled { body, .. } => stmt_has_top_level_await(body), - Stmt::Throw(e) => expr_has_top_level_await(e), - Stmt::Try { - body, - catch, - finally, - } => { - body.iter().any(stmt_has_top_level_await) - || catch - .as_ref() - .is_some_and(|c| c.body.iter().any(stmt_has_top_level_await)) - || finally - .as_ref() - .is_some_and(|f| f.iter().any(stmt_has_top_level_await)) - } - Stmt::Switch { - discriminant, - cases, - } => { - expr_has_top_level_await(discriminant) - || cases.iter().any(|c| { - c.test.as_ref().is_some_and(expr_has_top_level_await) - || c.body.iter().any(stmt_has_top_level_await) - }) - } - Stmt::Break - | Stmt::Continue - | Stmt::LabeledBreak(_) - | Stmt::LabeledContinue(_) - | Stmt::PreallocateBoxes(_) - | Stmt::PreallocateTdzBoxes(_) => false, - } -} - -fn expr_has_top_level_await(expr: &Expr) -> bool { - // The walker's `Closure` arm intentionally does NOT descend into the - // closure body, which is exactly the semantics we need: an `await` - // inside a nested closure/function belongs to that function's scope, - // not the module's top level. - if matches!(expr, Expr::Await(_)) { - return true; - } - let mut found = false; - walk_expr_children(expr, &mut |child| { - if !found && expr_has_top_level_await(child) { - found = true; - } - }); - found -} - #[cfg(test)] mod tests; diff --git a/crates/perry-hir/src/dynamic_import/top_level_await.rs b/crates/perry-hir/src/dynamic_import/top_level_await.rs new file mode 100644 index 0000000000..9f629fde69 --- /dev/null +++ b/crates/perry-hir/src/dynamic_import/top_level_await.rs @@ -0,0 +1,92 @@ +use crate::ir::{Expr, Module, Stmt}; +use crate::walker::walk_expr_children; + +/// Scan `module.init` for an `await` expression outside any function/ +/// closure body and set `module.has_top_level_await` accordingly. +/// +/// Idempotent — safe to call multiple times. Closure bodies are NOT +/// descended into because awaits inside them belong to the closure's +/// own async scope, not the module's top level. +pub fn detect_top_level_await(module: &mut Module) { + module.has_top_level_await = module.init.iter().any(stmt_has_top_level_await); +} + +fn stmt_has_top_level_await(stmt: &Stmt) -> bool { + match stmt { + Stmt::Let { init, .. } => init.as_ref().is_some_and(expr_has_top_level_await), + Stmt::Expr(e) => expr_has_top_level_await(e), + Stmt::Return(opt) => opt.as_ref().is_some_and(expr_has_top_level_await), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr_has_top_level_await(condition) + || then_branch.iter().any(stmt_has_top_level_await) + || else_branch + .as_ref() + .is_some_and(|b| b.iter().any(stmt_has_top_level_await)) + } + Stmt::While { condition, body } => { + expr_has_top_level_await(condition) || body.iter().any(stmt_has_top_level_await) + } + Stmt::DoWhile { body, condition } => { + body.iter().any(stmt_has_top_level_await) || expr_has_top_level_await(condition) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_some_and(stmt_has_top_level_await) + || condition.as_ref().is_some_and(expr_has_top_level_await) + || update.as_ref().is_some_and(expr_has_top_level_await) + || body.iter().any(stmt_has_top_level_await) + } + Stmt::Labeled { body, .. } => stmt_has_top_level_await(body), + Stmt::Throw(e) => expr_has_top_level_await(e), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().any(stmt_has_top_level_await) + || catch + .as_ref() + .is_some_and(|c| c.body.iter().any(stmt_has_top_level_await)) + || finally + .as_ref() + .is_some_and(|f| f.iter().any(stmt_has_top_level_await)) + } + Stmt::Switch { + discriminant, + cases, + } => { + expr_has_top_level_await(discriminant) + || cases.iter().any(|c| { + c.test.as_ref().is_some_and(expr_has_top_level_await) + || c.body.iter().any(stmt_has_top_level_await) + }) + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => false, + } +} + +fn expr_has_top_level_await(expr: &Expr) -> bool { + if matches!(expr, Expr::Await(_)) { + return true; + } + let mut found = false; + walk_expr_children(expr, &mut |child| { + if !found && expr_has_top_level_await(child) { + found = true; + } + }); + found +} diff --git a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs index a114f39321..3d5dfee7d5 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs @@ -136,6 +136,19 @@ pub(super) fn try_imported_module_dispatch( })); } if method_name == "call" { + // A named native-module function is a real callable value. + // Keep `.call(receiver, ...)` on the ordinary + // Function.prototype.call path instead of treating it as a + // nonexistent module-level `call` export. + let imported_callable = imported_method + .and_then(|name| perry_api_manifest::module_has_symbol(module_name, name)) + .is_some_and(|entry| { + matches!( + entry.kind, + perry_api_manifest::ApiKind::Method { .. } + | perry_api_manifest::ApiKind::Class + ) + }); if normalized_module == "stream" && matches!(imported_method, None | Some("Stream")) { @@ -177,6 +190,9 @@ pub(super) fn try_imported_module_dispatch( byte_offset: 0, })); } + if imported_callable { + return Ok(Err(args)); + } } // Unimplemented-API gate (#463 / #525) for the 2-deep // `mod.method()` call form. Without this, perry/* and diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index e08a253708..6bb0a625b3 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -416,8 +416,15 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re if is_process_obj { if let ast::MemberProp::Ident(prop_ident) = &member.prop { let prop = prop_ident.sym.as_ref(); - if let Some(expr) = process_metadata_native_property(prop) { - return Ok(expr); + if prop != "sourceMapsEnabled" + || matches!( + ctx.lookup_native_module(obj_name), + Some(("process.namespace", None)) + ) + { + if let Some(expr) = process_metadata_native_property(prop) { + return Ok(expr); + } } match prop { "argv" => return Ok(Expr::ProcessArgv), @@ -621,8 +628,10 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re if inner_is_global_process { if let ast::MemberProp::Ident(prop_ident) = &member.prop { let prop = prop_ident.sym.as_ref(); - if let Some(expr) = process_metadata_native_property(prop) { - return Ok(expr); + if prop != "sourceMapsEnabled" { + if let Some(expr) = process_metadata_native_property(prop) { + return Ok(expr); + } } match prop { "argv" => return Ok(Expr::ProcessArgv), diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 3fe7cc2125..2c924fc4c4 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -73,14 +73,24 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R } if let ast::Expr::Ident(callee_ident) = callee_expr { - let is_module_constructor = ctx + let module_constructor = ctx .lookup_native_module(callee_ident.sym.as_ref()) .map(|(module_name, method)| { - module_name == "module" - && matches!(method.as_deref(), Some("Module") | Some("default")) + (module_name == "module" + && matches!( + method.as_deref(), + Some("Module") | Some("SourceMap") | Some("default") + )) + .then(|| { + if method.as_deref() == Some("SourceMap") { + "SourceMap" + } else { + "Module" + } + }) }) - .unwrap_or(false); - if is_module_constructor { + .flatten(); + if let Some(method) = module_constructor { let args = new_expr .args .as_ref() @@ -95,7 +105,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R module: "module".to_string(), class_name: None, object: None, - method: "Module".to_string(), + method: method.to_string(), args, }); } @@ -514,13 +524,16 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R } if let Some((module_name, method_name)) = ctx.lookup_native_module(&class_name) { - if matches!((module_name, method_name), ("module", Some("Module"))) { + if module_name == "module" + && matches!(method_name, Some("Module") | Some("SourceMap")) + { + let method = method_name.unwrap_or("Module").to_string(); let args = lower_optional_args(ctx, new_expr.args.as_deref())?; return Ok(Expr::NativeMethodCall { module: "module".to_string(), class_name: None, object: None, - method: "Module".to_string(), + method, args, }); } diff --git a/crates/perry-runtime/src/eh.rs b/crates/perry-runtime/src/eh.rs index 052185f983..7f99f6f163 100644 --- a/crates/perry-runtime/src/eh.rs +++ b/crates/perry-runtime/src/eh.rs @@ -61,12 +61,13 @@ extern "C" { fn _Unwind_RaiseException(exception: *mut UnwindException) -> UnwindReasonCode; fn _Unwind_GetLanguageSpecificData(ctx: *mut UnwindContext) -> *const u8; fn _Unwind_GetIPInfo(ctx: *mut UnwindContext, ip_before_insn: *mut c_int) -> usize; + pub(crate) fn _Unwind_GetIP(ctx: *mut UnwindContext) -> usize; fn _Unwind_GetRegionStart(ctx: *mut UnwindContext) -> usize; fn _Unwind_SetGR(ctx: *mut UnwindContext, reg_index: c_int, value: usize); fn _Unwind_SetIP(ctx: *mut UnwindContext, value: usize); fn _Unwind_GetCFA(ctx: *mut UnwindContext) -> usize; - fn _Unwind_Backtrace( - trace: extern "C" fn(*mut UnwindContext, *mut core::ffi::c_void) -> UnwindReasonCode, + pub(crate) fn _Unwind_Backtrace( + trace: unsafe extern "C" fn(*mut UnwindContext, *mut core::ffi::c_void) -> UnwindReasonCode, arg: *mut core::ffi::c_void, ) -> UnwindReasonCode; } @@ -113,7 +114,10 @@ fn selfcheck_frame_a() -> usize { #[inline(never)] fn selfcheck_frame_b() -> usize { - extern "C" fn count(_ctx: *mut UnwindContext, arg: *mut core::ffi::c_void) -> UnwindReasonCode { + unsafe extern "C" fn count( + _ctx: *mut UnwindContext, + arg: *mut core::ffi::c_void, + ) -> UnwindReasonCode { unsafe { *(arg as *mut usize) += 1 }; // _URC_NO_REASON: the ONLY value that lets _Unwind_Backtrace keep // walking — any other reason code stops the trace after one frame. diff --git a/crates/perry-runtime/src/eh_walker.rs b/crates/perry-runtime/src/eh_walker.rs index cc92c743f6..183a2b7826 100644 --- a/crates/perry-runtime/src/eh_walker.rs +++ b/crates/perry-runtime/src/eh_walker.rs @@ -277,7 +277,11 @@ fn parse_unwind_info(ui: &[u8], image_base: u64) -> (Vec<(u64, u32)>, Vec<(u64, let enc = if idx < common.len() { common[idx] } else { - u32at(page_off + enc_off + 4 * (idx - common.len())) + let local_idx = idx - common.len(); + if local_idx >= enc_count { + continue; + } + u32at(page_off + enc_off + 4 * local_idx) }; funcs.push((image_base + fn_base + (raw & 0x00FF_FFFF) as u64, enc)); } @@ -919,17 +923,12 @@ mod tests { /// Collect frame PCs via the SYSTEM unwinder (_Unwind_Backtrace) — /// the oracle the owned walk must match. fn system_pcs(max: usize) -> Vec { - use core::ffi::{c_int, c_void}; - unsafe extern "C" { - fn _Unwind_Backtrace( - trace: extern "C" fn(*mut c_void, *mut c_void) -> c_int, - arg: *mut c_void, - ) -> c_int; - fn _Unwind_GetIP(ctx: *mut c_void) -> u64; - } - extern "C" fn cb(ctx: *mut c_void, arg: *mut c_void) -> c_int { + use crate::eh::{_Unwind_Backtrace, _Unwind_GetIP, UnwindContext, UnwindReasonCode}; + use core::ffi::c_void; + + unsafe extern "C" fn cb(ctx: *mut UnwindContext, arg: *mut c_void) -> UnwindReasonCode { let v = unsafe { &mut *(arg as *mut Vec) }; - unsafe { v.push(_Unwind_GetIP(ctx)) }; + unsafe { v.push(_Unwind_GetIP(ctx) as u64) }; 0 } let mut v: Vec = Vec::with_capacity(max); diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index d9bcddf2e6..84beb581be 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -679,6 +679,7 @@ pub fn gc_init() { gc_register_mutable_root_scanner(crate::tls::scan_tls_roots_mut); gc_register_mutable_root_scanner(crate::process::scan_process_finalization_roots_mut); gc_register_mutable_root_scanner(crate::process::scan_process_module_loader_roots_mut); + gc_register_mutable_root_scanner(crate::module_require::scan_module_path_registry_roots_mut); // #7231: the materialize-once `process.*` caches. Each is a thread-local // cell holding a NURSERY-allocated object that nothing else refers to — // `process.env` / `.permission` / `.report` are getter CALLS, not fields diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 2086639bb2..878f7c7a41 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -663,14 +663,7 @@ pub(super) fn try_mark_value_or_raw(word: u64, valid_ptrs: &ValidPointerSet) -> #[inline(always)] #[cfg(target_os = "macos")] pub(super) fn get_stack_bottom() -> usize { - extern "C" { - fn pthread_self() -> *mut std::ffi::c_void; - fn pthread_get_stackaddr_np(thread: *mut std::ffi::c_void) -> *mut std::ffi::c_void; - } - unsafe { - let thread = pthread_self(); - pthread_get_stackaddr_np(thread) as usize - } + unsafe { libc::pthread_get_stackaddr_np(libc::pthread_self()) as usize } } #[cfg(target_os = "linux")] diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index 3e6a52877f..0a365bb107 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -984,18 +984,9 @@ fn loaded_stack_map_section() -> Option<&'static [u8]> { #[cfg(any(target_vendor = "apple", target_os = "linux"))] mod unwind { use super::*; - - #[repr(C)] - struct UnwindContext { - _private: [u8; 0], - } + use crate::eh::{_Unwind_Backtrace, _Unwind_GetIP, UnwindContext, UnwindReasonCode}; unsafe extern "C" { - fn _Unwind_Backtrace( - trace: unsafe extern "C" fn(*mut UnwindContext, *mut c_void) -> i32, - argument: *mut c_void, - ) -> i32; - fn _Unwind_GetIP(context: *mut UnwindContext) -> usize; fn _Unwind_GetGR(context: *mut UnwindContext, register: i32) -> usize; /// The frame's canonical frame address — the supported way to reach a /// frame's stack pointer. `_Unwind_GetGR` on the SP column is not a @@ -1033,7 +1024,7 @@ mod unwind { unsafe extern "C" fn walk_frame( context: *mut UnwindContext, argument: *mut c_void, - ) -> i32 { + ) -> UnwindReasonCode { let state = &mut *argument.cast::>(); state.stats.frames_visited = state.stats.frames_visited.saturating_add(1); let ip = _Unwind_GetIP(context); @@ -1360,11 +1351,7 @@ mod fp_chain { // the alternative was this module quietly not existing there. #[cfg(target_vendor = "apple")] fn stack_top() -> usize { - unsafe extern "C" { - fn pthread_self() -> usize; - fn pthread_get_stackaddr_np(thread: usize) -> *mut c_void; - } - unsafe { pthread_get_stackaddr_np(pthread_self()) as usize } + unsafe { libc::pthread_get_stackaddr_np(libc::pthread_self()) as usize } } /// Linux (#7173): stack bounds via pthread attrs — the returned address diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 0f5609d24f..d889aaa16a 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -4,8 +4,11 @@ //! public function shape. Full CommonJS file/package resolution remains in the //! compiler-side CJS wrapper and future `Module._*` work. -use crate::closure::{js_closure_alloc, js_register_closure_arity, ClosureHeader}; -use crate::object::{js_object_alloc, js_object_set_field_by_name}; +use crate::closure::{ + js_closure_alloc, js_closure_get_capture_f64, js_closure_set_capture_f64, + js_register_closure_arity, ClosureHeader, +}; +use crate::object::{js_object_alloc, js_object_get_field_by_name, js_object_set_field_by_name}; use crate::string::js_string_from_bytes; use crate::value::{js_nanbox_pointer, JSValue, TAG_NULL, TAG_UNDEFINED}; @@ -27,12 +30,26 @@ fn object_value(obj: *mut crate::object::ObjectHeader) -> f64 { } fn set_field(obj: *mut crate::object::ObjectHeader, name: &str, value: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let value_handle = scope.root_nanbox_f64(value); let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(obj, key, value); + js_object_set_field_by_name( + obj_handle.get_raw_mut_ptr::(), + key, + value_handle.get_nanbox_f64(), + ); } fn set_closure_prop(closure: *mut ClosureHeader, name: &str, value: f64) { - crate::closure::closure_set_dynamic_prop(closure as usize, name, value); + let scope = crate::gc::RuntimeHandleScope::new(); + let closure_handle = scope.root_raw_mut_ptr(closure); + let value_handle = scope.root_nanbox_f64(value); + crate::closure::closure_set_dynamic_prop( + closure_handle.get_raw_mut_ptr::() as usize, + name, + value_handle.get_nanbox_f64(), + ); } fn named_closure( @@ -43,9 +60,18 @@ fn named_closure( ) -> (*mut ClosureHeader, f64) { js_register_closure_arity(func, arity); crate::closure::js_register_closure_length(func, length); - let closure = js_closure_alloc(func, 0); - crate::object::set_bound_native_closure_name(closure, name); - crate::object::set_builtin_closure_length(closure as usize, length); + let closure = js_closure_alloc(func, 1); + let scope = crate::gc::RuntimeHandleScope::new(); + let closure_handle = scope.root_raw_mut_ptr(closure); + crate::object::set_bound_native_closure_name( + closure_handle.get_raw_mut_ptr::(), + name, + ); + crate::object::set_builtin_closure_length( + closure_handle.get_raw_mut_ptr::() as usize, + length, + ); + let closure = closure_handle.get_raw_mut_ptr::(); (closure, js_nanbox_pointer(closure as i64)) } @@ -120,96 +146,626 @@ fn throw_module_not_found(specifier: &str) -> ! { crate::fs::validate::throw_error_with_code(&message, "MODULE_NOT_FOUND") } -fn throw_unsupported_package_require(specifier: &str) -> ! { - let message = format!( - "Perry createRequire() currently supports built-in modules only; package/file require('{}') is not supported under perry compile. Use ESM import syntax and perry.compilePackages instead.", +fn throw_require_module_not_found(specifier: &str, base: &std::path::Path) -> ! { + let message = format!("Cannot find module '{specifier}'"); + let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32); + crate::node_submodules::register_error_code_pub(msg, "MODULE_NOT_FOUND"); + let error = crate::error::js_error_new_with_message(msg); + let scope = crate::gc::RuntimeHandleScope::new(); + let error_handle = scope.root_raw_mut_ptr(error); + let stack = crate::array::js_array_alloc_with_length(1); + let stack_handle = scope.root_raw_mut_ptr(stack); + let base_value = string_value(&base.to_string_lossy()); + crate::array::js_array_set_f64( + stack_handle.get_raw_mut_ptr::(), + 0, + base_value, + ); + let error = error_handle.get_raw_mut_ptr::(); + let error_value = js_nanbox_pointer(error as i64); + unsafe { + crate::object::exotic_expando::exotic_set_property( + error as usize, + crate::object::exotic_expando::ExoticKind::Error, + "requireStack", + f64::from_bits( + JSValue::array_ptr(stack_handle.get_raw_mut_ptr::()) + .bits(), + ), + error_value, + ); + } + crate::exception::js_throw(error_value) +} + +fn throw_package_path_not_exported(specifier: &str) -> ! { + let message = format!("Package subpath '{specifier}' is not defined by \"exports\""); + crate::fs::validate::throw_error_with_code(&message, "ERR_PACKAGE_PATH_NOT_EXPORTED") +} + +#[derive(Clone, Copy)] +enum ResolveError { + NotFound, + NotExported, +} + +fn require_base_dir(closure: *const ClosureHeader) -> std::path::PathBuf { + let path = std::path::PathBuf::from(require_base_filename(closure)); + path.parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .to_path_buf() +} + +fn require_base_filename(closure: *const ClosureHeader) -> String { + if closure.is_null() { + return std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join("__perry_ambient.cjs") + .to_string_lossy() + .into_owned(); + } + value_to_string(js_closure_get_capture_f64(closure, 0), "filename") +} + +fn resolve_file(path: &std::path::Path) -> Option { + if path.is_file() { + return Some(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); + } + for ext in ["js", "json", "node"] { + let mut candidate = path.as_os_str().to_os_string(); + candidate.push("."); + candidate.push(ext); + let candidate = std::path::PathBuf::from(candidate); + if candidate.is_file() { + return Some(std::fs::canonicalize(&candidate).unwrap_or(candidate)); + } + } + if path.is_dir() { + if let Ok(text) = std::fs::read_to_string(path.join("package.json")) { + if let Ok(manifest) = serde_json::from_str::(&text) { + if let Some(main) = manifest.get("main").and_then(|v| v.as_str()) { + if let Some(found) = resolve_file(&path.join(main)) { + return Some(found); + } + } + } + } + for ext in ["js", "json", "node", "cjs"] { + let candidate = path.join(format!("index.{ext}")); + if candidate.is_file() { + return Some(std::fs::canonicalize(&candidate).unwrap_or(candidate)); + } + } + } + None +} + +fn package_parts(specifier: &str) -> (&str, Option<&str>) { + if specifier.starts_with('@') { + let mut parts = specifier.splitn(3, '/'); + let scope = parts.next().unwrap_or(specifier); + let name = parts.next().unwrap_or(""); + let package_len = scope.len() + 1 + name.len(); + (&specifier[..package_len.min(specifier.len())], parts.next()) + } else { specifier + .split_once('/') + .map_or((specifier, None), |(p, s)| (p, Some(s))) + } +} + +fn resolve_exports(value: &serde_json::Value, key: &str) -> Option { + match value { + serde_json::Value::String(target) => Some(target.clone()), + serde_json::Value::Array(items) => items.iter().find_map(|v| resolve_exports(v, key)), + serde_json::Value::Object(map) => { + if let Some(target) = map.get(key) { + return resolve_exports(target, key); + } + for (condition, target) in map { + if matches!(condition.as_str(), "node" | "require" | "default") { + if let Some(found) = resolve_exports(target, key) { + return Some(found); + } + } + } + None + } + _ => None, + } +} + +fn resolve_request( + base: &std::path::Path, + specifier: &str, +) -> Result { + if specifier.starts_with('/') { + return resolve_file(std::path::Path::new(specifier)).ok_or(ResolveError::NotFound); + } + if specifier.starts_with("./") || specifier.starts_with("../") { + return resolve_file(&base.join(specifier)).ok_or(ResolveError::NotFound); + } + let (package, subpath) = package_parts(specifier); + for ancestor in base.ancestors() { + let package_dir = ancestor.join("node_modules").join(package); + if !package_dir.is_dir() { + continue; + } + if let Ok(text) = std::fs::read_to_string(package_dir.join("package.json")) { + if let Ok(manifest) = serde_json::from_str::(&text) { + if let Some(exports) = manifest.get("exports") { + let key = subpath + .map(|s| format!("./{s}")) + .unwrap_or_else(|| ".".into()); + let target = resolve_exports(exports, &key).ok_or(ResolveError::NotExported)?; + return resolve_file(&package_dir.join(target)).ok_or(ResolveError::NotFound); + } + if let Some(subpath) = subpath { + return resolve_file(&package_dir.join(subpath)).ok_or(ResolveError::NotFound); + } + if let Some(main) = manifest.get("main").and_then(|v| v.as_str()) { + if let Some(found) = resolve_file(&package_dir.join(main)) { + return Ok(found); + } + } + } + } + return resolve_file(&package_dir).ok_or(ResolveError::NotFound); + } + Err(ResolveError::NotFound) +} + +fn object_ptr(value: f64) -> *mut crate::object::ObjectHeader { + crate::value::js_nanbox_get_pointer(value) as *mut crate::object::ObjectHeader +} + +fn cached_record(cache: f64, filename: &str) -> Option<(f64, f64)> { + let scope = crate::gc::RuntimeHandleScope::new(); + let cache_handle = scope.root_nanbox_f64(cache); + let key = js_string_from_bytes(filename.as_ptr(), filename.len() as u32); + let record = js_object_get_field_by_name(object_ptr(cache_handle.get_nanbox_f64()), key); + if record.is_undefined() { + return None; + } + let record_handle = scope.root_nanbox_f64(f64::from_bits(record.bits())); + let exports_key = js_string_from_bytes(b"exports".as_ptr(), 7); + let exports = f64::from_bits( + js_object_get_field_by_name(object_ptr(record_handle.get_nanbox_f64()), exports_key).bits(), + ); + Some((record_handle.get_nanbox_f64(), exports)) +} + +fn cache_exports(cache: f64, filename: &str, exports: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let cache_handle = scope.root_nanbox_f64(cache); + let exports_handle = scope.root_nanbox_f64(exports); + let record = js_object_alloc(0, 5); + let record_handle = scope.root_raw_mut_ptr(record); + let id = string_value(filename); + set_field(record_handle.get_raw_mut_ptr(), "id", id); + let filename_value = string_value(filename); + set_field(record_handle.get_raw_mut_ptr(), "filename", filename_value); + set_field( + record_handle.get_raw_mut_ptr(), + "exports", + exports_handle.get_nanbox_f64(), + ); + set_field( + record_handle.get_raw_mut_ptr(), + "loaded", + f64::from_bits(crate::value::TAG_TRUE), + ); + let children = crate::array::js_array_alloc_with_length(0); + let children_handle = scope.root_raw_mut_ptr(children); + set_field( + record_handle.get_raw_mut_ptr(), + "children", + f64::from_bits(JSValue::array_ptr(children_handle.get_raw_mut_ptr()).bits()), + ); + let key = js_string_from_bytes(filename.as_ptr(), filename.len() as u32); + let record_value = object_value(record_handle.get_raw_mut_ptr()); + js_object_set_field_by_name(object_ptr(cache_handle.get_nanbox_f64()), key, record_value); + record_value +} + +fn cjs_record_exports(value: f64) -> Option { + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return None; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let record_handle = scope.root_nanbox_f64(value); + let marker_key = js_string_from_bytes(b"__perry_cjs_record".as_ptr(), 18); + let record = object_ptr(record_handle.get_nanbox_f64()); + let marker = js_object_get_field_by_name(record, marker_key); + if !marker.is_bool() || !marker.as_bool() { + return None; + } + let exports_key = js_string_from_bytes(b"exports".as_ptr(), 7); + let exports = f64::from_bits( + js_object_get_field_by_name(object_ptr(record_handle.get_nanbox_f64()), exports_key).bits(), + ); + Some(exports) +} + +fn cjs_record_field(value: f64, name: &str) -> Option { + if cjs_record_exports(value).is_none() { + return None; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let record_handle = scope.root_nanbox_f64(value); + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + Some(f64::from_bits( + js_object_get_field_by_name(object_ptr(record_handle.get_nanbox_f64()), key).bits(), + )) +} + +fn registered_path_module_value(path: &str) -> Option { + let key = canonicalize_module_path(path); + let guard = MODULE_PATH_REGISTRY.read().unwrap(); + guard + .as_ref() + .and_then(|modules| modules.get(&key).copied()) + .map(f64::from_bits) +} + +fn link_parent(cache: f64, record: f64, parent_filename: &str) { + let scope = crate::gc::RuntimeHandleScope::new(); + let cache_handle = scope.root_nanbox_f64(cache); + let record_handle = scope.root_nanbox_f64(record); + let parent_key = js_string_from_bytes(b"parent".as_ptr(), 6); + let existing = + js_object_get_field_by_name(object_ptr(record_handle.get_nanbox_f64()), parent_key); + if !existing.is_undefined() { + return; + } + let cache_key = js_string_from_bytes(parent_filename.as_ptr(), parent_filename.len() as u32); + let cached_parent = + js_object_get_field_by_name(object_ptr(cache_handle.get_nanbox_f64()), cache_key); + let parent = scope.root_nanbox_f64(if cached_parent.is_undefined() { + let parent = js_object_alloc(0, 2); + let parent_handle = scope.root_raw_mut_ptr(parent); + let id = string_value(parent_filename); + set_field(parent_handle.get_raw_mut_ptr(), "id", id); + let children = scope.root_nanbox_f64(f64::from_bits( + JSValue::array_ptr(crate::array::js_array_alloc_with_length(0)).bits(), + )); + set_field( + parent_handle.get_raw_mut_ptr(), + "children", + children.get_nanbox_f64(), + ); + object_value(parent_handle.get_raw_mut_ptr()) + } else { + f64::from_bits(cached_parent.bits()) + }); + set_field( + object_ptr(record_handle.get_nanbox_f64()), + "parent", + parent.get_nanbox_f64(), + ); + let children_key = js_string_from_bytes(b"children".as_ptr(), 8); + let children = js_object_get_field_by_name(object_ptr(parent.get_nanbox_f64()), children_key); + let mut children_ptr = if children.is_pointer() { + let ptr = children.as_pointer::(); + if unsafe { crate::value::addr_class::try_read_gc_header(ptr as usize) } + .is_some_and(|header| header.obj_type == crate::gc::GC_TYPE_ARRAY) + { + ptr as *mut crate::array::ArrayHeader + } else { + std::ptr::null_mut() + } + } else { + std::ptr::null_mut() + }; + if children_ptr.is_null() { + let children = scope.root_nanbox_f64(f64::from_bits( + JSValue::array_ptr(crate::array::js_array_alloc_with_length(0)).bits(), + )); + js_object_set_field_by_name( + object_ptr(parent.get_nanbox_f64()), + children_key, + children.get_nanbox_f64(), + ); + children_ptr = crate::value::js_nanbox_get_pointer(children.get_nanbox_f64()) + as *mut crate::array::ArrayHeader; + } + if crate::array::js_array_includes_f64(children_ptr, record_handle.get_nanbox_f64()) == 0 { + let children_ptr = + crate::array::js_array_push_f64(children_ptr, record_handle.get_nanbox_f64()); + js_object_set_field_by_name( + object_ptr(parent.get_nanbox_f64()), + children_key, + f64::from_bits(JSValue::array_ptr(children_ptr).bits()), + ); + } +} + +struct PendingRequireParentGuard; + +impl Drop for PendingRequireParentGuard { + fn drop(&mut self) { + PENDING_REQUIRE_PARENT.with(|pending| { + pending.borrow_mut().take(); + }); + } +} + +fn require_path(cache: f64, path: &std::path::Path, parent_filename: &str) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let cache_handle = scope.root_nanbox_f64(cache); + let filename = path.to_string_lossy(); + if let Some((record, exports)) = cached_record(cache_handle.get_nanbox_f64(), &filename) { + link_parent(cache_handle.get_nanbox_f64(), record, parent_filename); + return exports; + } + let mut registered = registered_path_module_value(&filename).unwrap_or_else(|| { + PENDING_REQUIRE_PARENT.with(|pending| { + *pending.borrow_mut() = Some(parent_filename.to_string()); + }); + let _pending_parent_guard = PendingRequireParentGuard; + let result = js_require_path_module(string_value(&filename)); + registered_path_module_value(&filename).unwrap_or(result) + }); + if let Some((record, exports)) = cached_record(cache_handle.get_nanbox_f64(), &filename) { + link_parent(cache_handle.get_nanbox_f64(), record, parent_filename); + run_custom_extension(path, record, &filename); + return exports; + } + if cjs_record_field(registered, "loaded").is_some_and(|loaded| { + JSValue::from_bits(loaded.to_bits()).is_bool() + && JSValue::from_bits(loaded.to_bits()).as_bool() + }) { + if let Some(factory) = cjs_record_field(registered, "__perry_cjs_factory") { + let factory_value = JSValue::from_bits(factory.to_bits()); + if factory_value.is_pointer() + && crate::closure::is_closure_ptr( + crate::value::js_nanbox_get_pointer(factory) as usize + ) + { + let factory_handle = scope.root_nanbox_f64(factory); + let exports = crate::closure::js_closure_call0( + crate::value::js_nanbox_get_pointer(factory_handle.get_nanbox_f64()) + as *const ClosureHeader, + ); + if let Some((record, cached)) = + cached_record(cache_handle.get_nanbox_f64(), &filename) + { + link_parent(cache_handle.get_nanbox_f64(), record, parent_filename); + run_custom_extension(path, record, &filename); + return cached; + } + let exports_handle = scope.root_nanbox_f64(exports); + let (record, value) = + if let Some(value) = cjs_record_exports(exports_handle.get_nanbox_f64()) { + let key = js_string_from_bytes(filename.as_ptr(), filename.len() as u32); + js_object_set_field_by_name( + object_ptr(cache_handle.get_nanbox_f64()), + key, + exports_handle.get_nanbox_f64(), + ); + (exports_handle.get_nanbox_f64(), value) + } else { + ( + cache_exports( + cache_handle.get_nanbox_f64(), + &filename, + exports_handle.get_nanbox_f64(), + ), + exports_handle.get_nanbox_f64(), + ) + }; + let record_handle = scope.root_nanbox_f64(record); + link_parent( + cache_handle.get_nanbox_f64(), + record_handle.get_nanbox_f64(), + parent_filename, + ); + run_custom_extension(path, record_handle.get_nanbox_f64(), &filename); + return value; + } + } + registered = registered_path_module_value(&filename).unwrap_or(registered); + } + let registered_handle = scope.root_nanbox_f64(registered); + let exports = if let Some(exports) = cjs_record_exports(registered_handle.get_nanbox_f64()) { + exports + } else if !JSValue::from_bits(registered_handle.get_nanbox_f64().to_bits()).is_undefined() { + registered_handle.get_nanbox_f64() + } else if path.extension().and_then(|e| e.to_str()) == Some("json") { + js_require_json_disk(string_value(&filename)) + } else { + if JSValue::from_bits(registered_handle.get_nanbox_f64().to_bits()).is_undefined() { + throw_module_not_found(&filename); + } + registered_handle.get_nanbox_f64() + }; + let exports_handle = scope.root_nanbox_f64(exports); + let record = if cjs_record_exports(registered_handle.get_nanbox_f64()).is_some() { + let key = js_string_from_bytes(filename.as_ptr(), filename.len() as u32); + js_object_set_field_by_name( + object_ptr(cache_handle.get_nanbox_f64()), + key, + registered_handle.get_nanbox_f64(), + ); + registered_handle.get_nanbox_f64() + } else { + cache_exports( + cache_handle.get_nanbox_f64(), + &filename, + exports_handle.get_nanbox_f64(), + ) + }; + let record_handle = scope.root_nanbox_f64(record); + if cjs_record_exports(record_handle.get_nanbox_f64()).is_some() { + set_field( + object_ptr(record_handle.get_nanbox_f64()), + "loaded", + f64::from_bits(crate::value::TAG_TRUE), + ); + } + link_parent( + cache_handle.get_nanbox_f64(), + record_handle.get_nanbox_f64(), + parent_filename, ); - crate::fs::validate::throw_error_with_code(&message, "ERR_PERRY_UNSUPPORTED_CREATE_REQUIRE") + run_custom_extension(path, record_handle.get_nanbox_f64(), &filename); + exports_handle.get_nanbox_f64() } -extern "C" fn require_thunk(_closure: *const ClosureHeader, id: f64) -> f64 { +fn run_custom_extension(path: &std::path::Path, record: f64, filename: &str) { + let scope = crate::gc::RuntimeHandleScope::new(); + let record_handle = scope.root_nanbox_f64(record); + if let Some(extension) = path.extension().and_then(|e| e.to_str()) { + let extension = format!(".{extension}"); + if !matches!(extension.as_str(), ".js" | ".json" | ".node" | ".cjs") { + let extensions_handle = + scope.root_nanbox_f64(crate::object::module_cjs_extensions_value()); + let key = js_string_from_bytes(extension.as_ptr(), extension.len() as u32); + let handler = + js_object_get_field_by_name(object_ptr(extensions_handle.get_nanbox_f64()), key); + if !handler.is_undefined() && !handler.is_null() { + let handler_handle = scope.root_nanbox_f64(f64::from_bits(handler.bits())); + let handler_ptr = + crate::value::js_nanbox_get_pointer(handler_handle.get_nanbox_f64()) as usize; + if !handler.is_pointer() || !crate::closure::is_closure_ptr(handler_ptr) { + crate::process::module_throw_plain_type_error( + "Module._extensions[extension] is not a function", + ); + } + let filename_value = string_value(&filename); + crate::closure::js_closure_call2( + handler_ptr as *mut ClosureHeader, + record_handle.get_nanbox_f64(), + filename_value, + ); + } + } + } +} + +extern "C" fn require_thunk(closure: *const ClosureHeader, id: f64) -> f64 { let specifier = value_to_string(id, "id"); if specifier.is_empty() { let message = "The argument 'id' must be a non-empty string"; crate::fs::validate::throw_type_error_with_code(message, "ERR_INVALID_ARG_VALUE"); } - let Some(module_name) = supported_require_builtin(&specifier) else { - throw_unsupported_package_require(&specifier); - }; - require_builtin_value(module_name) + if let Some(module_name) = supported_require_builtin(&specifier) { + return require_builtin_value(module_name); + } + let base = require_base_dir(closure); + let parent_filename = require_base_filename(closure); + match resolve_request(&base, &specifier) { + Ok(path) => require_path( + crate::object::module_cjs_cache_value(), + &path, + &parent_filename, + ), + Err(ResolveError::NotExported) => throw_package_path_not_exported(&specifier), + Err(ResolveError::NotFound) => throw_require_module_not_found(&specifier, &base), + } } -extern "C" fn resolve_thunk(_closure: *const ClosureHeader, request: f64, _options: f64) -> f64 { +extern "C" fn resolve_thunk(closure: *const ClosureHeader, request: f64, _options: f64) -> f64 { let specifier = value_to_string(request, "request"); if let Some(resolved) = resolve_builtin(&specifier) { return string_value(resolved); } - throw_module_not_found(&specifier) + let base = require_base_dir(closure); + match resolve_request(&base, &specifier) { + Ok(path) => string_value(&path.to_string_lossy()), + Err(ResolveError::NotExported) => throw_package_path_not_exported(&specifier), + Err(ResolveError::NotFound) => throw_require_module_not_found(&specifier, &base), + } } -extern "C" fn resolve_paths_thunk(_closure: *const ClosureHeader, request: f64) -> f64 { +extern "C" fn resolve_paths_thunk(closure: *const ClosureHeader, request: f64) -> f64 { let specifier = value_to_string(request, "request"); if supported_require_builtin(&specifier).is_some() { return null(); } - null() -} - -extern "C" fn extension_noop_thunk( - _closure: *const ClosureHeader, - _module: f64, - _filename: f64, -) -> f64 { - undefined() -} - -fn extensions_object() -> f64 { + let base = require_base_dir(closure); + let paths: Vec<_> = if specifier.starts_with("./") || specifier.starts_with("../") { + vec![base.to_string_lossy().into_owned()] + } else { + base.ancestors() + .map(|dir| dir.join("node_modules").to_string_lossy().into_owned()) + .collect() + }; let scope = crate::gc::RuntimeHandleScope::new(); - let obj = js_object_alloc(0, 3); - let obj_handle = scope.root_raw_mut_ptr(obj); - for name in [".js", ".json", ".node"] { - let (_, value) = named_closure(extension_noop_thunk as *const u8, 2, 2, name); - let value_handle = scope.root_nanbox_f64(value); - set_field( - obj_handle.get_raw_mut_ptr::(), - name, - value_handle.get_nanbox_f64(), - ); + let arr = crate::array::js_array_alloc_with_length(paths.len() as u32); + let arr_handle = scope.root_raw_mut_ptr(arr); + for (index, path) in paths.iter().enumerate() { + let path_value = string_value(path); + crate::array::js_array_set_f64(arr_handle.get_raw_mut_ptr(), index as u32, path_value); } - object_value(obj_handle.get_raw_mut_ptr::()) + f64::from_bits(JSValue::array_ptr(arr_handle.get_raw_mut_ptr()).bits()) } -fn make_require(main_value: f64) -> f64 { +fn make_require(base: f64, main_value: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); + let base_handle = scope.root_nanbox_f64(base); + let main_handle = scope.root_nanbox_f64(main_value); let (_, paths_value) = named_closure(resolve_paths_thunk as *const u8, 1, 1, "paths"); let paths_handle = scope.root_nanbox_f64(paths_value); - let (resolve_closure, resolve_value) = - named_closure(resolve_thunk as *const u8, 2, 2, "resolve"); + js_closure_set_capture_f64( + object_ptr(paths_handle.get_nanbox_f64()) as *mut ClosureHeader, + 0, + base_handle.get_nanbox_f64(), + ); + let (_, resolve_value) = named_closure(resolve_thunk as *const u8, 2, 2, "resolve"); let resolve_handle = scope.root_nanbox_f64(resolve_value); - set_closure_prop(resolve_closure, "paths", paths_handle.get_nanbox_f64()); + js_closure_set_capture_f64( + object_ptr(resolve_handle.get_nanbox_f64()) as *mut ClosureHeader, + 0, + base_handle.get_nanbox_f64(), + ); + set_closure_prop( + object_ptr(resolve_handle.get_nanbox_f64()) as *mut ClosureHeader, + "paths", + paths_handle.get_nanbox_f64(), + ); + let resolve_prototype = scope.root_nanbox_f64(object_value(js_object_alloc(0, 0))); + set_closure_prop( + object_ptr(resolve_handle.get_nanbox_f64()) as *mut ClosureHeader, + "prototype", + resolve_prototype.get_nanbox_f64(), + ); - let cache_handle = scope.root_nanbox_f64(object_value(js_object_alloc(0, 0))); - let extensions_handle = scope.root_nanbox_f64(extensions_object()); + let cache_handle = scope.root_nanbox_f64(crate::object::module_cjs_cache_value()); + let extensions_handle = scope.root_nanbox_f64(crate::object::module_cjs_extensions_value()); - let (require_closure, require_value) = - named_closure(require_thunk as *const u8, 1, 1, "require"); + let (_, require_value) = named_closure(require_thunk as *const u8, 1, 1, "require"); let require_handle = scope.root_nanbox_f64(require_value); - set_closure_prop(require_closure, "resolve", resolve_handle.get_nanbox_f64()); - set_closure_prop(require_closure, "cache", cache_handle.get_nanbox_f64()); + js_closure_set_capture_f64( + object_ptr(require_handle.get_nanbox_f64()) as *mut ClosureHeader, + 0, + base_handle.get_nanbox_f64(), + ); + let require_ptr = || object_ptr(require_handle.get_nanbox_f64()) as *mut ClosureHeader; + set_closure_prop(require_ptr(), "resolve", resolve_handle.get_nanbox_f64()); + set_closure_prop(require_ptr(), "cache", cache_handle.get_nanbox_f64()); set_closure_prop( - require_closure, + require_ptr(), "extensions", extensions_handle.get_nanbox_f64(), ); - set_closure_prop(require_closure, "main", main_value); + set_closure_prop(require_ptr(), "main", main_handle.get_nanbox_f64()); + let require_prototype = scope.root_nanbox_f64(object_value(js_object_alloc(0, 0))); + set_closure_prop( + require_ptr(), + "prototype", + require_prototype.get_nanbox_f64(), + ); require_handle.get_nanbox_f64() } #[no_mangle] pub extern "C" fn js_module_create_require(filename_or_url: f64) -> f64 { validate_create_require_base(filename_or_url); - make_require(undefined()) + let base = crate::url::node_compat::module_base_to_path(filename_or_url) + .unwrap_or_else(|| value_to_string(filename_or_url, "filename")); + make_require(string_value(&base), undefined()) } /// Devirt codegen entry for `module.createRequire(...)` (#6644). The require @@ -240,6 +796,20 @@ pub extern "C" fn js_module_create_require_devirt(filename_or_url: f64) -> f64 { static MODULE_PATH_REGISTRY: std::sync::RwLock>> = std::sync::RwLock::new(None); +pub(crate) fn scan_module_path_registry_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + if let Some(modules) = MODULE_PATH_REGISTRY.write().unwrap().as_mut() { + for bits in modules.values_mut() { + let mut value = f64::from_bits(*bits); + visitor.visit_nanbox_f64_slot(&mut value); + *bits = value.to_bits(); + } + } +} + +thread_local! { + static PENDING_REQUIRE_PARENT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + /// Next.js wall 54 (part 2): registry mapping an AOT-compiled module's absolute /// source path to the ADDRESS of its `__init` function, so a runtime /// `require(absolutePath.js)` can LAZILY trigger init of a module that was NOT @@ -286,12 +856,29 @@ pub unsafe extern "C" fn js_register_path_init(path_ptr: *const u8, path_len: i6 /// [`MODULE_PATH_REGISTRY`]. #[no_mangle] pub extern "C" fn js_register_path_module(path_value: f64, exports: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let exports = scope.root_nanbox_f64(exports); let path = value_to_string(path_value, "path"); let key = canonicalize_module_path(&path); + if let Some(parent_filename) = + PENDING_REQUIRE_PARENT.with(|pending| pending.borrow_mut().take()) + { + if cjs_record_exports(exports.get_nanbox_f64()).is_some() { + let record = scope.root_nanbox_f64(exports.get_nanbox_f64()); + let parent = scope.root_nanbox_f64(object_value(js_object_alloc(0, 1))); + let id = string_value(&parent_filename); + set_field(object_ptr(parent.get_nanbox_f64()), "id", id); + set_field( + object_ptr(record.get_nanbox_f64()), + "parent", + parent.get_nanbox_f64(), + ); + } + } let mut guard = MODULE_PATH_REGISTRY.write().unwrap(); guard .get_or_insert_with(std::collections::HashMap::new) - .insert(key, exports.to_bits()); + .insert(key, exports.get_nanbox_f64().to_bits()); } /// Codegen FFI: resolve a runtime `require(absolutePath.js)` to a registered @@ -309,7 +896,8 @@ pub extern "C" fn js_require_path_module(path_value: f64) -> f64 { let guard = MODULE_PATH_REGISTRY.read().unwrap(); if let Some(map) = guard.as_ref() { if let Some(bits) = map.get(&key) { - return f64::from_bits(*bits); + let value = f64::from_bits(*bits); + return cjs_record_exports(value).unwrap_or(value); } } } @@ -332,7 +920,8 @@ pub extern "C" fn js_require_path_module(path_value: f64) -> f64 { let guard = MODULE_PATH_REGISTRY.read().unwrap(); if let Some(map) = guard.as_ref() { if let Some(bits) = map.get(&key) { - return f64::from_bits(*bits); + let value = f64::from_bits(*bits); + return cjs_record_exports(value).unwrap_or(value); } } } @@ -362,7 +951,8 @@ pub extern "C" fn js_require_path_module(path_value: f64) -> f64 { guard.as_ref().and_then(|m| m.get(&cand_key).copied()) }; if let Some(bits) = resolved { - return f64::from_bits(bits); + let value = f64::from_bits(bits); + return cjs_record_exports(value).unwrap_or(value); } // Deferred module: trigger its init, then re-check. let cand_init = { @@ -375,7 +965,8 @@ pub extern "C" fn js_require_path_module(path_value: f64) -> f64 { init_fn(); let guard = MODULE_PATH_REGISTRY.read().unwrap(); if let Some(bits) = guard.as_ref().and_then(|m| m.get(&cand_key).copied()) { - return f64::from_bits(bits); + let value = f64::from_bits(bits); + return cjs_record_exports(value).unwrap_or(value); } } } @@ -487,7 +1078,12 @@ pub extern "C" fn js_require_json_disk(specifier: f64) -> f64 { /// resolution. #[no_mangle] pub extern "C" fn js_module_ambient_require() -> f64 { - make_require(undefined()) + let base = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join("__perry_ambient.cjs") + .to_string_lossy() + .into_owned(); + make_require(string_value(&base), undefined()) } /// Keepalive anchor for the auto-optimize whole-program build (generated-code-only diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 3d7cc364bd..cb196c5483 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -592,8 +592,9 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu } } _ => { - let dynv = crate::closure::closure_get_dynamic_prop(ptr, name); - if dynv.to_bits() != crate::value::TAG_UNDEFINED { + if crate::closure::closure_has_own_dynamic_prop(ptr, name) { + let dynv = crate::closure::closure_get_own_dynamic_prop(ptr, name) + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); let attrs = registered .unwrap_or(super::PropertyAttrs::new(true, true, true)); Some(( @@ -610,6 +611,7 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu let Some((value, writable, enumerable, configurable)) = resolved else { return f64::from_bits(crate::value::TAG_UNDEFINED); }; + let value_handle = scope.root_nanbox_f64(value); let packed = b"value\0writable\0enumerable\0configurable"; let desc = js_object_alloc_with_shape( 0x0D_E5_C0, @@ -620,7 +622,7 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu let header_size = std::mem::size_of::(); let fields = (desc as *mut u8).add(header_size) as *mut f64; // GC_STORE_AUDIT(INIT): descriptor object is freshly allocated; layout is rebuilt before publication. - *fields = value; + *fields = value_handle.get_nanbox_f64(); *fields.add(1) = f64::from_bits(if writable { TAG_TRUE } else { TAG_FALSE }); *fields.add(2) = f64::from_bits(if enumerable { TAG_TRUE } else { TAG_FALSE }); *fields.add(3) = @@ -1615,6 +1617,14 @@ pub(crate) unsafe fn nm_get_own_descriptor( .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); return Some(build_data_descriptor(value, false, true, false)); } + if module_name == "module" { + return Some(build_data_descriptor( + f64::from_bits(value.bits()), + true, + true, + false, + )); + } Some(build_data_descriptor( f64::from_bits(value.bits()), true, diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 7c5d6d6518..e8e5abacef 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1839,6 +1839,16 @@ pub(crate) fn get_field_by_name_object_tail( } } + // CommonJS Module instances inherit an intrinsic constructor accessor. + // Resolve it to the exact shared ESM/callable identity after own-field + // lookup, preserving ordinary shadowing while avoiding a rebound + // function value from the generic inherited-accessor path. + if (*obj).class_id == crate::process::MODULE_CJS_CLASS_ID && key_bytes == b"constructor" { + return JSValue::from_bits( + crate::object::module_constructor_identity_value().to_bits(), + ); + } + // #2820: before giving up, walk an explicit `Object.setPrototypeOf` // prototype chain recorded for this object so inherited property reads // (`obj.x` where `x` is an own property of the set prototype) resolve. diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index e342ab31f2..4d1b9372c2 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -519,6 +519,7 @@ fn is_uncallable_builtin_super_parent(name: &str) -> bool { | "Set" | "WeakMap" | "WeakSet" + | "EventTarget" | "Array" | "ArrayBuffer" | "SharedArrayBuffer" @@ -556,6 +557,7 @@ fn is_uncallable_builtin_super_parent_class_id(class_id: u32) -> bool { "Set", "WeakMap", "WeakSet", + "EventTarget", "Array", "ArrayBuffer", "DataView", diff --git a/crates/perry-runtime/src/object/namespace_create.rs b/crates/perry-runtime/src/object/namespace_create.rs index d02930c81f..100bd1a1e4 100644 --- a/crates/perry-runtime/src/object/namespace_create.rs +++ b/crates/perry-runtime/src/object/namespace_create.rs @@ -4,6 +4,29 @@ use super::*; +pub(crate) const MODULE_NAMESPACE_CLASS_ID: u32 = 0xFFFF_4E53; + +/// Apply the host-defined invariants shared by static and dynamic module +/// namespace objects. +#[no_mangle] +pub extern "C" fn js_finalize_namespace(value: f64) -> f64 { + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return value; + } + let obj = jv.as_pointer::() as *mut ObjectHeader; + if obj.is_null() + || !crate::value::addr_class::is_above_handle_band(obj as usize) + || !crate::object::is_valid_obj_ptr(obj as *const u8) + { + return value; + } + unsafe { + (*obj).class_id = MODULE_NAMESPACE_CLASS_ID; + } + crate::object::js_object_prevent_extensions(value) +} + /// Issue #100: build a module-namespace object (the value an `await /// import("./foo.ts")` resolves to) from parallel arrays of keys and /// values. @@ -38,6 +61,17 @@ pub extern "C" fn js_create_namespace( ) -> f64 { let count = if n < 0 { 0 } else { n as usize }; unsafe { + // Export values arrive in a caller stack buffer, which is not part of + // the runtime root set. Namespace construction allocates the object, + // keys array, and key strings; retain every value across those moving + // collections so CJS default/shared object identity is preserved. + let raw_values = if count == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(values, count).to_vec() + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let value_handles = scope.root_nanbox_f64_slice(&raw_values); // Allocate a plain object with `count` inline slots. class_id 0 // is the generic-object class used by Object.create / {} / URL. let obj = js_object_alloc(0, count as u32); @@ -45,7 +79,6 @@ pub extern "C" fn js_create_namespace( // Fallback to undefined — should never happen but defensive. return f64::from_bits(0x7FFC_0000_0000_0001); } - let scope = crate::gc::RuntimeHandleScope::new(); let obj_handle = scope.root_raw_mut_ptr(obj); // Initialize an empty keys array so `js_object_set_field_by_name` @@ -72,14 +105,14 @@ pub extern "C" fn js_create_namespace( // pointer. Pre-SSO-only would crash on >7-byte export names. let key_hdr = crate::string::js_string_from_bytes(key_data, key_len_u); obj = obj_handle.get_raw_mut_ptr::(); - let val = *values.add(i); + let val = value_handles[i].get_nanbox_f64(); js_object_set_field_by_name(obj, key_hdr, val); } // NaN-box POINTER_TAG and return. obj = obj_handle.get_raw_mut_ptr::(); let bits = (obj as u64) | 0x7FFD_0000_0000_0000; - f64::from_bits(bits) + js_finalize_namespace(f64::from_bits(bits)) } } diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 8b52dd9b48..eb7a2ef9a7 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -29,8 +29,9 @@ pub(crate) use callable_exports::{ bound_native_callable_value_arity, buffer_constructor_value, builtin_closure_is_non_constructable_value, builtin_closure_length, fs_namespace_descriptor_getter_value, fs_namespace_descriptor_setter_value, - is_buffer_constructor_value, is_cluster_emitter_method, module_cjs_cache_value, - module_cjs_extensions_value, module_cjs_global_paths_value, module_cjs_path_cache_value, + is_buffer_constructor_value, is_cluster_emitter_method, module_builtin_modules_value, + module_cjs_cache_value, module_cjs_extensions_value, module_cjs_global_paths_value, + module_cjs_path_cache_value, module_cjs_prototype_for_instance, module_constants_value, native_string_value, scan_tls_derived_prototype_roots_mut, set_bound_native_closure_name, set_builtin_closure_length, set_builtin_closure_non_constructable, sqlite_session_constructor_value, sqlite_statement_sync_constructor_value, @@ -67,6 +68,9 @@ thread_local! { pub(crate) static MODULE_CJS_EXTENSIONS_VALUE: Cell = const { Cell::new(0) }; pub(crate) static MODULE_CJS_PATH_CACHE_VALUE: Cell = const { Cell::new(0) }; pub(crate) static MODULE_CJS_GLOBAL_PATHS_VALUE: Cell = const { Cell::new(0) }; + pub(crate) static MODULE_CJS_PROTOTYPE_VALUE: Cell = const { Cell::new(0) }; + pub(crate) static MODULE_BUILTIN_MODULES_VALUE: Cell = const { Cell::new(0) }; + pub(crate) static MODULE_CONSTANTS_VALUE: Cell = const { Cell::new(0) }; pub(crate) static NATIVE_MODULE_NAMESPACES: RefCell> = RefCell::new(HashMap::new()); /// User overrides of native-module namespace properties, keyed @@ -76,6 +80,8 @@ thread_local! { /// subsequent property reads instead of throwing read-only. static NATIVE_NAMESPACE_PROP_OVERRIDES: RefCell> = RefCell::new(HashMap::new()); + static NATIVE_ESM_EXPORT_VALUES: RefCell> = + RefCell::new(HashMap::new()); } /// Store a user override for a native-module namespace property @@ -124,6 +130,12 @@ pub fn scan_native_callable_export_roots_mut(visitor: &mut crate::gc::RuntimeRoo visitor.visit_nanbox_u64_slot(value_bits); } }); + NATIVE_ESM_EXPORT_VALUES.with(|cache| { + let mut cache = cache.borrow_mut(); + for value_bits in cache.values_mut() { + visitor.visit_nanbox_u64_slot(value_bits); + } + }); NATIVE_MODULE_ACCESSOR_EXPORTS.with(|cache| { let mut cache = cache.borrow_mut(); for value_bits in cache.values_mut() { @@ -221,6 +233,27 @@ pub fn scan_native_callable_export_roots_mut(visitor: &mut crate::gc::RuntimeRoo slot.set(value_bits); } }); + MODULE_CJS_PROTOTYPE_VALUE.with(|slot| { + let mut value_bits = slot.get(); + if value_bits != 0 { + visitor.visit_nanbox_u64_slot(&mut value_bits); + slot.set(value_bits); + } + }); + MODULE_BUILTIN_MODULES_VALUE.with(|slot| { + let mut value_bits = slot.get(); + if value_bits != 0 { + visitor.visit_nanbox_u64_slot(&mut value_bits); + slot.set(value_bits); + } + }); + MODULE_CONSTANTS_VALUE.with(|slot| { + let mut value_bits = slot.get(); + if value_bits != 0 { + visitor.visit_nanbox_u64_slot(&mut value_bits); + slot.set(value_bits); + } + }); WORKER_THREADS_WEB_LOCKS.with(|state| { let mut state = state.borrow_mut(); for held in &mut state.held { @@ -467,6 +500,9 @@ pub extern "C" fn js_create_native_module_namespace( // Return as NaN-boxed pointer let value = crate::value::js_nanbox_pointer(obj as i64); + if module_name == "module" { + crate::object::js_object_seal(value); + } if should_cache_native_module_namespace(module_name) { NATIVE_MODULE_NAMESPACES.with(|cache| { cache @@ -753,6 +789,22 @@ pub unsafe extern "C" fn js_native_module_property_by_name( module_name_len: usize, property_name_ptr: *const u8, property_name_len: usize, +) -> f64 { + native_module_property_by_name_impl( + module_name_ptr, + module_name_len, + property_name_ptr, + property_name_len, + true, + ) +} + +unsafe fn native_module_property_by_name_impl( + module_name_ptr: *const u8, + module_name_len: usize, + property_name_ptr: *const u8, + property_name_len: usize, + consult_overrides: bool, ) -> f64 { // Codegen NativeModuleRef fast path — can mint native-module-backed // values without a namespace object; the vtable must be live for the @@ -774,8 +826,10 @@ pub unsafe extern "C" fn js_native_module_property_by_name( // `vt_get_own_field`, which the generic object-by-name read path uses; the // codegen `NativeModuleRef` fast-path landed here without consulting the // side-table, so writes via `PutValueSet` didn't round-trip on reads. - if let Some(value) = native_namespace_prop_override_get(module_name, property_name) { - return value; + if consult_overrides { + if let Some(value) = native_namespace_prop_override_get(module_name, property_name) { + return value; + } } if module_name == "process.namespace" && property_name == "default" { return cjs_default_export_value("process") @@ -906,6 +960,90 @@ pub unsafe extern "C" fn js_native_module_property_by_name( f64::from_bits(crate::value::TAG_UNDEFINED) } +fn native_module_string_arg(value: f64) -> Option { + let value = JSValue::from_bits(value.to_bits()); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let bytes = unsafe { crate::string::js_string_key_bytes(value, &mut sso) }?; + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +/// Snapshot-backed value used for named ESM imports from builtins. CommonJS +/// namespace writes stay isolated until `syncBuiltinESMExports()` copies them. +#[no_mangle] +pub extern "C" fn js_native_module_esm_export_value(module: f64, property: f64) -> f64 { + let Some(module) = native_module_string_arg(module) else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + let Some(property) = native_module_string_arg(property) else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + let module = normalize_native_module_alias(&module).to_string(); + let key = format!("{module}\0{property}"); + if let Some(bits) = NATIVE_ESM_EXPORT_VALUES.with(|values| values.borrow().get(&key).copied()) { + return f64::from_bits(bits); + } + let value = unsafe { + native_module_property_by_name_impl( + module.as_ptr(), + module.len(), + property.as_ptr(), + property.len(), + false, + ) + }; + if value.to_bits() == crate::value::TAG_UNDEFINED { + return value; + } + NATIVE_ESM_EXPORT_VALUES.with(|values| { + values.borrow_mut().insert(key, value.to_bits()); + }); + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + value +} + +pub(crate) fn module_constructor_identity_value() -> f64 { + const KEY: &str = "module\0Module"; + if let Some(bits) = NATIVE_ESM_EXPORT_VALUES.with(|values| values.borrow().get(KEY).copied()) { + return f64::from_bits(bits); + } + if let Some(bits) = NATIVE_CALLABLE_EXPORTS.with(|values| values.borrow().get(KEY).copied()) { + return f64::from_bits(bits); + } + bound_native_callable_export_value("module", "Module") +} + +#[no_mangle] +pub extern "C" fn js_module_sync_builtin_esm_exports() -> f64 { + let keys = + NATIVE_ESM_EXPORT_VALUES.with(|values| values.borrow().keys().cloned().collect::>()); + for key in keys { + let Some((module, property)) = key.split_once('\0') else { + continue; + }; + let value = unsafe { + native_module_property_by_name_impl( + module.as_ptr(), + module.len(), + property.as_ptr(), + property.len(), + true, + ) + }; + NATIVE_ESM_EXPORT_VALUES.with(|values| { + values.borrow_mut().insert(key.clone(), value.to_bits()); + }); + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + } + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +#[no_mangle] +pub extern "C" fn js_module_run_main() -> f64 { + // Perry's AOT entry point has already run before JavaScript can call this + // compatibility export, so there is no unevaluated main module to dispatch. + f64::from_bits(crate::value::TAG_UNDEFINED) +} + /// Access a property on a native module namespace object. /// For method references (e.g., `fs.existsSync`), creates a bound method closure. /// For constant properties (e.g., `path.sep`, `fs.constants`), returns the value directly. diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index 8f3d587a4a..b0c47aa871 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -235,7 +235,8 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option Some(0), ("perf_hooks", "PerformanceResourceTiming") => Some(0), // #3119/#3126/#3263 node:module helpers. - ("module", "createRequire") => Some(1), + ("module", "createRequire" | "SourceMap") => Some(1), + ("module", "findPackageJSON" | "findSourceMap") => Some(1), ("module", "Module") => Some(0), ("module", "enableCompileCache") => Some(1), ("module", "flushCompileCache") => Some(0), @@ -451,6 +452,7 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ "module", &[ ("Module", 0), + ("SourceMap", 1), ("_findPath", 3), ("_initPaths", 0), ("_load", 3), @@ -460,6 +462,8 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("_resolveLookupPaths", 2), ("createRequire", 1), ("enableCompileCache", 1), + ("findPackageJSON", 1), + ("findSourceMap", 1), ("flushCompileCache", 0), ("getCompileCacheDir", 0), ("getSourceMapsSupport", 0), diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index aad2f4e9ee..78cf6a13e0 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -1,6 +1,12 @@ use super::callable_export_arity_table::native_callable_export_arity; use super::*; -use std::cell::Cell; +mod module_cjs; +use module_cjs::attach_module_cjs_constructor_statics; +pub(crate) use module_cjs::{ + module_builtin_modules_value, module_cjs_cache_value, module_cjs_extensions_value, + module_cjs_global_paths_value, module_cjs_path_cache_value, module_cjs_prototype_for_instance, + module_constants_value, +}; pub(crate) fn bound_native_callable_export_value(module_name: &str, property_name: &str) -> f64 { // Bound-native closures carry (module, method) metadata that the @@ -29,49 +35,90 @@ pub(crate) fn bound_native_callable_export_value(module_name: &str, property_nam } else { export_module_name }; + // Direct named/default `node:module` imports can materialize the callable + // without ever constructing a namespace object. Install its registry row + // here as well as at codegen import sites so Module's one canonical + // closure always receives the prototype/statics attachment. + if callable_module_name == "module" { + super::super::native_module_registry::js_nm_install_module(); + } let key = format!("{callable_module_name}\0{property_name}"); if let Some(bits) = NATIVE_CALLABLE_EXPORTS.with(|c| c.borrow().get(&key).copied()) { return f64::from_bits(bits); } let method_bytes: &'static [u8] = property_name.as_bytes().to_vec().leak(); - let ns = js_create_native_module_namespace( + let scope = crate::gc::RuntimeHandleScope::new(); + let ns = scope.root_nanbox_f64(js_create_native_module_namespace( callable_module_name.as_ptr(), callable_module_name.len(), - ); + )); let closure = crate::closure::js_closure_alloc(crate::closure::BOUND_METHOD_FUNC_PTR, 3); - crate::closure::js_closure_set_capture_f64(closure, 0, ns); - crate::closure::js_closure_set_capture_ptr(closure, 1, method_bytes.as_ptr() as i64); - crate::closure::js_closure_set_capture_ptr(closure, 2, method_bytes.len() as i64); + let closure = scope.root_raw_mut_ptr(closure); + crate::closure::js_closure_set_capture_f64(closure.get_raw_mut_ptr(), 0, ns.get_nanbox_f64()); + crate::closure::js_closure_set_capture_ptr( + closure.get_raw_mut_ptr(), + 1, + method_bytes.as_ptr() as i64, + ); + crate::closure::js_closure_set_capture_ptr( + closure.get_raw_mut_ptr(), + 2, + method_bytes.len() as i64, + ); let exposed_name = if export_module_name == "fs" { native_callable_export_display_name(export_module_name, property_name) } else if export_module_name == "url" && property_name == "resolveObject" { "urlResolveObject" } else if export_module_name == "http" && property_name == "_connectionListener" { "connectionListener" + } else if export_module_name == "module" && property_name == "runMain" { + "executeUserEntryPoint" } else if export_module_name == "fs" && property_name == "_toUnixTimestamp" { "toUnixTimestamp" } else { property_name }; - set_bound_native_closure_name(closure, exposed_name); + set_bound_native_closure_name(closure.get_raw_mut_ptr(), exposed_name); if let Some(length) = native_callable_export_arity(export_module_name, property_name) { - set_builtin_closure_length(closure as usize, length); + set_builtin_closure_length( + closure.get_raw_mut_ptr::() as usize, + length, + ); } - let mut value = crate::value::js_nanbox_pointer(closure as i64); - let closure_addr = closure as usize; + let value = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + closure.get_raw_mut_ptr::() as i64, + )); // Per-module prototype/statics decoration, routed through the attach // registry (see `native_module_registry::nm_attach_lookup`): each // module's handler is registered by its `js_nm_install_()`, and // this path is only reachable through that module's namespace — so a // binary links exactly the attach machinery of the modules it imports. - if let Some(attach) = super::super::native_module_registry::nm_attach_lookup(export_module_name) + if export_module_name == "module" && property_name == "SourceMap" { + // A named import can materialize this callable without first lowering + // a namespace expression (and therefore before `js_nm_install_module` + // registers the optional attach handler). SourceMap's prototype is + // intrinsic constructor state, so attach it at the common callable + // creation seam instead of relying on that optional registry. + crate::process::module_source_map_attach_constructor(crate::value::js_nanbox_get_pointer( + value.get_nanbox_f64(), + ) as usize); + } else if let Some(attach) = + super::super::native_module_registry::nm_attach_lookup(export_module_name) { // SAFETY: registry only ever holds the `nm_attach_*` handlers below. - value = unsafe { attach(property_name, value, closure_addr) }; + unsafe { + attach( + property_name, + value.get_nanbox_f64(), + crate::value::js_nanbox_get_pointer(value.get_nanbox_f64()) as usize, + ) + }; } + let value = value.get_nanbox_f64(); + NATIVE_CALLABLE_EXPORTS.with(|c| { c.borrow_mut().insert(key, value.to_bits()); crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); @@ -887,142 +934,14 @@ fn native_object_value(obj: *mut ObjectHeader) -> f64 { } fn native_set_field(obj: *mut ObjectHeader, name: &str, value: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_nanbox_f64(native_object_value(obj)); + let value = scope.root_nanbox_f64(value); let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(obj, key, value); -} - -extern "C" fn module_cjs_extension_noop_thunk( - _closure: *const crate::closure::ClosureHeader, - _module: f64, - _filename: f64, -) -> f64 { - f64::from_bits(crate::value::TAG_UNDEFINED) -} - -fn module_cjs_extension_function(name: &str) -> f64 { - let func_ptr = module_cjs_extension_noop_thunk as *const u8; - crate::closure::js_register_closure_arity(func_ptr, 2); - crate::closure::js_register_closure_length(func_ptr, 2); - let closure = crate::closure::js_closure_alloc(func_ptr, 0); - set_bound_native_closure_name(closure, name); - crate::object::set_builtin_closure_length(closure as usize, 2); - crate::value::js_nanbox_pointer(closure as i64) -} - -fn store_module_cjs_root(slot: &Cell, value: f64) -> f64 { - slot.set(value.to_bits()); - crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); - value -} - -pub(crate) fn module_cjs_cache_value() -> f64 { - MODULE_CJS_CACHE_VALUE.with(|slot| { - let bits = slot.get(); - if bits != 0 { - return f64::from_bits(bits); - } - let obj = crate::object::js_object_alloc_null_proto(0, 0); - store_module_cjs_root(slot, native_object_value(obj)) - }) -} - -pub(crate) fn module_cjs_path_cache_value() -> f64 { - MODULE_CJS_PATH_CACHE_VALUE.with(|slot| { - let bits = slot.get(); - if bits != 0 { - return f64::from_bits(bits); - } - let obj = crate::object::js_object_alloc_null_proto(0, 0); - store_module_cjs_root(slot, native_object_value(obj)) - }) -} - -pub(crate) fn module_cjs_extensions_value() -> f64 { - MODULE_CJS_EXTENSIONS_VALUE.with(|slot| { - let bits = slot.get(); - if bits != 0 { - return f64::from_bits(bits); - } - let obj = js_object_alloc(0, 3); - native_set_field(obj, ".js", module_cjs_extension_function(".js")); - native_set_field(obj, ".json", module_cjs_extension_function(".json")); - native_set_field(obj, ".node", module_cjs_extension_function(".node")); - store_module_cjs_root(slot, native_object_value(obj)) - }) -} - -pub(crate) fn module_cjs_global_paths_value() -> f64 { - MODULE_CJS_GLOBAL_PATHS_VALUE.with(|slot| { - let bits = slot.get(); - if bits != 0 { - return f64::from_bits(bits); - } - - let mut paths = Vec::new(); - if let Some(home) = std::env::var_os("HOME") { - let home = std::path::PathBuf::from(home); - paths.push(home.join(".node_modules").to_string_lossy().into_owned()); - paths.push(home.join(".node_libraries").to_string_lossy().into_owned()); - } - let prefix = std::env::var("PREFIX").unwrap_or_else(|_| "/usr/local".to_string()); - paths.push(format!("{prefix}/lib/node")); - - let arr = crate::array::js_array_alloc_with_length(paths.len() as u32); - for (i, path) in paths.iter().enumerate() { - crate::array::js_array_set_f64(arr, i as u32, native_string_value(path)); - } - store_module_cjs_root(slot, f64::from_bits(JSValue::array_ptr(arr).bits())) - }) -} - -fn attach_module_cjs_constructor_statics(closure_addr: usize) { - crate::closure::closure_set_dynamic_prop(closure_addr, "_cache", module_cjs_cache_value()); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "_extensions", - module_cjs_extensions_value(), - ); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "_pathCache", - module_cjs_path_cache_value(), - ); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "globalPaths", - module_cjs_global_paths_value(), - ); - for name in [ - "_findPath", - "_initPaths", - "_load", - "_nodeModulePaths", - "_preloadModules", - "_resolveFilename", - "_resolveLookupPaths", - ] { - crate::closure::closure_set_dynamic_prop( - closure_addr, - name, - bound_native_callable_export_value("module", name), - ); - } - // `Module.prototype` — Node's require-hook pattern (Next.js): - // `const mod = require('module'); const orig = mod.prototype.require; - // mod.prototype.require = function(request) {…}`. Expose a plain object - // carrying a `require` method so the read+patch round-trips; the patch - // is inert under AOT compilation (Perry resolves modules at compile - // time), but startup must not throw on the access. - let proto = js_object_alloc(0, 1); - native_set_field( - proto, - "require", - bound_native_callable_export_value("module", "_load"), - ); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "prototype", - crate::value::js_nanbox_pointer(proto as i64), + js_object_set_field_by_name( + crate::value::js_nanbox_get_pointer(obj.get_nanbox_f64()) as *mut ObjectHeader, + key, + value.get_nanbox_f64(), ); } @@ -1503,10 +1422,13 @@ pub(crate) fn builtin_closure_is_non_constructable_value(value: f64) -> bool { pub(crate) unsafe fn nm_attach_module( property_name: &str, mut value: f64, - closure_addr: usize, + _closure_addr: usize, ) -> f64 { if property_name == "Module" { - attach_module_cjs_constructor_statics(closure_addr); + value = attach_module_cjs_constructor_statics(value); + } + if matches!(property_name, "flushCompileCache" | "isBuiltin") { + set_builtin_closure_non_constructable(crate::value::js_nanbox_get_pointer(value) as usize); } value } diff --git a/crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs b/crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs new file mode 100644 index 0000000000..f435e4dfc7 --- /dev/null +++ b/crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs @@ -0,0 +1,501 @@ +use super::*; +use std::cell::Cell; + +extern "C" fn module_cjs_extension_noop_thunk( + _closure: *const crate::closure::ClosureHeader, + _module: f64, + _filename: f64, +) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn module_cjs_extension_function(name: &str) -> f64 { + let func_ptr = module_cjs_extension_noop_thunk as *const u8; + crate::closure::js_register_closure_arity(func_ptr, 2); + crate::closure::js_register_closure_length(func_ptr, 2); + let closure = crate::closure::js_closure_alloc(func_ptr, 0); + let scope = crate::gc::RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(closure); + set_bound_native_closure_name(closure.get_raw_mut_ptr(), name); + crate::object::set_builtin_closure_length( + closure.get_raw_mut_ptr::() as usize, + 2, + ); + crate::value::js_nanbox_pointer( + closure.get_raw_mut_ptr::() as i64 + ) +} + +fn store_module_cjs_root(slot: &Cell, value: f64) -> f64 { + slot.set(value.to_bits()); + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + value +} + +pub(crate) fn module_cjs_cache_value() -> f64 { + MODULE_CJS_CACHE_VALUE.with(|slot| { + let bits = slot.get(); + if bits != 0 { + return f64::from_bits(bits); + } + let obj = crate::object::js_object_alloc_null_proto(0, 0); + store_module_cjs_root(slot, native_object_value(obj)) + }) +} + +pub(crate) fn module_cjs_path_cache_value() -> f64 { + MODULE_CJS_PATH_CACHE_VALUE.with(|slot| { + let bits = slot.get(); + if bits != 0 { + return f64::from_bits(bits); + } + let obj = crate::object::js_object_alloc_null_proto(0, 0); + store_module_cjs_root(slot, native_object_value(obj)) + }) +} + +pub(crate) fn module_cjs_extensions_value() -> f64 { + MODULE_CJS_EXTENSIONS_VALUE.with(|slot| { + let bits = slot.get(); + if bits != 0 { + return f64::from_bits(bits); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_nanbox_f64(native_object_value(js_object_alloc(0, 3))); + store_module_cjs_root(slot, obj.get_nanbox_f64()); + for name in [".js", ".json", ".node"] { + let value = scope.root_nanbox_f64(module_cjs_extension_function(name)); + native_set_field( + crate::value::js_nanbox_get_pointer(obj.get_nanbox_f64()) as *mut ObjectHeader, + name, + value.get_nanbox_f64(), + ); + } + store_module_cjs_root(slot, obj.get_nanbox_f64()) + }) +} + +pub(crate) fn module_cjs_global_paths_value() -> f64 { + MODULE_CJS_GLOBAL_PATHS_VALUE.with(|slot| { + let bits = slot.get(); + if bits != 0 { + return f64::from_bits(bits); + } + + let mut paths = Vec::new(); + if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { + let home = std::path::PathBuf::from(home); + paths.push(home.join(".node_modules").to_string_lossy().into_owned()); + paths.push(home.join(".node_libraries").to_string_lossy().into_owned()); + } + let prefix = std::env::var_os("PREFIX") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::current_exe() + .ok() + .and_then(|path| path.parent()?.parent().map(std::path::Path::to_path_buf)) + }) + .unwrap_or_else(|| std::path::PathBuf::from("/usr/local")); + paths.push(prefix.join("lib/node").to_string_lossy().into_owned()); + + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = scope.root_nanbox_f64(f64::from_bits( + JSValue::array_ptr(crate::array::js_array_alloc_with_length(paths.len() as u32)).bits(), + )); + store_module_cjs_root(slot, arr.get_nanbox_f64()); + for (i, path) in paths.iter().enumerate() { + let value = scope.root_nanbox_f64(native_string_value(path)); + crate::array::js_array_set_f64( + JSValue::from_bits(arr.get_nanbox_f64().to_bits()) + .as_pointer::() as *mut _, + i as u32, + value.get_nanbox_f64(), + ); + } + store_module_cjs_root(slot, arr.get_nanbox_f64()) + }) +} + +pub(crate) fn module_builtin_modules_value() -> f64 { + MODULE_BUILTIN_MODULES_VALUE.with(|slot| { + let bits = slot.get(); + if bits != 0 { + return f64::from_bits(bits); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = scope.root_nanbox_f64(f64::from_bits( + JSValue::array_ptr(crate::array::js_array_alloc_with_length( + crate::process::MODULE_BUILTIN_MODULES.len() as u32, + )) + .bits(), + )); + store_module_cjs_root(slot, arr.get_nanbox_f64()); + for (i, name) in crate::process::MODULE_BUILTIN_MODULES.iter().enumerate() { + let value = scope.root_nanbox_f64(native_string_value(name)); + crate::array::js_array_set_f64( + JSValue::from_bits(arr.get_nanbox_f64().to_bits()) + .as_pointer::() as *mut _, + i as u32, + value.get_nanbox_f64(), + ); + } + let value = arr.get_nanbox_f64(); + crate::object::js_object_freeze(value); + store_module_cjs_root(slot, value) + }) +} + +pub(crate) fn module_constants_value() -> f64 { + MODULE_CONSTANTS_VALUE.with(|slot| { + let bits = slot.get(); + if bits != 0 { + return f64::from_bits(bits); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let constants = scope.root_nanbox_f64(native_object_value( + crate::object::js_object_alloc_null_proto(0, 1), + )); + store_module_cjs_root(slot, constants.get_nanbox_f64()); + let status = scope.root_nanbox_f64(native_object_value( + crate::object::js_object_alloc_null_proto(0, 4), + )); + for (name, value) in [ + ("FAILED", 0.0), + ("ENABLED", 1.0), + ("ALREADY_ENABLED", 2.0), + ("DISABLED", 3.0), + ] { + native_set_field( + crate::value::js_nanbox_get_pointer(status.get_nanbox_f64()) as *mut ObjectHeader, + name, + value, + ); + } + let status_value = status.get_nanbox_f64(); + crate::object::js_object_freeze(status_value); + native_set_field( + crate::value::js_nanbox_get_pointer(constants.get_nanbox_f64()) as *mut ObjectHeader, + "compileCacheStatus", + status_value, + ); + let value = constants.get_nanbox_f64(); + crate::object::js_object_freeze(value); + store_module_cjs_root(slot, value) + }) +} + +extern "C" fn module_wrap_thunk( + _closure: *const crate::closure::ClosureHeader, + source: f64, +) -> f64 { + let value = JSValue::from_bits(source.to_bits()); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let source = unsafe { crate::string::js_string_key_bytes(value, &mut sso) } + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + .unwrap_or_default(); + native_string_value(&format!( + "(function (exports, require, module, __filename, __dirname) {{ {source}\n}});" + )) +} + +fn module_wrap_value() -> f64 { + let func = module_wrap_thunk as *const u8; + crate::closure::js_register_closure_arity(func, 1); + crate::closure::js_register_closure_length(func, 1); + let closure = crate::closure::js_closure_alloc(func, 0); + let scope = crate::gc::RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(closure); + set_bound_native_closure_name(closure.get_raw_mut_ptr(), "wrap"); + set_builtin_closure_length( + closure.get_raw_mut_ptr::() as usize, + 1, + ); + crate::value::js_nanbox_pointer( + closure.get_raw_mut_ptr::() as i64 + ) +} + +fn module_wrapper_value() -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = scope.root_nanbox_f64(f64::from_bits( + JSValue::array_ptr(crate::array::js_array_alloc_with_length(2)).bits(), + )); + let prefix = scope.root_nanbox_f64(native_string_value( + "(function (exports, require, module, __filename, __dirname) { ", + )); + crate::array::js_array_set_f64( + JSValue::from_bits(arr.get_nanbox_f64().to_bits()).as_pointer::() + as *mut _, + 0, + prefix.get_nanbox_f64(), + ); + let suffix = scope.root_nanbox_f64(native_string_value("\n});")); + crate::array::js_array_set_f64( + JSValue::from_bits(arr.get_nanbox_f64().to_bits()).as_pointer::() + as *mut _, + 1, + suffix.get_nanbox_f64(), + ); + arr.get_nanbox_f64() +} + +extern "C" fn module_prototype_method_thunk( + _closure: *const crate::closure::ClosureHeader, + _a: f64, + _b: f64, + _c: f64, +) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +extern "C" fn module_prototype_load_thunk( + _closure: *const crate::closure::ClosureHeader, + filename: f64, + _b: f64, + _c: f64, +) -> f64 { + crate::process::js_module_instance_load(filename) +} + +extern "C" fn module_prototype_require_thunk( + _closure: *const crate::closure::ClosureHeader, + specifier: f64, + _b: f64, + _c: f64, +) -> f64 { + crate::process::js_module_instance_require(specifier) +} + +fn module_prototype_method(name: &str, length: u32) -> f64 { + let func = match name { + "load" => module_prototype_load_thunk as *const u8, + "require" => module_prototype_require_thunk as *const u8, + _ => module_prototype_method_thunk as *const u8, + }; + crate::closure::js_register_closure_arity(func, 3); + let closure = crate::closure::js_closure_alloc(func, 0); + let scope = crate::gc::RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(closure); + set_bound_native_closure_name(closure.get_raw_mut_ptr(), ""); + set_builtin_closure_length( + closure.get_raw_mut_ptr::() as usize, + length, + ); + crate::value::js_nanbox_pointer( + closure.get_raw_mut_ptr::() as i64 + ) +} + +extern "C" fn module_prototype_constructor_getter( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + // Resolve through the canonical callable cache at access time. Capturing + // the constructor while its own attach was still in progress preserved a + // pre-publication pointer; after moving GC, the named import and inherited + // getter could compare as different values even though instanceof used the + // same prototype. The cache is populated before user code can reach this + // getter, so this returns the exact exported Module identity. + bound_native_callable_export_value("module", "Module") +} + +extern "C" fn module_prototype_false_getter(_closure: *const crate::closure::ClosureHeader) -> f64 { + native_bool_value(false) +} + +extern "C" fn module_prototype_parent_getter( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +extern "C" fn module_prototype_parent_setter( + _closure: *const crate::closure::ClosureHeader, + _value: f64, +) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn module_accessor( + get_func: *const u8, + set_func: Option<*const u8>, + capture: Option, +) -> crate::object::AccessorDescriptor { + let scope = crate::gc::RuntimeHandleScope::new(); + let capture = capture.map(|value| scope.root_nanbox_f64(value)); + crate::closure::js_register_closure_arity(get_func, 0); + let getter = crate::closure::js_closure_alloc(get_func, capture.is_some() as u32); + let getter = scope.root_raw_mut_ptr(getter); + if let Some(value) = capture.as_ref() { + crate::closure::js_closure_set_capture_f64( + getter.get_raw_mut_ptr(), + 0, + value.get_nanbox_f64(), + ); + } + let setter = set_func.map(|func| { + crate::closure::js_register_closure_arity(func, 1); + scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(func, 0)) + }); + crate::object::AccessorDescriptor { + get: crate::value::js_nanbox_pointer( + getter.get_raw_mut_ptr::() as i64, + ) + .to_bits(), + set: setter + .map(|closure| { + crate::value::js_nanbox_pointer( + closure.get_raw_mut_ptr::() as i64, + ) + .to_bits() + }) + .unwrap_or(0), + } +} + +fn module_cjs_prototype_value(_module_value: f64) -> f64 { + MODULE_CJS_PROTOTYPE_VALUE.with(|slot| { + let bits = slot.get(); + if bits != 0 { + return f64::from_bits(bits); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let keys = b"_compile\0constructor\0isPreloading\0load\0parent\0require\0"; + let proto = scope.root_nanbox_f64(native_object_value( + crate::object::js_object_alloc_with_shape( + 0xC0_00_4E, + 6, + keys.as_ptr(), + keys.len() as u32, + ), + )); + // Publish before any nested closure/string allocation so the moving GC + // can update both the shared singleton and this local construction. + store_module_cjs_root(slot, proto.get_nanbox_f64()); + let undefined = JSValue::from_bits(crate::value::TAG_UNDEFINED); + for index in 0..6 { + crate::object::js_object_set_field( + crate::value::js_nanbox_get_pointer(proto.get_nanbox_f64()) as *mut ObjectHeader, + index, + undefined, + ); + } + for (index, name, length) in [(0, "_compile", 3), (3, "load", 1), (5, "require", 1)] { + let method = scope.root_nanbox_f64(module_prototype_method(name, length)); + let proto_ptr = + crate::value::js_nanbox_get_pointer(proto.get_nanbox_f64()) as *mut ObjectHeader; + crate::object::js_object_set_field( + proto_ptr, + index, + JSValue::from_bits(method.get_nanbox_f64().to_bits()), + ); + crate::object::set_property_attrs( + proto_ptr as usize, + name.to_string(), + crate::object::PropertyAttrs::new(true, true, true), + ); + } + let install_accessor = |name: &str, descriptor| { + let proto_ptr = crate::value::js_nanbox_get_pointer(proto.get_nanbox_f64()) as usize; + crate::object::set_accessor_descriptor(proto_ptr, name.to_string(), descriptor); + crate::object::set_property_attrs( + proto_ptr, + name.to_string(), + crate::object::PropertyAttrs::new(false, false, false), + ); + }; + install_accessor( + "constructor", + module_accessor(module_prototype_constructor_getter as *const u8, None, None), + ); + install_accessor( + "isPreloading", + module_accessor(module_prototype_false_getter as *const u8, None, None), + ); + install_accessor( + "parent", + module_accessor( + module_prototype_parent_getter as *const u8, + Some(module_prototype_parent_setter as *const u8), + None, + ), + ); + store_module_cjs_root(slot, proto.get_nanbox_f64()) + }) +} + +fn current_module_cjs_prototype_value() -> Option { + MODULE_CJS_PROTOTYPE_VALUE.with(|slot| (slot.get() != 0).then(|| f64::from_bits(slot.get()))) +} + +pub(crate) fn module_cjs_prototype_for_instance() -> f64 { + if let Some(prototype) = current_module_cjs_prototype_value() { + return prototype; + } + let module = bound_native_callable_export_value("module", "Module"); + current_module_cjs_prototype_value().unwrap_or_else(|| module_cjs_prototype_value(module)) +} + +pub(super) fn attach_module_cjs_constructor_statics(module_value: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let module = scope.root_nanbox_f64(module_value); + macro_rules! set_static { + ($name:expr, $value:expr) => {{ + let property = scope.root_nanbox_f64($value); + crate::closure::closure_set_dynamic_prop( + crate::value::js_nanbox_get_pointer(module.get_nanbox_f64()) as usize, + $name, + property.get_nanbox_f64(), + ); + }}; + } + set_static!("Module", module.get_nanbox_f64()); + set_static!("_cache", module_cjs_cache_value()); + set_static!("_extensions", module_cjs_extensions_value()); + set_static!("_pathCache", module_cjs_path_cache_value()); + set_static!("globalPaths", module_cjs_global_paths_value()); + set_static!("builtinModules", module_builtin_modules_value()); + set_static!("constants", module_constants_value()); + for name in [ + "SourceMap", + "_findPath", + "_initPaths", + "_load", + "_nodeModulePaths", + "_preloadModules", + "_resolveFilename", + "_resolveLookupPaths", + "createRequire", + "enableCompileCache", + "findPackageJSON", + "findSourceMap", + "flushCompileCache", + "getCompileCacheDir", + "getSourceMapsSupport", + "isBuiltin", + "register", + "registerHooks", + "runMain", + "setSourceMapsSupport", + "stripTypeScriptTypes", + "syncBuiltinESMExports", + ] { + set_static!(name, bound_native_callable_export_value("module", name)); + } + set_static!("wrap", module_wrap_value()); + set_static!("wrapper", module_wrapper_value()); + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + set_static!("_readPackage", undefined); + set_static!("_stat", undefined); + for name in ["wrap", "wrapper"] { + crate::object::set_property_attrs( + crate::value::js_nanbox_get_pointer(module.get_nanbox_f64()) as usize, + name.to_string(), + crate::object::PropertyAttrs::new(false, false, false), + ); + } + set_static!( + "prototype", + module_cjs_prototype_value(module.get_nanbox_f64()) + ); + module.get_nanbox_f64() +} diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index f258f3a072..40ff8f4c18 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1550,6 +1550,39 @@ const SEA_DEFAULT_KEYS: &[&[u8]] = &[ b"getAssetKeys", ]; +const MODULE_NAMESPACE_KEYS: &[&[u8]] = &[ + b"Module", + b"SourceMap", + b"_cache", + b"_extensions", + b"_findPath", + b"_initPaths", + b"_load", + b"_nodeModulePaths", + b"_pathCache", + b"_preloadModules", + b"_resolveFilename", + b"_resolveLookupPaths", + b"builtinModules", + b"constants", + b"createRequire", + b"default", + b"enableCompileCache", + b"findPackageJSON", + b"findSourceMap", + b"flushCompileCache", + b"getCompileCacheDir", + b"getSourceMapsSupport", + b"globalPaths", + b"isBuiltin", + b"register", + b"registerHooks", + b"runMain", + b"setSourceMapsSupport", + b"stripTypeScriptTypes", + b"syncBuiltinESMExports", +]; + pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'static [&'static [u8]]> { let module_name = normalize_native_module_alias(module_name); match module_name { @@ -1612,6 +1645,7 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati ]), "sea" => Some(SEA_NAMESPACE_KEYS), "sea.default" => Some(SEA_DEFAULT_KEYS), + "module" => Some(MODULE_NAMESPACE_KEYS), "domain" => Some(&[b"_stack", b"Domain", b"createDomain", b"create", b"active"]), // #3677: zlib.constants enumerates the full Z_*/BROTLI_*/ZSTD_* table. "zlib.constants" => Some(ZLIB_CONSTANTS_KEYS), diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs index 681d1c45b9..8f34f4bad2 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs @@ -44,8 +44,10 @@ pub(crate) unsafe fn nm_dispatch_module(ctx: &NmCtx, module_name: &str, method_n ("module", "flushCompileCache") => crate::process::js_module_flush_compile_cache(), ("module", "getCompileCacheDir") => crate::process::js_module_get_compile_cache_dir(), ("module", "getSourceMapsSupport") => crate::process::js_module_get_source_maps_support(), + ("module", "findSourceMap") => crate::process::js_module_find_source_map(arg(0)), ("module", "isBuiltin") => crate::process::js_module_is_builtin(arg(0)), - ("module", "Module") => crate::process::js_module_module_new(arg(0)), + ("module", "Module") => crate::process::js_module_module_new(arg(0), arg(1)), + ("module", "SourceMap") => crate::process::js_module_source_map_new(arg(0), arg(1)), ("module", "_findPath") => crate::process::js_module_find_path(arg(0), arg(1), arg(2)), ("module", "_initPaths") => crate::process::js_module_init_paths(), ("module", "_load") => crate::process::js_module_load(arg(0), arg(1), arg(2)), @@ -59,12 +61,14 @@ pub(crate) unsafe fn nm_dispatch_module(ctx: &NmCtx, module_name: &str, method_n } ("module", "register") => crate::process::js_module_register(arg(0), arg(1), arg(2)), ("module", "registerHooks") => crate::process::js_module_register_hooks(arg(0)), + ("module", "runMain") => crate::object::js_module_run_main(), ("module", "setSourceMapsSupport") => { crate::process::js_module_set_source_maps_support(arg(0), arg(1)) } ("module", "stripTypeScriptTypes") => { crate::process::js_module_strip_typescript_types(arg(0), arg(1)) } + ("module", "syncBuiltinESMExports") => crate::object::js_module_sync_builtin_esm_exports(), _ => f64::from_bits(JSValue::undefined().bits()), } } diff --git a/crates/perry-runtime/src/object/to_string_tag.rs b/crates/perry-runtime/src/object/to_string_tag.rs index dae554ff02..107240705e 100644 --- a/crates/perry-runtime/src/object/to_string_tag.rs +++ b/crates/perry-runtime/src/object/to_string_tag.rs @@ -378,6 +378,8 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 { tag_str = Some("DecompressionStream".to_string()); } else if class_id == crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID { tag_str = Some("RegExp String Iterator".to_string()); + } else if class_id == crate::object::namespace_create::MODULE_NAMESPACE_CLASS_ID { + tag_str = Some("Module".to_string()); } if let Some(func_ptr) = lookup_to_string_tag_hook(class_id) { let getter: extern "C" fn(f64) -> f64 = std::mem::transmute(func_ptr as *const u8); @@ -429,6 +431,7 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 { /// caller's `None` arm. fn native_module_to_string_tag(module: &str) -> Option<&'static str> { match module { + "module" => Some("Module"), // `Object.prototype.toString.call(performance)` is // "[object Performance]" in Node. "perf_hooks" => Some("Performance"), diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index aca9f59613..6431b782a3 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -66,20 +66,22 @@ pub use finalization::{ pub(crate) use permission::{process_permission_enabled, scan_permission_cache_roots_mut}; // ── report re-exports ─────────────────────────────────────────────────────── +pub(crate) use node_module::js_module_instance_require; pub(crate) use report::scan_report_cache_roots_mut; // ── node_module re-exports ────────────────────────────────────────────────── pub use node_module::{ js_module_builtin_modules, js_module_constants, js_module_dynamic_import_apply_hooks, js_module_enable_compile_cache, js_module_find_package_json, js_module_find_path, - js_module_flush_compile_cache, js_module_get_compile_cache_dir, - js_module_get_source_maps_support, js_module_init_paths, js_module_is_builtin, js_module_load, - js_module_module_new, js_module_node_module_paths, js_module_preload_modules, - js_module_register, js_module_register_hooks, js_module_resolve_filename, - js_module_resolve_lookup_paths, js_module_set_source_maps_support, js_module_source_map_new, - js_module_strip_typescript_types, js_process_get_builtin_module, + js_module_find_source_map, js_module_flush_compile_cache, js_module_get_compile_cache_dir, + js_module_get_source_maps_support, js_module_init_paths, js_module_instance_load, + js_module_is_builtin, js_module_load, js_module_module_new, js_module_node_module_paths, + js_module_preload_modules, js_module_register, js_module_register_hooks, + js_module_resolve_filename, js_module_resolve_lookup_paths, js_module_set_source_maps_support, + js_module_source_map_new, js_module_strip_typescript_types, js_process_get_builtin_module, js_process_get_builtin_module_devirt, js_process_set_source_maps_enabled, - js_process_source_maps_enabled, scan_process_module_loader_roots_mut, + js_process_source_maps_enabled, module_source_map_attach_constructor, + scan_process_module_loader_roots_mut, }; // ───────────────────────────────────────────────────────────────────────────── @@ -184,6 +186,8 @@ pub(crate) fn builtin_module_value(module_name: &str) -> f64 { crate::object::native_module_get_builtin_module_value(module_name) } +pub(crate) const MODULE_CJS_CLASS_ID: u32 = 0xC0_00_4D; + pub(crate) const MODULE_BUILTIN_MODULES: &[&str] = &[ "_http_agent", "_http_client", @@ -191,12 +195,6 @@ pub(crate) const MODULE_BUILTIN_MODULES: &[&str] = &[ "_http_incoming", "_http_outgoing", "_http_server", - "_stream_duplex", - "_stream_passthrough", - "_stream_readable", - "_stream_transform", - "_stream_wrap", - "_stream_writable", "_tls_common", "_tls_wrap", "assert", @@ -223,10 +221,6 @@ pub(crate) const MODULE_BUILTIN_MODULES: &[&str] = &[ "inspector/promises", "module", "net", - "node:sea", - "node:sqlite", - "node:test", - "node:test/reporters", "os", "path", "path/posix", @@ -257,6 +251,10 @@ pub(crate) const MODULE_BUILTIN_MODULES: &[&str] = &[ "wasi", "worker_threads", "zlib", + "node:sea", + "node:sqlite", + "node:test", + "node:test/reporters", ]; pub(crate) fn module_string_value(value: &str) -> f64 { @@ -269,8 +267,11 @@ pub(crate) fn module_object_value(obj: *mut crate::object::ObjectHeader) -> f64 } pub(crate) fn module_set_field(obj: *mut crate::object::ObjectHeader, name: &str, value: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let value = scope.root_nanbox_f64(value); let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - crate::object::js_object_set_field_by_name(obj, key, value); + crate::object::js_object_set_field_by_name(obj.get_raw_mut_ptr(), key, value.get_nanbox_f64()); } pub(crate) type ModuleFunction1 = extern "C" fn(*const crate::closure::ClosureHeader, f64) -> f64; @@ -330,9 +331,16 @@ pub(crate) fn module_function1(name: &str, thunk: ModuleFunction1, length: u32) crate::closure::js_register_closure_arity(func_ptr, 1); crate::closure::js_register_closure_length(func_ptr, length); let closure = crate::closure::js_closure_alloc(func_ptr, 0); - crate::object::set_bound_native_closure_name(closure, name); - crate::object::set_builtin_closure_length(closure as usize, length); - crate::value::js_nanbox_pointer(closure as i64) + let scope = crate::gc::RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(closure); + crate::object::set_bound_native_closure_name(closure.get_raw_mut_ptr(), name); + crate::object::set_builtin_closure_length( + closure.get_raw_mut_ptr::() as usize, + length, + ); + crate::value::js_nanbox_pointer( + closure.get_raw_mut_ptr::() as i64 + ) } pub(crate) fn module_function2(name: &str, thunk: ModuleFunction2, length: u32) -> f64 { diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index 339afd5b60..9f3f20c92e 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -966,7 +966,7 @@ static KEEP_JS_REMOVEENV: extern "C" fn(*const StringHeader) = js_removeenv; // so pin a retained reference edge for the auto-optimize whole-program build. #[cfg(feature = "keepalive-anchors")] #[used] -static KEEP_JS_MODULE_FIND_PACKAGE_JSON: extern "C" fn(f64, f64) -> f64 = +static KEEP_JS_MODULE_FIND_PACKAGE_JSON: extern "C" fn(f64, f64, f64) -> f64 = js_module_find_package_json; // node:module helper-state APIs are codegen-emitted from generated `.o`, so pin // retained reference edges for the auto-optimize whole-program build. @@ -1005,7 +1005,7 @@ static KEEP_JS_MODULE_DYNAMIC_IMPORT_APPLY_HOOKS: extern "C" fn(f64) -> f64 = js_module_dynamic_import_apply_hooks; #[cfg(feature = "keepalive-anchors")] #[used] -static KEEP_JS_MODULE_MODULE_NEW: extern "C" fn(f64) -> f64 = js_module_module_new; +static KEEP_JS_MODULE_MODULE_NEW: extern "C" fn(f64, f64) -> f64 = js_module_module_new; #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_JS_MODULE_FIND_PATH: extern "C" fn(f64, f64, f64) -> f64 = js_module_find_path; diff --git a/crates/perry-runtime/src/process/node_module.rs b/crates/perry-runtime/src/process/node_module.rs index b9225b5cac..da191bb17d 100644 --- a/crates/perry-runtime/src/process/node_module.rs +++ b/crates/perry-runtime/src/process/node_module.rs @@ -6,14 +6,17 @@ //! move — no behavior change. use super::*; -use crate::closure::{ - js_closure_alloc, js_closure_get_capture_f64, js_closure_set_capture_f64, ClosureHeader, -}; +use crate::closure::{js_closure_get_capture_f64, js_closure_set_capture_f64}; use crate::string::js_string_from_bytes; use crate::value::JSValue; use std::cell::Cell; use std::sync::atomic::Ordering; +mod source_map; +pub use source_map::{ + js_module_find_source_map, js_module_source_map_new, module_source_map_attach_constructor, +}; + pub fn scan_process_module_loader_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { MODULE_LOADER_HOOKS.with(|hooks| { for entry in hooks.borrow_mut().iter_mut() { @@ -33,6 +36,7 @@ pub fn scan_process_module_loader_roots_mut(visitor: &mut crate::gc::RuntimeRoot cell.set(callback); } }); + source_map::scan_roots(visitor); } /// `module.builtinModules` — Node exposes this as an Array of builtin module @@ -40,11 +44,7 @@ pub fn scan_process_module_loader_roots_mut(visitor: &mut crate::gc::RuntimeRoot /// shape should still match Node's module API. #[no_mangle] pub extern "C" fn js_module_builtin_modules() -> f64 { - let arr = crate::array::js_array_alloc_with_length(MODULE_BUILTIN_MODULES.len() as u32); - for (i, name) in MODULE_BUILTIN_MODULES.iter().enumerate() { - crate::array::js_array_set_f64(arr, i as u32, module_string_value(name)); - } - f64::from_bits(JSValue::array_ptr(arr).bits()) + crate::object::module_builtin_modules_value() } /// Minimal `module.constants` shape. The compile-cache status values are not @@ -52,25 +52,40 @@ pub extern "C" fn js_module_builtin_modules() -> f64 { /// stable process state for feature detection. #[no_mangle] pub extern "C" fn js_module_constants() -> f64 { - let constants = crate::object::js_object_alloc(0, 1); - let compile_cache_status = crate::object::js_object_alloc(0, 4); - module_set_field(compile_cache_status, "FAILED", 0.0); - module_set_field(compile_cache_status, "ENABLED", 1.0); - module_set_field(compile_cache_status, "ALREADY_ENABLED", 2.0); - module_set_field(compile_cache_status, "DISABLED", 3.0); - module_set_field( - constants, - "compileCacheStatus", - module_object_value(compile_cache_status), - ); - module_object_value(constants) + crate::object::module_constants_value() } extern "C" fn module_require_thunk( _closure: *const crate::closure::ClosureHeader, - _specifier: f64, + specifier: f64, ) -> f64 { - f64::from_bits(crate::value::TAG_UNDEFINED) + js_module_instance_require(specifier) +} + +pub(crate) fn js_module_instance_require(specifier: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(crate::object::js_implicit_this_get()); + let specifier = scope.root_nanbox_f64(specifier); + let Some(_) = module_object_ptr(receiver.get_nanbox_f64()) else { + module_throw_plain_type_error("Module.prototype.require called on incompatible receiver"); + }; + let filename = module_get_named_field( + module_object_ptr(receiver.get_nanbox_f64()).unwrap(), + "filename", + ); + let filename = scope.root_nanbox_f64(if module_value_to_string(filename).is_some() { + filename + } else { + module_get_named_field(module_object_ptr(receiver.get_nanbox_f64()).unwrap(), "id") + }); + let require = scope.root_nanbox_f64(crate::module_require::js_module_create_require( + filename.get_nanbox_f64(), + )); + crate::closure::js_closure_call1( + crate::value::js_nanbox_get_pointer(require.get_nanbox_f64()) + as *const crate::closure::ClosureHeader, + specifier.get_nanbox_f64(), + ) } fn module_null() -> f64 { @@ -81,46 +96,95 @@ fn module_null() -> f64 { /// execute CJS modules through this object yet; this mirrors Node's observable /// constructor fields and leaves loading to the resolver helpers below. #[no_mangle] -pub extern "C" fn js_module_module_new(id: f64) -> f64 { +pub extern "C" fn js_module_module_new(id: f64, parent: f64) -> f64 { let id_string = module_value_to_string(id).unwrap_or_default(); + let scope = crate::gc::RuntimeHandleScope::new(); + let parent = scope.root_nanbox_f64(parent); let keys = b"id\0path\0exports\0filename\0loaded\0children\0parent\0require\0"; - let obj = - crate::object::js_object_alloc_with_shape(0xC0_00_4D, 8, keys.as_ptr(), keys.len() as u32); - let exports = crate::object::js_object_alloc(0, 0); - let children = crate::array::js_array_alloc_with_length(0); + let obj = scope.root_nanbox_f64(module_object_value( + crate::object::js_object_alloc_with_shape( + super::MODULE_CJS_CLASS_ID, + 8, + keys.as_ptr(), + keys.len() as u32, + ), + )); + unsafe { + (*(crate::value::js_nanbox_get_pointer(obj.get_nanbox_f64()) + as *mut crate::object::ObjectHeader)) + .class_id = super::MODULE_CJS_CLASS_ID; + } + let exports = scope.root_nanbox_f64(module_object_value(crate::object::js_object_alloc(0, 0))); + let children = scope.root_nanbox_f64(f64::from_bits( + JSValue::array_ptr(crate::array::js_array_alloc_with_length(0)).bits(), + )); + let obj_ptr = || { + crate::value::js_nanbox_get_pointer(obj.get_nanbox_f64()) + as *mut crate::object::ObjectHeader + }; + let id = scope.root_nanbox_f64(module_string_value(&id_string)); crate::object::js_object_set_field( - obj, + obj_ptr(), 0, - JSValue::from_bits(module_string_value(&id_string).to_bits()), + JSValue::from_bits(id.get_nanbox_f64().to_bits()), ); + let path = scope.root_nanbox_f64(module_string_value(&module_cjs_dirname(&id_string))); crate::object::js_object_set_field( - obj, + obj_ptr(), 1, - JSValue::from_bits(module_string_value(&module_cjs_dirname(&id_string)).to_bits()), + JSValue::from_bits(path.get_nanbox_f64().to_bits()), ); crate::object::js_object_set_field( - obj, + obj_ptr(), 2, - JSValue::from_bits(module_object_value(exports).to_bits()), + JSValue::from_bits(exports.get_nanbox_f64().to_bits()), ); - crate::object::js_object_set_field(obj, 3, JSValue::from_bits(module_null().to_bits())); + crate::object::js_object_set_field(obj_ptr(), 3, JSValue::from_bits(module_null().to_bits())); crate::object::js_object_set_field( - obj, + obj_ptr(), 4, JSValue::from_bits(module_bool_value(false).to_bits()), ); crate::object::js_object_set_field( - obj, + obj_ptr(), 5, - JSValue::from_bits(JSValue::array_ptr(children).bits()), + JSValue::from_bits(children.get_nanbox_f64().to_bits()), ); - crate::object::js_object_set_field(obj, 6, JSValue::from_bits(module_null().to_bits())); + let has_parent = module_object_ptr(parent.get_nanbox_f64()).is_some(); crate::object::js_object_set_field( - obj, + obj_ptr(), + 6, + JSValue::from_bits(if has_parent { + parent.get_nanbox_f64().to_bits() + } else { + module_null().to_bits() + }), + ); + let require = scope.root_nanbox_f64(module_function1("require", module_require_thunk, 1)); + crate::object::js_object_set_field( + obj_ptr(), 7, - JSValue::from_bits(module_function1("require", module_require_thunk, 1).to_bits()), + JSValue::from_bits(require.get_nanbox_f64().to_bits()), ); - module_object_value(obj) + let value = obj.get_nanbox_f64(); + if has_parent { + let parent_obj = module_object_ptr(parent.get_nanbox_f64()).unwrap() as *mut _; + let parent_children = crate::object::js_object_get_field(parent_obj, 5); + if parent_children.is_pointer() { + let children = parent_children.as_pointer::() as *mut _; + let children = crate::array::js_array_push_f64(children, value); + crate::object::js_object_set_field( + module_object_ptr(parent.get_nanbox_f64()).unwrap() as *mut _, + 5, + JSValue::from_bits(JSValue::array_ptr(children).bits()), + ); + } + } + crate::object::js_object_set_prototype_of( + obj.get_nanbox_f64(), + crate::object::module_cjs_prototype_for_instance(), + ); + obj.get_nanbox_f64() } fn module_cjs_dirname(path: &str) -> String { @@ -372,309 +436,54 @@ pub extern "C" fn js_module_load(request: f64, _parent: f64, _is_main: f64) -> f module_undefined() } -/// Constructor for `new module.SourceMap(payload)`. Preserves the payload -/// object and exposes working `findEntry`/`findOrigin` lookups. The bound -/// method closures capture the payload (slot 0) so the lookup thunks can -/// decode its `mappings`/`sources`/`names` without a separate `this` channel -/// (mirrors the dgram socket-method pattern). #3675. -#[no_mangle] -pub extern "C" fn js_module_source_map_new(payload: f64) -> f64 { - let obj = crate::object::js_object_alloc(0, 3); - module_set_field(obj, "payload", payload); - module_set_field( - obj, - "findEntry", - source_map_method(payload, "findEntry", source_map_find_entry_thunk), - ); - module_set_field( - obj, - "findOrigin", - source_map_method(payload, "findOrigin", source_map_find_origin_thunk), +pub extern "C" fn js_module_instance_load(filename: f64) -> f64 { + let receiver = crate::object::js_implicit_this_get(); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let Some(_) = module_object_ptr(receiver.get_nanbox_f64()) else { + module_throw_plain_type_error("Module.prototype.load called on incompatible receiver"); + }; + let loaded_key = js_string_from_bytes(b"loaded".as_ptr(), 6); + let loaded = JSValue::from_bits( + crate::object::js_object_get_field_by_name_f64( + module_object_ptr(receiver.get_nanbox_f64()).unwrap(), + loaded_key, + ) + .to_bits(), ); - module_object_value(obj) -} - -type SourceMapThunk = extern "C" fn(*const ClosureHeader, f64) -> f64; - -/// Build a bound SourceMap method closure that captures `payload` in slot 0 -/// and packs all call arguments into a single rest array. -fn source_map_method(payload: f64, name: &str, thunk: SourceMapThunk) -> f64 { - let func_ptr = thunk as *const u8; - let closure = js_closure_alloc(func_ptr, 1); - js_closure_set_capture_f64(closure, 0, payload); - crate::closure::js_register_closure_rest(func_ptr, 0); - crate::object::set_bound_native_closure_name(closure, name); - crate::value::js_nanbox_pointer(closure as i64) -} - -/// Decode a base64 VLQ alphabet byte to its 0–63 value. -fn source_map_b64(c: u8) -> Option { - match c { - b'A'..=b'Z' => Some((c - b'A') as i64), - b'a'..=b'z' => Some((c - b'a' + 26) as i64), - b'0'..=b'9' => Some((c - b'0' + 52) as i64), - b'+' => Some(62), - b'/' => Some(63), - _ => None, - } -} - -/// Decode one comma-delimited segment's VLQ fields. -fn source_map_decode_segment(seg: &[u8]) -> Vec { - let mut out = Vec::new(); - let mut value: i64 = 0; - let mut shift: u32 = 0; - for &b in seg { - let Some(digit) = source_map_b64(b) else { - continue; - }; - let cont = (digit & 0x20) != 0; - value += (digit & 0x1f) << shift; - if cont { - shift += 5; - } else { - let negative = (value & 1) != 0; - let decoded = value >> 1; - out.push(if negative { -decoded } else { decoded }); - value = 0; - shift = 0; - } - } - out -} - -#[derive(Clone, Copy)] -struct SourceMapEntry { - generated_line: i64, - generated_column: i64, - // `None` for genCol-only (1-field) segments that mark an unmapped position. - // The inner name index is `Some` only for segments that carried an explicit - // 5th VLQ field (a named mapping). - original: Option<(i64, i64, i64, Option)>, // (source_index, line, column, name_index) -} - -/// Decode the full `mappings` string into ordered entries with cumulative -/// source/line/column/name indices per the Source Map v3 grammar. `name_index` -/// is attached only to genuinely-named (5-field) segments, matching how a -/// position with no explicit name resolves (Node returns no `name` for the -/// names-less mapping in the issue repro). -fn source_map_decode(mappings: &str) -> Vec { - let mut entries = Vec::new(); - let (mut src_idx, mut src_line, mut src_col, mut name_idx) = (0i64, 0i64, 0i64, 0i64); - for (gen_line, line) in mappings.split(';').enumerate() { - let mut gen_col = 0i64; - for seg in line.split(',') { - if seg.is_empty() { - continue; - } - let fields = source_map_decode_segment(seg.as_bytes()); - if fields.is_empty() { - continue; - } - gen_col += fields[0]; - let original = if fields.len() >= 4 { - src_idx += fields[1]; - src_line += fields[2]; - src_col += fields[3]; - let name = if fields.len() >= 5 { - name_idx += fields[4]; - Some(name_idx) - } else { - None - }; - Some((src_idx, src_line, src_col, name)) - } else { - None - }; - entries.push(SourceMapEntry { - generated_line: gen_line as i64, - generated_column: gen_col, - original, - }); - } - } - entries -} - -/// Read `payload.` as a raw JSValue f64 (undefined when absent or when -/// the payload is not a heap object). -fn source_map_field(payload: f64, field: &str) -> f64 { - let p = JSValue::from_bits(payload.to_bits()); - if !p.is_pointer() { - return undefined_value(); - } - let obj = crate::value::js_nanbox_get_pointer(payload) as *const crate::object::ObjectHeader; - if obj.is_null() { - return undefined_value(); - } - let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); - let v = crate::object::js_object_get_field_by_name(obj, key); - f64::from_bits(v.bits()) -} - -/// Read `payload.` as a Rust string, if it is a string value. -fn source_map_field_string(payload: f64, field: &str) -> Option { - let value = JSValue::from_bits(source_map_field(payload, field).to_bits()); - let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - let bytes = unsafe { crate::string::js_string_key_bytes(value, &mut sso) }?; - Some(String::from_utf8_lossy(bytes).into_owned()) -} - -/// Read `payload.[index]` as a raw JSValue f64 (undefined when out -/// of range or not an array). -fn source_map_array_element(payload: f64, field: &str, index: i64) -> f64 { - if index < 0 { - return undefined_value(); - } - let arr_value = source_map_field(payload, field); - let av = JSValue::from_bits(arr_value.to_bits()); - if !av.is_pointer() { - return undefined_value(); - } - let arr = crate::value::js_nanbox_get_pointer(arr_value) as *const crate::array::ArrayHeader; - if arr.is_null() { - return undefined_value(); - } - let len = crate::array::js_array_length(arr); - if index as u32 >= len { - return undefined_value(); - } - crate::array::js_array_get_f64(arr, index as u32) -} - -fn source_map_collect_args(rest: f64) -> Vec { - let rv = JSValue::from_bits(rest.to_bits()); - if !rv.is_pointer() { - return Vec::new(); - } - let arr = crate::value::js_nanbox_get_pointer(rest) as *const crate::array::ArrayHeader; - if arr.is_null() { - return Vec::new(); - } - let len = crate::array::js_array_length(arr); - (0..len) - .map(|i| crate::array::js_array_get_f64(arr, i)) - .collect() -} - -/// Coerce call argument `idx` to a finite number, if it is one. -fn source_map_arg_number(args: &[f64], idx: usize) -> Option { - args.get(idx) - .map(|v| JSValue::from_bits(v.to_bits()).to_number()) - .filter(|n| n.is_finite()) -} - -fn source_map_arg_i64(args: &[f64], idx: usize) -> i64 { - source_map_arg_number(args, idx) - .map(|n| n as i64) - .unwrap_or(0) -} - -/// Decode the payload's `mappings` and return the greatest entry whose -/// generated position is `<=` (line, column). Entries are emitted in -/// non-decreasing order, so the last non-exceeding one wins. -fn source_map_lookup(payload: f64, line: i64, col: i64) -> Option { - let mappings = source_map_field_string(payload, "mappings")?; - let mut best = None; - for entry in source_map_decode(&mappings) { - if (entry.generated_line, entry.generated_column) <= (line, col) { - best = Some(entry); - } else { - break; - } - } - best -} - -/// Build the `{ name?, fileName, lineNumber, columnNumber }` shape Node's -/// `findOrigin` echoes (name/fileName from the matched entry; line/column from -/// the call arguments). Insertion order matches Node for byte-identical JSON. -fn source_map_origin_object( - payload: f64, - entry: Option, - line: Option, - col: Option, -) -> f64 { - let obj = crate::object::js_object_alloc(0, 4); - if let Some(SourceMapEntry { - original: Some((source_index, _, _, name_index)), - .. - }) = entry - { - if let Some(name_index) = name_index { - let name = source_map_array_element(payload, "names", name_index); - if JSValue::from_bits(name.to_bits()).is_string() { - module_set_field(obj, "name", name); - } - } - module_set_field( - obj, - "fileName", - source_map_array_element(payload, "sources", source_index), + if loaded.is_bool() && loaded.as_bool() { + crate::fs::validate::throw_error_with_code( + "Module did not self-register", + "ERR_INTERNAL_ASSERTION", ); } - let null = f64::from_bits(crate::value::TAG_NULL); - module_set_field(obj, "lineNumber", line.map_or(null, |n| n)); - module_set_field(obj, "columnNumber", col.map_or(null, |n| n)); - module_object_value(obj) -} - -/// `SourceMap#findEntry(lineNumber, columnNumber)` — return the greatest -/// decoded entry whose generated position is `<=` the query, shaped like -/// Node's `{ generatedLine, generatedColumn, originalSource, originalLine, -/// originalColumn, name? }`. Returns `{}` when no entry precedes the query. -extern "C" fn source_map_find_entry_thunk(closure: *const ClosureHeader, rest: f64) -> f64 { - let payload = js_closure_get_capture_f64(closure, 0); - let args = source_map_collect_args(rest); - let query_line = source_map_arg_i64(&args, 0); - let query_col = source_map_arg_i64(&args, 1); - - let Some(entry) = source_map_lookup(payload, query_line, query_col) else { - return module_object_value(crate::object::js_object_alloc(0, 0)); - }; - - let obj = crate::object::js_object_alloc(0, 6); - module_set_field(obj, "generatedLine", entry.generated_line as f64); - module_set_field(obj, "generatedColumn", entry.generated_column as f64); - if let Some((source_index, original_line, original_column, name_index)) = entry.original { - module_set_field( - obj, - "originalSource", - source_map_array_element(payload, "sources", source_index), + let Some(filename_string) = module_value_to_string(filename) else { + crate::fs::validate::throw_type_error_with_code( + "The \"filename\" argument must be of type string", + "ERR_INVALID_ARG_TYPE", ); - module_set_field(obj, "originalLine", original_line as f64); - module_set_field(obj, "originalColumn", original_column as f64); - if let Some(name_index) = name_index { - let name = source_map_array_element(payload, "names", name_index); - if JSValue::from_bits(name.to_bits()).is_string() { - module_set_field(obj, "name", name); - } - } - } - module_object_value(obj) -} - -/// `SourceMap#findOrigin(lineNumber, columnNumber)`. Node echoes the queried -/// coordinates (as `lineNumber`/`columnNumber`, or `null` when an argument is -/// not a finite number) and tags on the `name`/`fileName` of the entry at that -/// generated position. The lone special case is a numeric `(0, 0)` query, for -/// which Node returns an empty object. -extern "C" fn source_map_find_origin_thunk(closure: *const ClosureHeader, rest: f64) -> f64 { - let payload = js_closure_get_capture_f64(closure, 0); - let args = source_map_collect_args(rest); - let line = source_map_arg_number(&args, 0); - let col = source_map_arg_number(&args, 1); - - if line == Some(0.0) && col == Some(0.0) { - return module_object_value(crate::object::js_object_alloc(0, 0)); - } - - let entry = source_map_lookup( - payload, - line.map(|n| n as i64).unwrap_or(0), - col.map(|n| n as i64).unwrap_or(0), + }; + let filename_value = scope.root_nanbox_f64(module_string_value(&filename_string)); + let exports = scope.root_nanbox_f64(crate::module_require::js_require_path_module( + filename_value.get_nanbox_f64(), + )); + let cache = scope.root_nanbox_f64(crate::object::module_cjs_cache_value()); + let key = js_string_from_bytes(filename_string.as_ptr(), filename_string.len() as u32); + crate::object::js_object_delete_field( + module_object_ptr(cache.get_nanbox_f64()).unwrap_or(std::ptr::null()) as *mut _, + key, ); - source_map_origin_object(payload, entry, line, col) + module_set_field( + module_object_ptr(receiver.get_nanbox_f64()).unwrap() as *mut _, + "exports", + exports.get_nanbox_f64(), + ); + module_set_field( + module_object_ptr(receiver.get_nanbox_f64()).unwrap() as *mut _, + "loaded", + module_bool_value(true), + ); + module_undefined() } /// Module.isBuiltin(id) -> boolean @@ -713,12 +522,10 @@ pub extern "C" fn js_module_is_builtin(id: f64) -> f64 { /// `TypeError [ERR_INVALID_ARG_TYPE]` /// * no enclosing `package.json` → `undefined` #[no_mangle] -pub extern "C" fn js_module_find_package_json(specifier: f64, base: f64) -> f64 { +pub extern "C" fn js_module_find_package_json(specifier: f64, base: f64, arg_count: f64) -> f64 { let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); - // `specifier` is required and must be a string (Perry covers the - // local-path/file-URL specifier shape). - if specifier.to_bits() == crate::value::TAG_UNDEFINED { + if arg_count == 0.0 { crate::fs::validate::throw_error_with_code( "The \"specifier\" argument must be specified", "ERR_MISSING_ARGS", @@ -729,13 +536,24 @@ pub extern "C" fn js_module_find_package_json(specifier: f64, base: f64) -> f64 let Some(spec_bytes) = (unsafe { crate::string::js_string_key_bytes(spec_value, &mut sso_buf) }) else { - let message = format!( - "The \"specifier\" argument must be of type string. Received {}", - crate::fs::validate::describe_received(specifier) + crate::fs::validate::throw_error_with_code( + "Cannot find package for the supplied specifier", + "ERR_MODULE_NOT_FOUND", ); - crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); }; let specifier_str = String::from_utf8_lossy(spec_bytes).into_owned(); + if specifier_str.is_empty() { + crate::fs::validate::throw_error_with_code( + "Cannot find package for the supplied specifier", + "ERR_MODULE_NOT_FOUND", + ); + } + if specifier_str.starts_with("node:") { + crate::fs::validate::throw_type_error_with_code( + "The URL must be of scheme file", + "ERR_INVALID_URL_SCHEME", + ); + } // Resolve `base` to a directory. A missing/undefined base anchors at the // current working directory (Node requires a base for relative specifiers, @@ -783,6 +601,35 @@ fn find_nearest_package_json(specifier: &str, base: &str) -> Option { .unwrap_or_else(|| PathBuf::from(".")) }; + let is_bare = !matches!(specifier, "." | "..") + && !Path::new(specifier).is_absolute() + && !specifier.starts_with("./") + && !specifier.starts_with("../"); + if is_bare { + let mut parts = specifier.split('/'); + let first = parts.next()?; + let package_name = if first.starts_with('@') { + format!("{first}/{}", parts.next()?) + } else { + first.to_string() + }; + let mut dir = base_dir; + loop { + let candidate = dir + .join("node_modules") + .join(&package_name) + .join("package.json"); + if candidate.is_file() { + let canonical = std::fs::canonicalize(&candidate).unwrap_or(candidate); + return Some(canonical.to_string_lossy().into_owned()); + } + match dir.parent() { + Some(parent) => dir = parent.to_path_buf(), + None => return None, + } + } + } + let resolved = if Path::new(specifier).is_absolute() { PathBuf::from(specifier) } else { @@ -1175,10 +1022,65 @@ fn module_loader_result_url(result: f64, fallback: f64) -> f64 { } } +extern "C" fn module_loader_hook_chain( + closure: *const crate::closure::ClosureHeader, + value: f64, + context: f64, +) -> f64 { + let callback = js_closure_get_capture_f64(closure, 0); + let next = js_closure_get_capture_f64(closure, 1); + let args = [value, context, next]; + unsafe { crate::closure::js_native_call_value(callback, args.as_ptr(), args.len()) } +} + +fn module_loader_hook_chain_function(callback: f64, next: f64, name: &str) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let callback = scope.root_nanbox_f64(callback); + let next = scope.root_nanbox_f64(next); + let func_ptr = module_loader_hook_chain as *const u8; + crate::closure::js_register_closure_arity(func_ptr, 2); + crate::closure::js_register_closure_length(func_ptr, 2); + let closure = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(func_ptr, 2)); + js_closure_set_capture_f64(closure.get_raw_mut_ptr(), 0, callback.get_nanbox_f64()); + js_closure_set_capture_f64(closure.get_raw_mut_ptr(), 1, next.get_nanbox_f64()); + crate::object::set_bound_native_closure_name(closure.get_raw_mut_ptr(), name); + crate::object::set_builtin_closure_length( + closure.get_raw_mut_ptr::() as usize, + 2, + ); + crate::value::js_nanbox_pointer( + closure.get_raw_mut_ptr::() as i64 + ) +} + +fn module_loader_build_hook_chain( + entries: &[ModuleLoaderHookEntry], + terminal: f64, + resolve: bool, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let chain = scope.root_nanbox_f64(terminal); + for entry in entries { + let callback = if resolve { entry.resolve } else { entry.load }; + if !is_function_value(callback) { + continue; + } + let callback = scope.root_nanbox_f64(callback); + let wrapper = module_loader_hook_chain_function( + callback.get_nanbox_f64(), + chain.get_nanbox_f64(), + if resolve { "nextResolve" } else { "nextLoad" }, + ); + chain.set_nanbox_f64(wrapper); + } + chain.get_nanbox_f64() +} + /// Apply active synchronous `module.registerHooks()` callbacks to a dynamic /// import known to Perry's compile-time graph. This supports observable /// resolve/load callback participation and deregistration; arbitrary new -/// runtime-loaded modules remain outside Perry's static import model. +/// runtime-loaded modules and load-hook source replacement remain outside +/// Perry's static import model. #[no_mangle] pub extern "C" fn js_module_dynamic_import_apply_hooks(specifier: f64) -> f64 { let entries = MODULE_LOADER_HOOKS.with(|hooks| { @@ -1194,61 +1096,52 @@ pub extern "C" fn js_module_dynamic_import_apply_hooks(specifier: f64) -> f64 { } let scope = crate::gc::RuntimeHandleScope::new(); - let mut current = specifier; - for entry in entries { - if is_function_value(entry.resolve) { - let current_handle = scope.root_nanbox_f64(current); - let callback_handle = scope.root_nanbox_f64(entry.resolve); - let context_handle = scope.root_nanbox_f64(module_loader_resolve_context()); - let next_handle = scope.root_nanbox_f64(module_loader_callback( - &MODULE_LOADER_NEXT_RESOLVE, - "nextResolve", - module_loader_next_resolve, - )); - let args = [ - current_handle.get_nanbox_f64(), - context_handle.get_nanbox_f64(), - next_handle.get_nanbox_f64(), - ]; - let result = unsafe { - crate::closure::js_native_call_value( - callback_handle.get_nanbox_f64(), - args.as_ptr(), - args.len(), - ) - }; - let result_handle = scope.root_nanbox_f64(result); - current = module_loader_result_url( - result_handle.get_nanbox_f64(), - current_handle.get_nanbox_f64(), - ); - } - - if is_function_value(entry.load) { - let current_handle = scope.root_nanbox_f64(current); - let callback_handle = scope.root_nanbox_f64(entry.load); - let context_handle = scope.root_nanbox_f64(module_loader_load_context()); - let next_handle = scope.root_nanbox_f64(module_loader_callback( - &MODULE_LOADER_NEXT_LOAD, - "nextLoad", - module_loader_next_load, - )); - let args = [ - current_handle.get_nanbox_f64(), - context_handle.get_nanbox_f64(), - next_handle.get_nanbox_f64(), - ]; - unsafe { - crate::closure::js_native_call_value( - callback_handle.get_nanbox_f64(), - args.as_ptr(), - args.len(), - ); - } - } + let specifier = scope.root_nanbox_f64(specifier); + let resolve_context = scope.root_nanbox_f64(module_loader_resolve_context()); + let resolve_terminal = module_loader_callback( + &MODULE_LOADER_NEXT_RESOLVE, + "nextResolve", + module_loader_next_resolve, + ); + let resolve_chain = scope.root_nanbox_f64(module_loader_build_hook_chain( + &entries, + resolve_terminal, + true, + )); + let resolve_args = [specifier.get_nanbox_f64(), resolve_context.get_nanbox_f64()]; + let resolved = unsafe { + crate::closure::js_native_call_value( + resolve_chain.get_nanbox_f64(), + resolve_args.as_ptr(), + resolve_args.len(), + ) + }; + let current = scope.root_nanbox_f64(module_loader_result_url( + resolved, + specifier.get_nanbox_f64(), + )); + + let load_context = scope.root_nanbox_f64(module_loader_load_context()); + let load_terminal = module_loader_callback( + &MODULE_LOADER_NEXT_LOAD, + "nextLoad", + module_loader_next_load, + ); + let load_chain = scope.root_nanbox_f64(module_loader_build_hook_chain( + &entries, + load_terminal, + false, + )); + let load_args = [current.get_nanbox_f64(), load_context.get_nanbox_f64()]; + unsafe { + crate::closure::js_native_call_value( + load_chain.get_nanbox_f64(), + load_args.as_ptr(), + load_args.len(), + ); } - current + current.get_nanbox_f64() } fn module_register_invalid_specifier(specifier: &str) -> bool { @@ -1293,6 +1186,177 @@ fn module_word_at(bytes: &[u8], index: usize, word: &[u8]) -> bool { !before.is_some_and(module_is_ident_byte) && !after.is_some_and(module_is_ident_byte) } +fn module_regex_can_start(bytes: &[u8], index: usize, previous: Option) -> bool { + if previous.is_none_or(|byte| { + matches!( + byte, + b'(' | b'[' + | b'{' + | b'=' + | b':' + | b',' + | b';' + | b'!' + | b'?' + | b'+' + | b'-' + | b'*' + | b'%' + | b'&' + | b'|' + | b'^' + | b'~' + | b'<' + | b'>' + ) + }) { + return true; + } + let mut end = index; + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + let mut start = end; + while start > 0 && module_is_ident_byte(bytes[start - 1]) { + start -= 1; + } + matches!( + &bytes[start..end], + b"return" + | b"throw" + | b"case" + | b"delete" + | b"void" + | b"typeof" + | b"instanceof" + | b"in" + | b"new" + | b"yield" + | b"await" + ) +} + +fn module_typescript_code_mask(bytes: &[u8]) -> Vec { + const CODE: u8 = 0; + const SINGLE: u8 = 1; + const DOUBLE: u8 = 2; + const TEMPLATE: u8 = 3; + const LINE_COMMENT: u8 = 4; + const BLOCK_COMMENT: u8 = 5; + const REGEX: u8 = 6; + + let mut mask = vec![false; bytes.len()]; + let mut state = CODE; + let mut template_depths = Vec::::new(); + let mut regex_class = false; + let mut previous_code = None; + let mut index = 0; + while index < bytes.len() { + match state { + CODE => match bytes[index] { + b'}' if template_depths.last() == Some(&1) => { + template_depths.pop(); + state = TEMPLATE; + } + b'{' if !template_depths.is_empty() => { + *template_depths.last_mut().unwrap() += 1; + mask[index] = true; + previous_code = Some(b'{'); + } + b'}' if !template_depths.is_empty() => { + *template_depths.last_mut().unwrap() -= 1; + mask[index] = true; + previous_code = Some(b'}'); + } + b'\'' => state = SINGLE, + b'"' => state = DOUBLE, + b'`' => state = TEMPLATE, + b'/' if bytes.get(index + 1) == Some(&b'/') => { + state = LINE_COMMENT; + index += 1; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + state = BLOCK_COMMENT; + index += 1; + } + b'/' if module_regex_can_start(bytes, index, previous_code) => { + state = REGEX; + regex_class = false; + } + byte => { + mask[index] = true; + if !byte.is_ascii_whitespace() { + previous_code = Some(byte); + } + } + }, + SINGLE | DOUBLE => { + if bytes[index] == b'\\' { + index += 1; + } else if (state == SINGLE && bytes[index] == b'\'') + || (state == DOUBLE && bytes[index] == b'"') + { + state = CODE; + previous_code = Some(b'x'); + } + } + TEMPLATE => { + if bytes[index] == b'\\' { + index += 1; + } else if bytes[index] == b'`' { + state = CODE; + previous_code = Some(b'x'); + } else if bytes[index] == b'$' && bytes.get(index + 1) == Some(&b'{') { + template_depths.push(1); + state = CODE; + index += 1; + previous_code = Some(b'{'); + } + } + REGEX => { + if bytes[index] == b'\\' { + index += 1; + } else if bytes[index] == b'[' { + regex_class = true; + } else if bytes[index] == b']' { + regex_class = false; + } else if bytes[index] == b'/' && !regex_class { + while bytes + .get(index + 1) + .is_some_and(|byte| byte.is_ascii_alphabetic()) + { + index += 1; + } + state = CODE; + previous_code = Some(b'x'); + } + } + LINE_COMMENT => { + if matches!(bytes[index], b'\n' | b'\r') { + state = CODE; + mask[index] = true; + } + } + BLOCK_COMMENT => { + if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') { + state = CODE; + index += 1; + } + } + _ => unreachable!(), + } + index += 1; + } + mask +} + +fn module_word_at_code(bytes: &[u8], mask: &[bool], index: usize, word: &[u8]) -> bool { + module_word_at(bytes, index, word) + && mask + .get(index..index + word.len()) + .is_some_and(|span| span.iter().all(|is_code| *is_code)) +} + fn module_is_ident_byte(byte: u8) -> bool { byte == b'_' || byte == b'$' || byte.is_ascii_alphanumeric() } @@ -1312,10 +1376,10 @@ fn module_space_span(bytes: &mut [u8], start: usize, end: usize) { } } -fn module_strip_interfaces(bytes: &mut [u8]) { +fn module_strip_interfaces(bytes: &mut [u8], mask: &[bool]) { let mut index = 0; while index < bytes.len() { - if !module_word_at(bytes, index, b"interface") { + if !module_word_at_code(bytes, mask, index, b"interface") { index += 1; continue; } @@ -1324,7 +1388,16 @@ fn module_strip_interfaces(bytes: &mut [u8]) { while cursor < bytes.len() && module_is_ident_byte(bytes[cursor]) { cursor += 1; } - cursor = module_skip_ws(bytes, cursor); + // Interfaces may have generic parameters and an `extends` clause. + // Find their body rather than requiring `{` immediately after the name. + while cursor < bytes.len() + && !(mask[cursor] && bytes[cursor] == b'{') + && bytes[cursor] != b';' + && bytes[cursor] != b'\n' + && bytes[cursor] != b'\r' + { + cursor += 1; + } if cursor >= bytes.len() || bytes[cursor] != b'{' { index += 1; continue; @@ -1333,6 +1406,7 @@ fn module_strip_interfaces(bytes: &mut [u8]) { let mut end = cursor; while end < bytes.len() { match bytes[end] { + _ if !mask[end] => {} b'{' => depth += 1, b'}' => { depth = depth.saturating_sub(1); @@ -1350,10 +1424,137 @@ fn module_strip_interfaces(bytes: &mut [u8]) { } } -fn module_strip_type_annotations(bytes: &mut [u8]) { +fn module_strip_type_aliases(bytes: &mut [u8], mask: &[bool]) { + let mut index = 0; + while index < bytes.len() { + let type_start = if module_word_at_code(bytes, mask, index, b"type") { + index + } else if module_word_at_code(bytes, mask, index, b"import") + || module_word_at_code(bytes, mask, index, b"export") + { + let type_start = module_skip_ws( + bytes, + index + + if bytes[index] == b'i' { + "import".len() + } else { + "export".len() + }, + ); + if !module_word_at_code(bytes, mask, type_start, b"type") { + index += 1; + continue; + } + let mut cursor = type_start + "type".len(); + let mut depth = 0usize; + while cursor < bytes.len() { + if mask[cursor] { + match bytes[cursor] { + b'{' | b'[' | b'(' => depth += 1, + b'}' | b']' | b')' => depth = depth.saturating_sub(1), + b';' if depth == 0 => { + cursor += 1; + break; + } + b'\n' | b'\r' if depth == 0 => break, + _ => {} + } + } + cursor += 1; + } + module_space_span(bytes, index, cursor); + index = cursor; + continue; + } else { + index += 1; + continue; + }; + let mut cursor = module_skip_ws(bytes, type_start + "type".len()); + if cursor >= bytes.len() || !module_is_ident_byte(bytes[cursor]) { + index += 1; + continue; + } + while cursor < bytes.len() + && !(mask[cursor] && matches!(bytes[cursor], b'=' | b'\n' | b'\r' | b';')) + { + cursor += 1; + } + if cursor >= bytes.len() || bytes[cursor] != b'=' { + index += 1; + continue; + } + let mut depth = 0usize; + while cursor < bytes.len() { + if mask[cursor] { + match bytes[cursor] { + b'{' | b'[' | b'(' => depth += 1, + b'}' | b']' | b')' => depth = depth.saturating_sub(1), + b';' | b'\n' | b'\r' if depth == 0 => break, + _ => {} + } + } + cursor += 1; + } + if cursor < bytes.len() && bytes[cursor] == b';' { + cursor += 1; + } + module_space_span(bytes, index, cursor); + index = cursor; + } +} + +fn module_is_import_export_alias(bytes: &[u8], mask: &[bool], index: usize) -> bool { + let statement_start = (0..index) + .rev() + .find(|position| mask[*position] && bytes[*position] == b';') + .map_or(0, |position| position + 1); + let mut cursor = statement_start; + while cursor < index && (!mask[cursor] || bytes[cursor].is_ascii_whitespace()) { + cursor += 1; + } + if module_word_at_code(bytes, mask, cursor, b"import") { + return true; + } + if !module_word_at_code(bytes, mask, cursor, b"export") { + return false; + } + cursor += "export".len(); + while cursor < index && (!mask[cursor] || bytes[cursor].is_ascii_whitespace()) { + cursor += 1; + } + matches!(bytes.get(cursor), Some(b'{' | b'*')) +} + +fn module_strip_type_clause(bytes: &mut [u8], mask: &[bool], keyword: &[u8]) { + let mut index = 0; + while index < bytes.len() { + if !module_word_at_code(bytes, mask, index, keyword) + || (keyword == b"as" && module_is_import_export_alias(bytes, mask, index)) + { + index += 1; + continue; + } + let mut cursor = module_skip_ws(bytes, index + keyword.len()); + let mut angle_depth = 0usize; + while cursor < bytes.len() { + match bytes[cursor] { + _ if !mask[cursor] => {} + b'<' => angle_depth += 1, + b'>' => angle_depth = angle_depth.saturating_sub(1), + b';' | b',' | b')' | b']' | b'\n' | b'\r' if angle_depth == 0 => break, + _ => {} + } + cursor += 1; + } + module_space_span(bytes, index, cursor); + index = cursor; + } +} + +fn module_strip_type_annotations(bytes: &mut [u8], mask: &[bool]) { let mut index = 0; while index < bytes.len() { - if bytes[index] != b':' { + if bytes[index] != b':' || !mask[index] { index += 1; continue; } @@ -1391,14 +1592,19 @@ fn module_strip_type_annotations(bytes: &mut [u8]) { fn module_strip_type_syntax(source: &str) -> String { let mut bytes = source.as_bytes().to_vec(); - module_strip_interfaces(&mut bytes); - module_strip_type_annotations(&mut bytes); + let mask = module_typescript_code_mask(&bytes); + module_strip_type_aliases(&mut bytes, &mask); + module_strip_interfaces(&mut bytes, &mask); + module_strip_type_annotations(&mut bytes, &mask); + module_strip_type_clause(&mut bytes, &mask, b"satisfies"); + module_strip_type_clause(&mut bytes, &mask, b"as"); String::from_utf8(bytes).unwrap_or_else(|_| source.to_string()) } fn module_contains_enum(source: &str) -> bool { let bytes = source.as_bytes(); - (0..bytes.len()).any(|index| module_word_at(bytes, index, b"enum")) + let mask = module_typescript_code_mask(bytes); + (0..bytes.len()).any(|index| module_word_at_code(bytes, &mask, index, b"enum")) } fn module_invalid_option_received(value: f64) -> String { diff --git a/crates/perry-runtime/src/process/node_module/source_map.rs b/crates/perry-runtime/src/process/node_module/source_map.rs new file mode 100644 index 0000000000..6b43ee6783 --- /dev/null +++ b/crates/perry-runtime/src/process/node_module/source_map.rs @@ -0,0 +1,794 @@ +use super::*; +use crate::closure::{js_closure_alloc, ClosureHeader}; +use base64::Engine as _; +use std::cell::{Cell, RefCell}; + +const SOURCE_MAP_CLASS_ID: u32 = 0xFFFF_04D0; + +thread_local! { + static SOURCE_MAP_PROTOTYPE: Cell = const { Cell::new(0) }; + static SOURCE_MAP_CACHE: RefCell> = + RefCell::new(std::collections::HashMap::new()); +} + +pub(super) fn scan_roots(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + SOURCE_MAP_PROTOTYPE.with(|cell| { + let mut value = f64::from_bits(cell.get()); + if value.to_bits() != 0 { + visitor.visit_nanbox_f64_slot(&mut value); + cell.set(value.to_bits()); + } + }); + SOURCE_MAP_CACHE.with(|cache| { + for bits in cache.borrow_mut().values_mut() { + let mut value = f64::from_bits(*bits); + visitor.visit_nanbox_f64_slot(&mut value); + *bits = value.to_bits(); + } + }); +} + +/// Constructor for `new module.SourceMap(payload[, options])`. +#[no_mangle] +pub extern "C" fn js_module_source_map_new(payload: f64, options: f64) -> f64 { + if module_object_ptr(payload).is_none() { + crate::fs::validate::throw_type_error_with_code( + "The \"payload\" argument must be of type object", + "ERR_INVALID_ARG_TYPE", + ); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(payload); + let options = scope.root_nanbox_f64(options); + let _ = source_map_prototype(); + let cloned_payload = crate::builtins::js_structured_clone(payload.get_nanbox_f64()); + let line_lengths = module_object_ptr(options.get_nanbox_f64()) + .map(|obj| module_get_named_field(obj, "lineLengths")) + .unwrap_or_else(module_undefined); + let cloned_payload = scope.root_nanbox_f64(cloned_payload); + let line_lengths = scope.root_nanbox_f64(line_lengths); + let keys = b"_payload\0_lineLengths\0"; + let obj = scope.root_raw_mut_ptr(crate::object::js_object_alloc_with_shape( + SOURCE_MAP_CLASS_ID, + 2, + keys.as_ptr(), + keys.len() as u32, + )); + // The first `js_object_alloc_with_shape` argument identifies the cached + // shape; it does not initialize ObjectHeader::class_id. + unsafe { + let obj = obj.get_raw_mut_ptr::(); + (*obj).class_id = SOURCE_MAP_CLASS_ID; + // GC_STORE_AUDIT(INIT): the fresh object is still rooted and unpublished. + (*obj).keys_array = std::ptr::null_mut(); + } + crate::object::js_object_set_field( + obj.get_raw_mut_ptr(), + 0, + JSValue::from_bits(cloned_payload.get_nanbox_f64().to_bits()), + ); + crate::object::js_object_set_field( + obj.get_raw_mut_ptr(), + 1, + JSValue::from_bits(line_lengths.get_nanbox_f64().to_bits()), + ); + // `proto` may have moved while cloning/allocating above; reload the rooted + // singleton before recording the instance chain. + let proto = source_map_prototype(); + crate::object::prototype_chain::object_set_static_prototype( + obj.get_raw_mut_ptr::() as usize, + proto.to_bits(), + ); + module_object_value(obj.get_raw_mut_ptr()) +} + +type SourceMapThunk = extern "C" fn(*const ClosureHeader, f64) -> f64; + +fn source_map_method(name: &str, thunk: SourceMapThunk) -> f64 { + let func_ptr = thunk as *const u8; + let closure = js_closure_alloc(func_ptr, 0); + crate::closure::js_register_closure_rest(func_ptr, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, 2); + crate::object::set_builtin_closure_non_constructable(closure as usize); + crate::value::js_nanbox_pointer(closure as i64) +} + +extern "C" fn source_map_payload_getter(_closure: *const ClosureHeader) -> f64 { + let obj = source_map_receiver(); + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(f64::from_bits( + crate::object::js_object_get_field(obj, 0).bits(), + )); + crate::builtins::js_structured_clone(payload.get_nanbox_f64()) +} + +extern "C" fn source_map_line_lengths_getter(_closure: *const ClosureHeader) -> f64 { + let obj = source_map_receiver(); + let scope = crate::gc::RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(f64::from_bits( + crate::object::js_object_get_field(obj, 1).bits(), + )); + let jv = JSValue::from_bits(value.get_nanbox_f64().to_bits()); + if !jv.is_pointer() { + return module_undefined(); + } + let ptr = jv.as_pointer::(); + if !crate::value::addr_class::is_plausible_heap_addr(ptr as usize) { + return module_undefined(); + } + let gc = unsafe { &*(ptr.sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader) }; + if gc.obj_type != crate::gc::GC_TYPE_ARRAY { + return module_undefined(); + } + let cloned = crate::array::js_array_clone(ptr as *const crate::array::ArrayHeader); + f64::from_bits(JSValue::array_ptr(cloned).bits()) +} + +fn source_map_getter(name: &str, thunk: extern "C" fn(*const ClosureHeader) -> f64) -> f64 { + let func_ptr = thunk as *const u8; + crate::closure::js_register_closure_arity(func_ptr, 0); + let closure = js_closure_alloc(func_ptr, 0); + crate::object::set_bound_native_closure_name(closure, &format!("get {name}")); + crate::object::set_builtin_closure_length(closure as usize, 0); + crate::object::set_builtin_closure_non_constructable(closure as usize); + crate::value::js_nanbox_pointer(closure as i64) +} + +fn source_map_receiver() -> *const crate::object::ObjectHeader { + let receiver = crate::object::js_implicit_this_get(); + let Some(obj) = module_object_ptr(receiver) else { + module_throw_plain_type_error("Receiver must be an instance of SourceMap"); + }; + if unsafe { (*obj).class_id } != SOURCE_MAP_CLASS_ID { + module_throw_plain_type_error("Receiver must be an instance of SourceMap"); + } + obj +} + +fn source_map_prototype() -> f64 { + SOURCE_MAP_PROTOTYPE.with(|slot| { + if slot.get() != 0 { + return f64::from_bits(slot.get()); + } + // Pre-shape the object so all properties are reflected even when their + // value is `undefined` (the accessor slots). Store the singleton root + // immediately: allocating closures below can run a moving GC. + let keys = b"constructor\0findEntry\0findOrigin\0lineLengths\0payload\0"; + let proto = + crate::object::js_object_alloc_with_shape(0, 5, keys.as_ptr(), keys.len() as u32); + let value = module_object_value(proto); + slot.set(value.to_bits()); + crate::object::js_object_set_field( + proto, + 0, + JSValue::from_bits(module_undefined().to_bits()), + ); + for (index, name, thunk) in [ + ( + 1, + "findEntry", + source_map_find_entry_thunk as SourceMapThunk, + ), + ( + 2, + "findOrigin", + source_map_find_origin_thunk as SourceMapThunk, + ), + ] { + let value = source_map_method(name, thunk); + let proto = module_object_ptr(f64::from_bits(slot.get())).expect("SourceMap prototype"); + crate::object::js_object_set_field( + proto as *mut _, + index, + JSValue::from_bits(value.to_bits()), + ); + crate::object::set_builtin_property_attrs( + proto as usize, + name.to_string(), + crate::object::PropertyAttrs::new(true, false, true), + ); + } + for (index, name, thunk) in [ + ( + 4, + "payload", + source_map_payload_getter as extern "C" fn(*const ClosureHeader) -> f64, + ), + ( + 3, + "lineLengths", + source_map_line_lengths_getter as extern "C" fn(*const ClosureHeader) -> f64, + ), + ] { + let getter = source_map_getter(name, thunk); + let proto = module_object_ptr(f64::from_bits(slot.get())).expect("SourceMap prototype"); + crate::object::js_object_set_field( + proto as *mut _, + index, + JSValue::from_bits(module_undefined().to_bits()), + ); + crate::object::set_builtin_accessor_descriptor( + proto as usize, + name.to_string(), + crate::object::AccessorDescriptor { + get: getter.to_bits(), + set: 0, + }, + crate::object::PropertyAttrs::new(true, false, true), + ); + } + f64::from_bits(slot.get()) + }) +} + +pub fn module_source_map_attach_constructor(closure_addr: usize) { + let scope = crate::gc::RuntimeHandleScope::new(); + let constructor = scope.root_raw_mut_ptr(closure_addr as *mut ClosureHeader); + let proto_value = scope.root_nanbox_f64(source_map_prototype()); + let proto = module_object_ptr(proto_value.get_nanbox_f64()).expect("SourceMap prototype"); + let constructor_value = + crate::value::js_nanbox_pointer(constructor.get_raw_mut_ptr::() as i64); + crate::object::js_object_set_field( + proto as *mut crate::object::ObjectHeader, + 0, + JSValue::from_bits(constructor_value.to_bits()), + ); + crate::object::set_builtin_property_attrs( + proto as usize, + "constructor".to_string(), + crate::object::PropertyAttrs::new(true, false, true), + ); + crate::closure::closure_set_dynamic_prop( + constructor.get_raw_mut_ptr::() as usize, + "prototype", + proto_value.get_nanbox_f64(), + ); + crate::object::set_builtin_property_attrs( + constructor.get_raw_mut_ptr::() as usize, + "prototype".to_string(), + crate::object::PropertyAttrs::new(false, false, false), + ); +} + +/// Decode a base64 VLQ alphabet byte to its 0–63 value. +fn source_map_b64(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some((c - b'A') as i64), + b'a'..=b'z' => Some((c - b'a' + 26) as i64), + b'0'..=b'9' => Some((c - b'0' + 52) as i64), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } +} + +/// Decode one comma-delimited segment's VLQ fields. +fn source_map_decode_segment(seg: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut value: i64 = 0; + let mut shift: u32 = 0; + for &b in seg { + let Some(digit) = source_map_b64(b) else { + continue; + }; + let cont = (digit & 0x20) != 0; + let Some(part) = (digit & 0x1f).checked_shl(shift) else { + return Vec::new(); + }; + let Some(next) = value.checked_add(part) else { + return Vec::new(); + }; + if next > u32::MAX as i64 { + return Vec::new(); + } + value = next; + if cont { + let Some(next_shift) = shift.checked_add(5) else { + return Vec::new(); + }; + if next_shift >= 35 { + return Vec::new(); + } + shift = next_shift; + } else { + let negative = (value & 1) != 0; + let decoded = value >> 1; + out.push(if negative { -decoded } else { decoded }); + value = 0; + shift = 0; + } + } + if out.is_empty() && !seg.is_empty() { + vec![0, 0, 0, 0] + } else if shift == 0 { + out + } else { + Vec::new() + } +} + +#[derive(Clone)] +struct SourceMapEntry { + generated_line: i64, + generated_column: i64, + section_path: Vec, + // `None` for genCol-only (1-field) segments that mark an unmapped position. + original: Option<(i64, i64, i64, Option)>, // (source_index, line, column, name_index) +} + +/// Decode the full `mappings` string into ordered entries with cumulative +/// source/line/column/name indices per Node's SourceMap behavior. +fn source_map_decode(mappings: &str) -> Vec { + let mut entries = Vec::new(); + let (mut src_idx, mut src_line, mut src_col, mut name_idx) = (0i64, 0i64, 0i64, 0i64); + let mut has_name = false; + for (gen_line, line) in mappings.split(';').enumerate() { + let mut gen_col = 0i64; + for seg in line.split(',') { + if seg.is_empty() { + continue; + } + let fields = source_map_decode_segment(seg.as_bytes()); + if fields.is_empty() { + continue; + } + gen_col += fields[0]; + let original = if fields.len() >= 4 { + src_idx += fields[1]; + src_line += fields[2]; + src_col += fields[3]; + let name = if fields.len() >= 5 { + name_idx += fields[4]; + has_name = true; + Some(name_idx) + } else if has_name { + // The name index is a running state just like source and + // original coordinates. Node carries the most recently + // decoded name onto later mapped segments, including a + // segment on a subsequent generated line. + Some(name_idx) + } else { + None + }; + Some((src_idx, src_line, src_col, name)) + } else { + None + }; + entries.push(SourceMapEntry { + generated_line: gen_line as i64, + generated_column: gen_col, + section_path: Vec::new(), + original, + }); + } + } + entries +} + +/// Read `payload.` as a raw JSValue f64 (undefined when absent or when +/// the payload is not a heap object). +fn source_map_field(payload: f64, field: &str) -> f64 { + let p = JSValue::from_bits(payload.to_bits()); + if !p.is_pointer() { + return undefined_value(); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(payload); + let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); + let obj = crate::value::js_nanbox_get_pointer(payload.get_nanbox_f64()) + as *const crate::object::ObjectHeader; + let v = crate::object::js_object_get_field_by_name(obj, key); + f64::from_bits(v.bits()) +} + +/// Read `payload.` as a Rust string, if it is a string value. +fn source_map_field_string(payload: f64, field: &str) -> Option { + let value = JSValue::from_bits(source_map_field(payload, field).to_bits()); + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let bytes = unsafe { crate::string::js_string_key_bytes(value, &mut sso) }?; + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +/// Read `payload.[index]` as a raw JSValue f64 (undefined when out +/// of range or not an array). +fn source_map_array_element(payload: f64, field: &str, index: i64) -> f64 { + if index < 0 { + return undefined_value(); + } + let arr_value = source_map_field(payload, field); + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_value = scope.root_nanbox_f64(arr_value); + let av = JSValue::from_bits(arr_value.get_nanbox_f64().to_bits()); + if !av.is_pointer() { + return undefined_value(); + } + let ptr = crate::value::js_nanbox_get_pointer(arr_value.get_nanbox_f64()) as *const u8; + if !crate::value::addr_class::is_plausible_heap_addr(ptr as usize) { + return undefined_value(); + } + let gc = unsafe { &*(ptr.sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader) }; + if gc.obj_type != crate::gc::GC_TYPE_ARRAY { + return undefined_value(); + } + let arr = ptr as *const crate::array::ArrayHeader; + let len = crate::array::js_array_length(arr); + if index >= i64::from(len) { + return undefined_value(); + } + crate::array::js_array_get_f64(arr, index as u32) +} + +fn source_map_arg(rest: f64, index: u32) -> f64 { + let rv = JSValue::from_bits(rest.to_bits()); + if !rv.is_pointer() { + return module_undefined(); + } + let arr = crate::value::js_nanbox_get_pointer(rest) as *const crate::array::ArrayHeader; + if !crate::value::addr_class::is_plausible_heap_addr(arr as usize) { + return module_undefined(); + } + let len = crate::array::js_array_length(arr); + if index >= len { + module_undefined() + } else { + crate::array::js_array_get_f64(arr, index) + } +} + +/// Coerce call argument `idx` to a finite number, if it is one. +fn source_map_arg_number(value: f64) -> Option { + let number = JSValue::from_bits(value.to_bits()).to_number(); + number.is_finite().then_some(number) +} + +fn source_map_arg_i64(value: f64) -> i64 { + source_map_arg_number(value).map(|n| n as i64).unwrap_or(0) +} + +/// Decode the payload's `mappings` and return the greatest entry whose +/// generated position is `<=` (line, column). Entries are emitted in +/// non-decreasing order, so the last non-exceeding one wins. +fn source_map_lookup(payload: f64, line: i64, col: i64) -> Option { + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(payload); + if let Some(sections) = source_map_array_value(payload.get_nanbox_f64(), "sections") { + let sections = scope.root_nanbox_f64(sections); + let sections_ptr = || { + crate::value::js_nanbox_get_pointer(sections.get_nanbox_f64()) + as *const crate::array::ArrayHeader + }; + let len = crate::array::js_array_length(sections_ptr()); + let mut best = None; + for index in 0..len { + let section = + scope.root_nanbox_f64(crate::array::js_array_get_f64(sections_ptr(), index)); + let offset = + scope.root_nanbox_f64(source_map_field(section.get_nanbox_f64(), "offset")); + if module_object_ptr(offset.get_nanbox_f64()).is_none() { + continue; + } + let offset_line = + JSValue::from_bits(source_map_field(offset.get_nanbox_f64(), "line").to_bits()) + .to_number() as i64; + let offset_col = + JSValue::from_bits(source_map_field(offset.get_nanbox_f64(), "column").to_bits()) + .to_number() as i64; + if (offset_line, offset_col) > (line, col) { + break; + } + let nested = scope.root_nanbox_f64(source_map_field(section.get_nanbox_f64(), "map")); + let local_line = line - offset_line; + let local_col = if local_line == 0 { + col - offset_col + } else { + col + }; + if let Some(mut entry) = + source_map_lookup(nested.get_nanbox_f64(), local_line, local_col) + { + entry.section_path.insert(0, index); + entry.generated_line += offset_line; + if entry.generated_line == offset_line { + entry.generated_column += offset_col; + } + best = Some(entry); + } + } + return best; + } + let payload = payload.get_nanbox_f64(); + let mappings = source_map_field_string(payload, "mappings")?; + let mut best = None; + for entry in source_map_decode(&mappings) { + if (entry.generated_line, entry.generated_column) <= (line, col) { + best = Some(entry); + } else { + break; + } + } + best +} + +fn source_map_array_value(payload: f64, field: &str) -> Option { + let value = source_map_field(payload, field); + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return None; + } + let ptr = jv.as_pointer::(); + if !crate::value::addr_class::is_plausible_heap_addr(ptr as usize) { + return None; + } + let gc = unsafe { &*(ptr.sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader) }; + (gc.obj_type == crate::gc::GC_TYPE_ARRAY).then_some(value) +} + +fn source_map_entry_payload(payload: f64, section_path: &[u32]) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let current = scope.root_nanbox_f64(payload); + for &index in section_path { + let Some(sections) = source_map_array_value(current.get_nanbox_f64(), "sections") else { + return module_undefined(); + }; + let sections = scope.root_nanbox_f64(sections); + let sections_ptr = crate::value::js_nanbox_get_pointer(sections.get_nanbox_f64()) + as *const crate::array::ArrayHeader; + if index >= crate::array::js_array_length(sections_ptr) { + return module_undefined(); + } + let section = scope.root_nanbox_f64(crate::array::js_array_get_f64(sections_ptr, index)); + current.set_nanbox_f64(source_map_field(section.get_nanbox_f64(), "map")); + } + current.get_nanbox_f64() +} + +/// Build the `{ name?, fileName, lineNumber, columnNumber }` shape Node's +/// `findOrigin` echoes (name/fileName from the matched entry; line/column from +/// the call arguments). Insertion order matches Node for byte-identical JSON. +fn source_map_origin_object( + payload: f64, + entry: Option, + line: Option, + col: Option, +) -> f64 { + let Some(entry) = entry else { + return module_object_value(crate::object::js_object_alloc(0, 0)); + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(payload); + let source_payload = scope.root_nanbox_f64(source_map_entry_payload( + payload.get_nanbox_f64(), + &entry.section_path, + )); + let obj = crate::object::js_object_alloc(0, 4); + let obj = scope.root_raw_mut_ptr(obj); + if let SourceMapEntry { + original: Some((source_index, _, _, name_index)), + .. + } = entry + { + if let Some(name_index) = name_index { + let name = + source_map_array_element(source_payload.get_nanbox_f64(), "names", name_index); + if JSValue::from_bits(name.to_bits()).is_string() { + module_set_field(obj.get_raw_mut_ptr(), "name", name); + } + } + module_set_field( + obj.get_raw_mut_ptr(), + "fileName", + source_map_array_element(source_payload.get_nanbox_f64(), "sources", source_index), + ); + } + let null = f64::from_bits(crate::value::TAG_NULL); + module_set_field( + obj.get_raw_mut_ptr(), + "lineNumber", + line.map_or(null, |n| n), + ); + module_set_field( + obj.get_raw_mut_ptr(), + "columnNumber", + col.map_or(null, |n| n), + ); + module_object_value(obj.get_raw_mut_ptr()) +} + +/// `SourceMap#findEntry(lineNumber, columnNumber)` — return the greatest +/// decoded entry whose generated position is `<=` the query, shaped like +/// Node's `{ generatedLine, generatedColumn, originalSource, originalLine, +/// originalColumn, name? }`. Returns `{}` when no entry precedes the query. +extern "C" fn source_map_find_entry_thunk(closure: *const ClosureHeader, rest: f64) -> f64 { + let _ = closure; + let receiver = source_map_receiver(); + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(f64::from_bits( + crate::object::js_object_get_field(receiver, 0).bits(), + )); + let rest = scope.root_nanbox_f64(rest); + let line = scope.root_nanbox_f64(source_map_arg(rest.get_nanbox_f64(), 0)); + let column = scope.root_nanbox_f64(source_map_arg(rest.get_nanbox_f64(), 1)); + let query_line = source_map_arg_i64(line.get_nanbox_f64()); + let query_col = source_map_arg_i64(column.get_nanbox_f64()); + + let Some(entry) = source_map_lookup(payload.get_nanbox_f64(), query_line, query_col) else { + return module_object_value(crate::object::js_object_alloc(0, 0)); + }; + + let obj = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 6)); + module_set_field( + obj.get_raw_mut_ptr(), + "generatedLine", + entry.generated_line as f64, + ); + module_set_field( + obj.get_raw_mut_ptr(), + "generatedColumn", + entry.generated_column as f64, + ); + if let Some((source_index, original_line, original_column, name_index)) = entry.original { + let source_payload = scope.root_nanbox_f64(source_map_entry_payload( + payload.get_nanbox_f64(), + &entry.section_path, + )); + module_set_field( + obj.get_raw_mut_ptr(), + "originalSource", + source_map_array_element(source_payload.get_nanbox_f64(), "sources", source_index), + ); + module_set_field(obj.get_raw_mut_ptr(), "originalLine", original_line as f64); + module_set_field( + obj.get_raw_mut_ptr(), + "originalColumn", + original_column as f64, + ); + if let Some(name_index) = name_index { + let name = + source_map_array_element(source_payload.get_nanbox_f64(), "names", name_index); + if JSValue::from_bits(name.to_bits()).is_string() { + module_set_field(obj.get_raw_mut_ptr(), "name", name); + } + } + } + module_object_value(obj.get_raw_mut_ptr()) +} + +/// `SourceMap#findOrigin(lineNumber, columnNumber)`. Node echoes the queried +/// coordinates (as `lineNumber`/`columnNumber`, or `null` when an argument is +/// not a finite number) and tags on the `name`/`fileName` of the entry at that +/// generated position. The lone special case is a numeric `(0, 0)` query, for +/// which Node returns an empty object. +extern "C" fn source_map_find_origin_thunk(closure: *const ClosureHeader, rest: f64) -> f64 { + let _ = closure; + let receiver = source_map_receiver(); + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(f64::from_bits( + crate::object::js_object_get_field(receiver, 0).bits(), + )); + let rest = scope.root_nanbox_f64(rest); + let line_arg = scope.root_nanbox_f64(source_map_arg(rest.get_nanbox_f64(), 0)); + let col_arg = scope.root_nanbox_f64(source_map_arg(rest.get_nanbox_f64(), 1)); + let line = source_map_arg_number(line_arg.get_nanbox_f64()); + let col = source_map_arg_number(col_arg.get_nanbox_f64()); + + if line == Some(0.0) && col == Some(0.0) { + return module_object_value(crate::object::js_object_alloc(0, 0)); + } + + // `findOrigin` consumes 1-based generated coordinates, unlike findEntry's + // 0-based coordinates. Node's native search falls through to the last + // mapping for a non-numeric line receiver; preserve that observable quirk. + let entry = if let Some(line) = line { + source_map_lookup( + payload.get_nanbox_f64(), + (line as i64).saturating_sub(1), + col.map(|n| (n as i64).saturating_sub(1)) + .unwrap_or(i64::MAX), + ) + } else { + source_map_lookup(payload.get_nanbox_f64(), i64::MAX, i64::MAX) + }; + source_map_origin_object(payload.get_nanbox_f64(), entry, line, col) +} + +fn source_map_normalize_inline_sources(payload: f64, generated_file: &std::path::Path) { + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(payload); + let Some(sources) = source_map_array_value(payload.get_nanbox_f64(), "sources") else { + return; + }; + let sources = scope.root_nanbox_f64(sources); + let source_root = + source_map_field_string(payload.get_nanbox_f64(), "sourceRoot").unwrap_or_default(); + let base = generated_file + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + let sources_ptr = || { + crate::value::js_nanbox_get_pointer(sources.get_nanbox_f64()) + as *const crate::array::ArrayHeader + }; + let len = crate::array::js_array_length(sources_ptr()); + for index in 0..len { + let raw = crate::array::js_array_get_f64(sources_ptr(), index); + let Some(source) = module_value_to_string(raw) else { + continue; + }; + if source.starts_with("file:") || source.contains("://") { + continue; + } + let path = base.join(&source_root).join(source); + // The source file need not exist, so canonicalize can legitimately + // fail. Components still removes lexical `.` segments in that case. + let normalized: std::path::PathBuf = path.components().collect(); + let path = std::fs::canonicalize(&path).unwrap_or(normalized); + let url = crate::url::node_compat::path_to_file_url_string( + &path.to_string_lossy(), + cfg!(windows), + ); + let url = module_string_value(&url); + crate::array::js_array_set_f64(sources_ptr() as *mut _, index, url); + } +} + +/// `module.findSourceMap(filename)` — lazily materialize inline source maps. +/// Perry's AOT loader already resolves the generated file; parsing the inline +/// payload on first lookup avoids a second loader-side registry. +#[no_mangle] +pub extern "C" fn js_module_find_source_map(filename: f64) -> f64 { + if !SOURCE_MAPS_ENABLED.load(Ordering::Relaxed) { + return module_undefined(); + } + let Some(filename) = module_value_to_string(filename) else { + return module_undefined(); + }; + let filename = std::fs::canonicalize(&filename) + .unwrap_or_else(|_| std::path::PathBuf::from(&filename)) + .to_string_lossy() + .into_owned(); + if let Some(bits) = SOURCE_MAP_CACHE.with(|cache| cache.borrow().get(&filename).copied()) { + return f64::from_bits(bits); + } + let Ok(source) = std::fs::read_to_string(&filename) else { + return module_undefined(); + }; + let prefix = "sourceMappingURL=data:application/json"; + let Some(marker) = source.rfind(prefix) else { + return module_undefined(); + }; + let suffix = source[marker + prefix.len()..].lines().next().unwrap_or(""); + let Some(encoded) = suffix + .strip_prefix(";base64,") + .or_else(|| suffix.strip_prefix(";charset=utf-8;base64,")) + else { + return module_undefined(); + }; + let encoded = encoded + .trim_start() + .split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='))) + .next() + .unwrap_or(""); + let engine = base64::engine::general_purpose::GeneralPurpose::new( + &base64::alphabet::STANDARD, + base64::engine::general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), + ); + let Ok(decoded) = engine.decode(encoded) else { + return module_undefined(); + }; + let text = js_string_from_bytes(decoded.as_ptr(), decoded.len() as u32); + let payload = unsafe { crate::json::js_json_parse(text) }; + let payload = f64::from_bits(payload.bits()); + if module_object_ptr(payload).is_none() { + return module_undefined(); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let payload = scope.root_nanbox_f64(payload); + source_map_normalize_inline_sources(payload.get_nanbox_f64(), std::path::Path::new(&filename)); + let map = js_module_source_map_new(payload.get_nanbox_f64(), module_undefined()); + SOURCE_MAP_CACHE.with(|cache| { + cache.borrow_mut().insert(filename, map.to_bits()); + }); + crate::gc::runtime_write_barrier_root_nanbox(map.to_bits()); + map +} diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 69091295ab..6ee01baf18 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -723,11 +723,36 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 } // Buffers inherit TypedArray iteration semantics in Node: the default // iterator is `values()`, yielding numeric bytes. - let raw_addr = if (bits >> 48) >= 0x7FF8 { + let is_pointer = (bits >> 48) == 0x7FFD; + let raw_addr = if is_pointer { (bits & POINTER_MASK) as usize } else { bits as usize }; + // Module namespace exotic objects expose an own @@toStringTag. Perry's + // namespaces share one synthetic class and virtualize their exports, so + // resolve the tag from the stored module name instead of duplicating a + // physical symbol property on every namespace instance. + if is_pointer + && crate::value::addr_class::is_above_handle_band(raw_addr) + && crate::object::is_valid_obj_ptr(raw_addr as *const u8) + { + let obj = raw_addr as *const crate::object::ObjectHeader; + if unsafe { (*obj).class_id } == crate::object::NATIVE_MODULE_CLASS_ID { + let tag_wk = well_known_symbol("toStringTag"); + if !tag_wk.is_null() { + let tag_f64 = + f64::from_bits(crate::value::JSValue::pointer(tag_wk as *const u8).bits()); + if sym_key_from_f64(sym_f64) == sym_key_from_f64(tag_f64) + && crate::object::read_native_module_name(obj).as_deref() == Some("module") + { + let tag = b"Module"; + let value = js_string_from_bytes(tag.as_ptr(), tag.len() as u32); + return f64::from_bits(STRING_TAG | (value as u64 & POINTER_MASK)); + } + } + } + } if raw_addr >= 0x1000 && crate::buffer::is_registered_buffer(raw_addr) { let iter_wk = well_known_symbol("iterator"); if !iter_wk.is_null() { diff --git a/crates/perry-runtime/src/url/node_compat.rs b/crates/perry-runtime/src/url/node_compat.rs index 57320c16d7..9e45371ba0 100644 --- a/crates/perry-runtime/src/url/node_compat.rs +++ b/crates/perry-runtime/src/url/node_compat.rs @@ -488,7 +488,13 @@ pub extern "C" fn js_url_path_to_file_url(path_f64: f64, options_f64: f64) -> f6 let path = get_string_content(path_f64); let windows = options_windows_flag(options_f64); - let href = if windows { + let href = path_to_file_url_string(&path, windows); + let obj = create_url_object(&href); + crate::value::js_nanbox_pointer(obj as i64) +} + +pub(crate) fn path_to_file_url_string(path: &str, windows: bool) -> String { + if windows { // Win32 (#2975). UNC paths (`\\host\share\...`) become // `file://host/share/...`; everything else is a (drive-letter) path // with `\` separators rewritten to `/`. @@ -527,9 +533,7 @@ pub extern "C" fn js_url_path_to_file_url(path_f64: f64, options_f64: f64) -> f6 } else { format!("file:///{}", encoded) } - }; - let obj = create_url_object(&href); - crate::value::js_nanbox_pointer(obj as i64) + } } /// `url.domainToASCII(domain)` (#3059). Node Web-IDL-stringifies the argument diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 8a2200e48d..9babd20013 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -263,7 +263,11 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // instead keep the synthetic binding and rename it `_lazyreq_N` so the // target stays `Deferred` and inits only when the shim's // `return _lazyreq_N` runs (i.e. when the function actually calls require). - let lazy_specs = function_local_specs(source); + let mut lazy_specs = function_local_specs(source); + let cyclic_specs = cyclic_require_specs(source, source_path); + let parent_sensitive_specs = parent_sensitive_require_specs(source, source_path); + lazy_specs.extend(cyclic_specs.iter().cloned()); + lazy_specs.extend(parent_sensitive_specs.iter().cloned()); let mut import_local_names: Vec = require_specs .iter() @@ -343,6 +347,9 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( }) .collect::>() .join("\n"); + let imports = format!( + "import {{ createRequire as __perry_cjs_create_require }} from 'node:module';\n{imports}" + ); // An UNRESOLVABLE adopted specifier (`require('@opentelemetry/api')` // with only Next's vendored copy on disk) leaves its hoisted import @@ -362,14 +369,64 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( .iter() .zip(import_local_names.iter()) .map(|(spec, local)| { + let resolved_target = + super::super::resolve::resolve_relative_import_path(spec, source_path); + let link_child = resolved_target + .as_ref() + .map(|target| { + format!( + "const child = require.cache[{path:?}]; if (child) {{ if (child.parent === undefined) child.parent = module; if (module.children.indexOf(child) === -1) module.children.push(child); }} ", + path = target.to_string_lossy(), + ) + }) + .unwrap_or_default(); + let needs_runtime_record = + cyclic_specs.contains(spec) || parent_sensitive_specs.contains(spec); + let runtime_require = if needs_runtime_record { + resolved_target + .as_ref() + .map(|target| { + let warnings = if cyclic_specs.contains(spec) { + cyclic_missing_property_names(source, source_path, spec, target) + .into_iter() + .map(|property| { + format!( + "if (childBefore && childBefore.loaded === false) process.emitWarning(\"Accessing non-existent property '{property}' of module exports inside circular dependency\"); " + ) + }) + .collect::() + } else { + String::new() + }; + format!( + "const childBefore = require.cache[{path:?}]; {warnings}globalThis.__perry_cjs_pending_parent = module; let required; try {{ required = __perry_require_path_module({path:?}); }} finally {{ globalThis.__perry_cjs_pending_parent = undefined; }} {link_child}return required;", + path = target.to_string_lossy(), + ) + }) + } else { + None + }; + let required_value = if needs_runtime_record { + runtime_require.clone().unwrap_or_else(|| format!("return {local};")) + } else { + format!("{link_child}return {local};") + }; if require_site_in_try(source, spec) { format!( " if (specifier === '{spec}') {{ if (typeof {local} === 'boolean') \ throw __perry_cjs_require_error('error', 'MODULE_NOT_FOUND', \ - \"Cannot find module '{spec}'\"); return {local}; }}" + \"Cannot find module '{spec}'\"); {required_value} }}" ) } else { - format!(" if (specifier === '{}') return {};", spec, local) + if needs_runtime_record { + format!(" if (specifier === '{spec}') {{ {required_value} }}") + } else if link_child.is_empty() { + format!(" if (specifier === '{spec}') return {local};") + } else { + format!( + " if (specifier === '{spec}') {{ {required_value} }}" + ) + } } }) .collect::>() @@ -745,6 +802,12 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default() ); + let module_filename_literal = format!("{:?}", source_path.to_string_lossy()); + let cjs_factory_value = if flat_default_class.is_some() { + "undefined" + } else { + "__perry_cjs_factory" + }; let cjs_preamble = format!( r#" // #3527: `module`/`exports` are reassignable `var`s (mirroring Node, where // they are wrapper-function parameters), so CJS bodies that do @@ -756,8 +819,21 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // real module ref the same way), so named/default-export resolution stays // correct regardless of what the body does to its `module` local. const __cjs_module = {{ exports: {{}} }}; + __cjs_module.__perry_cjs_record = true; + __cjs_module.__perry_cjs_factory = {cjs_factory_value}; + __cjs_module.id = {module_filename_literal}; + __cjs_module.path = {module_dir_literal}; + __cjs_module.filename = {module_filename_literal}; + __cjs_module.loaded = false; + __cjs_module.children = []; + __cjs_module.parent = globalThis.__perry_cjs_pending_parent; + globalThis.__perry_cjs_pending_parent = undefined; + __cjs_module.paths = [{module_dir_literal} + '/node_modules']; + __cjs_module.require = undefined; var module = __cjs_module; var exports = __cjs_module.exports; + const __perry_cjs_base_require = __perry_cjs_create_require({module_filename_literal}); + __perry_cjs_base_require.cache[{module_filename_literal}] = __cjs_module; function __perry_cjs_require_error(kind, code, message) {{ const err = kind === 'type' ? new TypeError(message) : new Error(message); err.code = code; @@ -862,15 +938,24 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( '.json': function(module, filename) {{}}, '.node': function(module, filename) {{}}, }}; + require.cache = __perry_cjs_base_require.cache; + require.extensions = __perry_cjs_base_require.extensions; require.main = module;"# ); + let cjs_preamble = format!( + "{cjs_preamble}\n module.require = function moduleRequire(specifier) {{ return require(specifier); }};" + ); // Wall 54: self-register this compiled module's exports under its absolute // source path so a runtime `require(absolutePath.js)` (turbopack/Next.js // page+chunk loading) resolves to it. `{:?}` debug-quotes to a valid JS // string literal. let path_register = format!( - "__perry_register_path_module({:?}, __cjs_module.exports);", + "__cjs_module.loaded = true; __perry_register_path_module({:?}, __cjs_module);", + source_path.to_string_lossy() + ); + let path_register_early = format!( + "__perry_register_path_module({:?}, __cjs_module);", source_path.to_string_lossy() ); let wrapped = if let Some(flat_class) = &flat_default_class { @@ -887,6 +972,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( {import_aliases} {hoisted_class_block} {cjs_preamble} +{path_register_early} {body_for_iife} @@ -906,12 +992,16 @@ export {{ {flat_class} }}; {import_aliases} {hoisted_class_block} const _cjs = (function() {{ +function __perry_cjs_factory() {{ {cjs_preamble} + {path_register_early} {body_for_iife} {path_register} return __cjs_module.exports; +}} +return __perry_cjs_factory(); }})(); {default_export_decl} @@ -989,6 +1079,124 @@ fn target_node_platform(target: Option<&str>) -> Option<&'static str> { } } +fn cyclic_require_specs(source: &str, source_path: &Path) -> std::collections::HashSet { + let source_key = source_path + .canonicalize() + .unwrap_or_else(|_| source_path.to_path_buf()); + extract_require_specifiers(source) + .into_iter() + .filter(|specifier| { + let Some(target) = + super::super::resolve::resolve_relative_import_path(specifier, source_path) + else { + return false; + }; + require_graph_reaches(&target, &source_key, &mut std::collections::HashSet::new()) + }) + .collect() +} + +fn parent_sensitive_require_specs( + source: &str, + source_path: &Path, +) -> std::collections::HashSet { + extract_require_specifiers(source) + .into_iter() + .filter(|specifier| { + super::super::resolve::resolve_relative_import_path(specifier, source_path) + .and_then(|target| std::fs::read_to_string(target).ok()) + .is_some_and(|dependency| dependency.contains("module.parent")) + }) + .collect() +} + +fn cyclic_missing_property_names( + source: &str, + source_path: &Path, + specifier: &str, + target_path: &Path, +) -> Vec { + let aliases: Vec = extract_require_aliases_with_ranges(source) + .into_iter() + .filter(|(_, required, _)| required == specifier) + .map(|(alias, _, _)| alias) + .collect(); + if aliases.is_empty() { + return Vec::new(); + } + let Ok(target_source) = std::fs::read_to_string(target_path) else { + return Vec::new(); + }; + let cycle_at = extract_require_specifiers(&target_source) + .into_iter() + .filter(|required| { + super::super::resolve::resolve_relative_import_path(required, target_path).is_some_and( + |resolved| { + resolved.canonicalize().unwrap_or(resolved) + == source_path + .canonicalize() + .unwrap_or_else(|_| source_path.to_path_buf()) + }, + ) + }) + .filter_map(|required| { + let single = format!("require('{required}')"); + let double = format!("require(\"{required}\")"); + target_source + .find(&single) + .or_else(|| target_source.find(&double)) + }) + .min() + .unwrap_or(target_source.len()); + let assigned_before = regex::Regex::new( + r#"(?:^|[^A-Za-z0-9_$])(?:exports|module\.exports)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*="#, + ) + .expect("CJS export assignment regex") + .captures_iter(&target_source[..cycle_at]) + .filter_map(|capture| capture.get(1).map(|name| name.as_str().to_string())) + .collect::>(); + let masked_source = super::detect::strip_comments_and_strings(source); + let mut missing = std::collections::BTreeSet::new(); + for alias in aliases { + let access = regex::Regex::new(&format!( + r#"(?:^|[^A-Za-z0-9_$]){}\.([A-Za-z_$][A-Za-z0-9_$]*)"#, + regex::escape(&alias) + )) + .expect("CJS cyclic alias access regex"); + for capture in access.captures_iter(&masked_source) { + if let Some(property) = capture.get(1).map(|name| name.as_str()) { + if !assigned_before.contains(property) { + missing.insert(property.to_string()); + } + } + } + } + missing.into_iter().collect() +} + +fn require_graph_reaches( + path: &Path, + target: &Path, + visited: &mut std::collections::HashSet, +) -> bool { + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if path == target { + return true; + } + if !visited.insert(path.clone()) { + return false; + } + let Ok(source) = std::fs::read_to_string(&path) else { + return false; + }; + extract_require_specifiers(&source) + .into_iter() + .filter_map(|specifier| { + super::super::resolve::resolve_relative_import_path(&specifier, &path) + }) + .any(|dependency| require_graph_reaches(&dependency, target, visited)) +} + fn inactive_platform_guarded_requires( source: &str, target: Option<&str>, diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 8340583463..8bbcad2354 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -28,6 +28,7 @@ use super::{ mod binding_faithfulness; mod crypto_ns; +mod discovery; mod dynamic_glob; mod eval_worker; mod feature_detect; @@ -41,6 +42,8 @@ mod tests; mod wasm_asset; use binding_faithfulness::audit_native_binding_choice; +use discovery::collect_js_files_recursive; +pub(super) use discovery::is_nextjs_runtime_module; use dynamic_glob::expand_dynamic_import_glob; use eval_worker::materialize_eval_worker_source; pub(super) use import_helpers::known_node_submodule_key; @@ -54,40 +57,6 @@ use wasm_asset::{is_wasm_asset, synthesize_wasm_stub_module}; const MAX_CROSS_MODULE_INLINE_PRIOR_MODULES: usize = 128; -/// Next.js wall 54 (part 2): recursively gather every `*.js` file under `dir` -/// (page/route loaders + turbopack chunks). Symlinks are not followed; errors -/// reading a subdirectory are skipped silently (best-effort discovery). -fn collect_js_files_recursive(dir: &std::path::Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_dir() { - collect_js_files_recursive(&path, out); - } else if file_type.is_file() && path.extension().and_then(|e| e.to_str()) == Some("js") { - out.push(path); - } - } -} - -/// Next.js wall 54 (part 2): true for a module discovered under a standalone -/// bundle's `.next/server/**` tree (page/route/chunk modules loaded by a -/// runtime-computed path). Matched by the `.next` then `server` path-component -/// sequence so it never false-matches a user file merely named `next` or a -/// `node_modules/.next-*` package. Used by init classification (these modules -/// must be eager so they self-register under their path at startup) and topo -/// ordering (chunks before the page loaders that `R.c()` them). -pub(super) fn is_nextjs_runtime_module(path: &std::path::Path) -> bool { - let comps: Vec<&std::ffi::OsStr> = path.components().map(|c| c.as_os_str()).collect(); - comps - .windows(2) - .any(|w| w[0] == std::ffi::OsStr::new(".next") && w[1] == std::ffi::OsStr::new("server")) -} - /// Collect all modules to compile (transitive closure of imports) pub(super) fn collect_modules( entry_path: &PathBuf, @@ -249,7 +218,8 @@ fn collect_module_one( .components() .any(|c| c.as_os_str() == "node_modules"); let is_perry_native = is_in_node_modules && is_in_perry_native_package(&canonical); - let is_in_compiled_pkg = (is_in_node_modules && is_in_compile_package(&canonical, &ctx.compile_packages)) + let is_in_compiled_pkg = ctx.aot_discovered_modules.contains(&canonical) + || (is_in_node_modules && is_in_compile_package(&canonical, &ctx.compile_packages)) || ctx.compile_package_dirs.values().any(|dir| { if canonical.starts_with(dir) { // Exclude nested node_modules/ inside the compiled package @@ -434,7 +404,12 @@ fn collect_module_one( e )); } - format!("export default {};\n", raw_source.trim()) + let json_value = "__perry_json_default"; + format!( + "function __perry_json_factory() {{ return {}; }}\nconst {json_value} = __perry_json_factory();\nconst __perry_json_module = {{ __perry_cjs_record: true, __perry_cjs_factory: __perry_json_factory, exports: {json_value}, loaded: false }};\n__perry_register_path_module({:?}, __perry_json_module);\nexport default {json_value};\n", + raw_source.trim(), + canonical.to_string_lossy(), + ) } else if is_text_asset { // #5223: text-asset import. The file's contents are exposed verbatim as // the module's default export (a JS string). We never TS-parse the raw @@ -1308,7 +1283,24 @@ fn collect_module_one( { let resolved_path = resolved.canonical_path; let source_path = resolved.source_path; - let kind = resolved.kind; + // A resolved HIR import must be compiled; promote only that file so + // runtime-computed package loads retain the compilePackages boundary. + let package_is_authorized = + super::audit_manifest::package_name_for_path(&resolved_path.to_string_lossy()) + .is_none_or(|package| { + ctx.compile_packages.contains(&package) + && super::allowlist_matches(&package, &ctx.allow_compile_packages) + }); + let kind = if resolved.kind == ModuleKind::Interpreted + && !is_in_perry_native_package(&resolved_path) + && !is_declaration_file(&resolved_path) + && package_is_authorized + { + ctx.aot_discovered_modules.insert(resolved_path.clone()); + ModuleKind::NativeCompiled + } else { + resolved.kind + }; import.resolved_path = Some(resolved_path.to_string_lossy().to_string()); import.module_kind = kind; if let Some(sidecar) = @@ -1608,7 +1600,7 @@ fn collect_module_one( // an over-eager classification is self-correcting at runtime. Limited to // Perry-compiled (`NativeCompiled`) targets — native stdlib / V8 modules // have their own init paths. - if was_cjs_wrapped { + { for import in &mut hir_module.imports { if import.type_only || import.is_dynamic @@ -1628,11 +1620,11 @@ fn collect_module_one( if is_lazy { import.is_deferred_require = true; } - // #5257: every import here was synthesized from a `require('S')`, - // which under CJS returns the exports object — so a no-`default` - // target must route through the namespace machinery (#4872), not - // trip the static-ESM default gate. Tag so the gate skips them. - import.is_adopted_require = true; + if was_cjs_wrapped { + // #5257: wrapped `require('S')` imports follow CommonJS + // default/namespace interop rather than the static-ESM gate. + import.is_adopted_require = true; + } } } @@ -1665,7 +1657,15 @@ fn collect_module_one( { let resolved_path = resolved.canonical_path; let source_path = resolved.source_path; - let kind = resolved.kind; + let kind = if resolved.kind == ModuleKind::Interpreted + && !is_in_perry_native_package(&resolved_path) + && !is_declaration_file(&resolved_path) + { + ctx.aot_discovered_modules.insert(resolved_path.clone()); + ModuleKind::NativeCompiled + } else { + resolved.kind + }; if let Some(sidecar) = declaration_sidecar_for_resolved_import(src.as_str(), &resolved_path) { diff --git a/crates/perry/src/commands/compile/collect_modules/discovery.rs b/crates/perry/src/commands/compile/collect_modules/discovery.rs new file mode 100644 index 0000000000..b25441ab0f --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/discovery.rs @@ -0,0 +1,28 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +/// Recursively gather every JavaScript file under a Next.js server bundle. +pub(super) fn collect_js_files_recursive(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + collect_js_files_recursive(&path, out); + } else if file_type.is_file() && path.extension().and_then(|e| e.to_str()) == Some("js") { + out.push(path); + } + } +} + +/// Return whether a path belongs to a standalone `.next/server/**` bundle. +pub(crate) fn is_nextjs_runtime_module(path: &Path) -> bool { + let comps: Vec<&std::ffi::OsStr> = path.components().map(|c| c.as_os_str()).collect(); + comps + .windows(2) + .any(|w| w[0] == std::ffi::OsStr::new(".next") && w[1] == std::ffi::OsStr::new("server")) +} diff --git a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs index 6d4d0ba9cf..225be9d3d5 100644 --- a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs +++ b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs @@ -3,6 +3,8 @@ use std::path::Path; use std::sync::OnceLock; use regex::Regex; +use serde::de::{MapAccess, SeqAccess, Visitor}; +use serde::Deserialize; use super::parse_package_specifier; use crate::commands::compile::cjs_wrap::detect::strip_comments_and_strings; @@ -56,13 +58,56 @@ pub(super) fn transform_static_literal_requires( let mut imported_specs = HashMap::new(); let mut imports = Vec::new(); + let mut discovered_side_effects = HashSet::new(); let mut replacements = Vec::new(); let mut next_id = 0usize; for alias in require_aliases { + for cap in literal_require_resolve_call_re(&alias).captures_iter(source) { + let Some(full) = cap.name("call") else { + continue; + }; + if masked_source[full.start()..full.end()] + .bytes() + .all(|b| b.is_ascii_whitespace()) + { + continue; + } + let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); + if let Some(target) = resolve_static_require(module_dir, specifier) { + if discovered_side_effects.insert(target.clone()) { + let binding = unique_lazy_require_name(source, &mut next_id); + imports.push(format!( + "import {binding} from {:?};", + target.to_string_lossy() + )); + } + } + } let call_re = literal_require_call_re(&alias); for cap in call_re.captures_iter(source) { let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); + let require_target = resolve_static_require(module_dir, specifier); if should_leave_runtime_require(specifier, compile_packages) { + if let Some(target) = require_target.as_ref() { + if discovered_side_effects.insert(target.clone()) { + let binding = unique_lazy_require_name(source, &mut next_id); + imports.push(format!( + "import {binding} from {:?};", + target.to_string_lossy() + )); + } + } + continue; + } + // A missing file or an exports-blocked package subpath is a runtime + // createRequire error, never a hard AOT import. This also covers + // callbacks declared outside the `try` that eventually invokes + // them; lexical try detection alone cannot see that control flow. + if alias != "require" + && require_target.is_none() + && (is_relative_or_absolute_specifier(specifier) + || package_subpath_is_blocked(module_dir, specifier)) + { continue; } // #6873: hoisting `try { x = require("./gen") } catch {}` to a @@ -75,9 +120,7 @@ pub(super) fn transform_static_literal_requires( // // Resolvable optional requires keep being hoisted, so a module // that IS present still gets compiled in and loads. - if optional_specs.get(specifier).copied().unwrap_or(false) - && !relative_specifier_resolves(module_dir, specifier) - { + if optional_specs.get(specifier).copied().unwrap_or(false) && require_target.is_none() { continue; } let Some(full) = cap.name("call") else { @@ -89,6 +132,27 @@ pub(super) fn transform_static_literal_requires( { continue; } + // CJS, JSON, native addons, and custom extensions need the runtime + // `require` path: it owns Node's cache/record/extension-hook + // semantics. The side-effect import only makes the statically-known + // module part of the AOT graph. + if let Some(target) = require_target.as_ref() { + let is_native_addon = + target.extension().and_then(|extension| extension.to_str()) == Some("node"); + if !matches!( + target.extension().and_then(|e| e.to_str()), + Some("ts" | "tsx" | "mts" | "cts" | "js" | "mjs") + ) { + if !is_native_addon && discovered_side_effects.insert(target.clone()) { + let binding = unique_lazy_require_name(source, &mut next_id); + imports.push(format!( + "import {binding} from {:?};", + target.to_string_lossy() + )); + } + continue; + } + } let temp = imported_specs .entry(specifier.to_string()) .or_insert_with(|| { @@ -112,6 +176,213 @@ pub(super) fn transform_static_literal_requires( prepend_imports_preserving_shebang(&transformed, &imports) } +fn resolve_static_require(module_dir: &Path, specifier: &str) -> Option { + if is_relative_or_absolute_specifier(specifier) { + let base = if std::path::Path::new(specifier).is_absolute() { + std::path::PathBuf::from(specifier) + } else { + module_dir.join(specifier) + }; + return resolve_require_path(&base); + } + + let (package, subpath) = super::parse_package_specifier(specifier); + for node_modules in crate::commands::compile::resolve::ancestor_node_modules_dirs(module_dir) { + let package_dir = node_modules.join(&package); + if !package_dir.is_dir() { + continue; + } + let package_json = std::fs::read_to_string(package_dir.join("package.json")).ok(); + if let Some(text) = package_json { + let manifest: PackageManifest = serde_json::from_str(&text).ok()?; + if let Some(exports) = manifest.exports.as_ref() { + let key = subpath + .as_deref() + .map(|s| format!("./{s}")) + .unwrap_or_else(|| ".".to_string()); + let target = resolve_require_exports(exports, &key)?; + return resolve_require_path(&package_dir.join(target)); + } + if let Some(subpath) = subpath.as_deref() { + return resolve_require_path(&package_dir.join(subpath)); + } + if let Some(main) = manifest.main.as_ref().and_then(serde_json::Value::as_str) { + if let Some(path) = resolve_require_path(&package_dir.join(main)) { + return Some(path); + } + } + } + return resolve_require_path(&package_dir); + } + None +} + +fn package_subpath_is_blocked(module_dir: &Path, specifier: &str) -> bool { + if is_relative_or_absolute_specifier(specifier) { + return false; + } + let (package, subpath) = super::parse_package_specifier(specifier); + for node_modules in crate::commands::compile::resolve::ancestor_node_modules_dirs(module_dir) { + let package_dir = node_modules.join(&package); + if !package_dir.is_dir() { + continue; + } + let Ok(text) = std::fs::read_to_string(package_dir.join("package.json")) else { + return false; + }; + let Ok(manifest) = serde_json::from_str::(&text) else { + return false; + }; + let Some(exports) = manifest.exports.as_ref() else { + return false; + }; + let key = subpath + .as_deref() + .map(|path| format!("./{path}")) + .unwrap_or_else(|| ".".to_string()); + return resolve_require_exports(exports, &key).is_none(); + } + false +} + +fn resolve_require_path(path: &Path) -> Option { + if path.is_file() { + return path.canonicalize().ok(); + } + for ext in ["ts", "tsx", "mts", "cts", "js", "json", "node", "cjs"] { + let mut candidate = path.as_os_str().to_os_string(); + candidate.push("."); + candidate.push(ext); + let candidate = std::path::PathBuf::from(candidate); + if candidate.is_file() { + return candidate.canonicalize().ok(); + } + } + if path.is_dir() { + if let Ok(text) = std::fs::read_to_string(path.join("package.json")) { + if let Ok(manifest) = serde_json::from_str::(&text) { + if let Some(main) = manifest.main.as_ref().and_then(serde_json::Value::as_str) { + if let Some(found) = resolve_require_path(&path.join(main)) { + return Some(found); + } + } + } + } + for ext in ["ts", "tsx", "mts", "cts", "js", "json", "node", "cjs"] { + let candidate = path.join(format!("index.{ext}")); + if candidate.is_file() { + return candidate.canonicalize().ok(); + } + } + } + None +} + +#[derive(Deserialize)] +struct PackageManifest { + main: Option, + exports: Option, +} + +enum OrderedJson { + String(String), + Array(Vec), + Object(Vec<(String, Self)>), + Other, +} + +impl<'de> Deserialize<'de> for OrderedJson { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct OrderedJsonVisitor; + + impl<'de> Visitor<'de> for OrderedJsonVisitor { + type Value = OrderedJson; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_str(self, value: &str) -> Result { + Ok(OrderedJson::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(OrderedJson::String(value)) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = seq.next_element()? { + values.push(value); + } + Ok(OrderedJson::Array(values)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = map.next_entry()? { + values.push(value); + } + Ok(OrderedJson::Object(values)) + } + + fn visit_bool(self, _: bool) -> Result { + Ok(OrderedJson::Other) + } + + fn visit_i64(self, _: i64) -> Result { + Ok(OrderedJson::Other) + } + + fn visit_u64(self, _: u64) -> Result { + Ok(OrderedJson::Other) + } + + fn visit_f64(self, _: f64) -> Result { + Ok(OrderedJson::Other) + } + + fn visit_unit(self) -> Result { + Ok(OrderedJson::Other) + } + } + + deserializer.deserialize_any(OrderedJsonVisitor) + } +} + +fn resolve_require_exports(exports: &OrderedJson, key: &str) -> Option { + match exports { + OrderedJson::String(target) => Some(target.clone()), + OrderedJson::Array(items) => items + .iter() + .find_map(|item| resolve_require_exports(item, key)), + OrderedJson::Object(map) => { + if let Some((_, target)) = map.iter().find(|(name, _)| name == key) { + return resolve_require_exports(target, key); + } + for (condition, target) in map { + if matches!(condition.as_str(), "node" | "require" | "default") { + if let Some(resolved) = resolve_require_exports(target, key) { + return Some(resolved); + } + } + } + None + } + _ => None, + } +} + fn prepend_imports_preserving_shebang(source: &str, imports: &[String]) -> String { let mut prefix = imports.join("\n"); prefix.push('\n'); @@ -192,6 +463,14 @@ fn literal_require_call_re(require_alias: &str) -> Regex { .expect("static require literal call regex") } +fn literal_require_resolve_call_re(require_alias: &str) -> Regex { + Regex::new(&format!( + r#"(?m)(?:^|[^A-Za-z0-9_$\.])(?P{}\.resolve\s*\(\s*['\"](?P[^'\"]+)['\"]\s*\))"#, + regex::escape(require_alias) + )) + .expect("static require.resolve literal call regex") +} + fn should_leave_runtime_require(specifier: &str, compile_packages: &HashSet) -> bool { if perry_hir::is_native_module(specifier) { return true; @@ -236,27 +515,14 @@ fn unique_temp_name(source: &str, next_id: &mut usize) -> String { } } -/// Does a relative/absolute `specifier` name a file that exists next to the -/// module being compiled? Mirrors the extension set the module resolver tries. -/// Non-relative specifiers return `true` so they keep today's hoisting. -fn relative_specifier_resolves(module_dir: &Path, specifier: &str) -> bool { - if !is_relative_or_absolute_specifier(specifier) { - return true; - } - const EXTENSIONS: [&str; 8] = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]; - let base = module_dir.join(specifier); - if base.is_file() { - return true; - } - for ext in EXTENSIONS { - if base.with_extension(ext).is_file() { - return true; - } - if base.join(format!("index.{ext}")).is_file() { - return true; +fn unique_lazy_require_name(source: &str, next_id: &mut usize) -> String { + loop { + let name = format!("_lazyreq_static_{}", *next_id); + *next_id += 1; + if !source.contains(&name) { + return name; } } - false } /// Is byte offset `at` lexically inside the block of a `try { ... }`? diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index b05aa58145..bc0ada3b5b 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1841,6 +1841,16 @@ pub fn run_with_parse_cache( path_to_module_name.insert(path.clone(), hir_module.name.clone()); module_name_to_path.insert(hir_module.name.clone(), path.clone()); } + // The CJS wrapper always materializes the evaluated `module.exports` as + // the synthetic top-level `_cjs` binding. Keep this structural check here + // (rather than keying on `.cjs`) because CommonJS packages commonly use + // `.js` files too. + let is_wrapped_cjs = |module: &perry_hir::Module| { + module + .init + .iter() + .any(|stmt| matches!(stmt, perry_hir::Stmt::Let { name, .. } if name == "_cjs")) + }; // Build a normalized HIR-by-name map for `flatten_exports`. Each // module's `Export::ReExport::source`, `Export::ExportAll::source`, // and `Export::NamespaceReExport::source` strings hold the raw @@ -2057,6 +2067,31 @@ pub fn run_with_parse_cache( kind, }); } + if is_wrapped_cjs(target_hir) { + // Node's CJS namespace has two aliases for the exact evaluated + // `module.exports` object. Do not clone/project it: identity with + // require(), the default import, and shared nested values matters. + let default_kind = entries + .iter() + .find(|entry| entry.name == "default") + .map(|entry| entry.kind.clone()) + .unwrap_or_else(|| perry_codegen::NamespaceEntryKind::ForeignVar { + source_prefix: sanitize_module_name(&target_hir.name), + source_local: "default".to_string(), + }); + if !entries.iter().any(|entry| entry.name == "default") { + entries.push(perry_codegen::NamespaceEntry { + name: "default".to_string(), + kind: default_kind.clone(), + }); + } + if !entries.iter().any(|entry| entry.name == "module.exports") { + entries.push(perry_codegen::NamespaceEntry { + name: "module.exports".to_string(), + kind: default_kind, + }); + } + } per_module_namespace_entries.insert(target_path.clone(), entries); } // For each consumer module, map every `Expr::DynamicImport` arg-path @@ -2242,8 +2277,9 @@ pub fn run_with_parse_cache( let nextjs_path_init_modules: Vec<(String, String)> = if is_entry { ctx.native_modules .iter() - .filter(|(p, _)| { - self::collect_modules::is_nextjs_runtime_module(p) + .filter(|(p, module)| { + module.init_kind == perry_hir::ModuleInitKind::Deferred + || self::collect_modules::is_nextjs_runtime_module(p) // A `perry.compilePackages` module may be reachable // ONLY through a runtime-computed require (Next's // require-hook aliases `styled-jsx` to its resolved @@ -2865,6 +2901,52 @@ pub fn run_with_parse_cache( } } } + if source_module.is_some_and(|module| is_wrapped_cjs(module)) { + let default_prefix = compute_module_prefix( + &resolved_path_str, + &ctx.project_root, + ); + let default_is_var = all_module_exports + .get(&resolved_path_str) + .and_then(|exports| exports.get("default")) + .is_some_and(|origin_path| { + let origin_name = all_module_export_origin_names + .get(&resolved_path_str) + .and_then(|names| names.get("default")) + .cloned() + .unwrap_or_else(|| "default".to_string()); + exported_var_names + .contains(&(origin_path.clone(), origin_name)) + }) + || exported_var_names.contains(&( + resolved_path_str.clone(), + "default".to_string(), + )); + // CJS namespace exotic objects expose both names + // as aliases of the evaluated exports object. The + // per-namespace origin map makes `module.exports` + // call the existing `default` getter rather than a + // nonexistent dotted symbol. + for member in ["default", "module.exports"] { + namespace_member_prefixes.insert( + (local.clone(), member.to_string()), + default_prefix.clone(), + ); + namespace_member_origin_names.insert( + (local.clone(), member.to_string()), + "default".to_string(), + ); + import_function_prefixes + .entry(member.to_string()) + .or_insert_with(|| default_prefix.clone()); + import_function_origin_names + .entry(member.to_string()) + .or_insert_with(|| "default".to_string()); + if member == "module.exports" || default_is_var { + imported_vars.insert(member.to_string()); + } + } + } continue; } diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index a89ecdfa6e..b30c39a17f 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -611,6 +611,11 @@ pub struct CompilationContext { pub package_aliases: HashMap, /// Packages to compile natively instead of routing to V8 (from perry.compilePackages) pub compile_packages: HashSet, + /// JavaScript package entry files reached through a statically resolved + /// import edge. Perry has no runtime JavaScript engine, so these exact + /// graph members must re-enter the native AOT collector even when their + /// containing package was not opted into wholesale via `compilePackages`. + pub aot_discovered_modules: HashSet, /// #5731 — assets to embed into the standalone executable, as /// `(embed-relative name, absolute source path)` pairs. Populated by /// merging the `--embed` flag with `perry.embed` / `[compile] embed` and @@ -1100,6 +1105,7 @@ impl CompilationContext { native_libraries: Vec::new(), package_aliases: HashMap::new(), compile_packages: HashSet::new(), + aot_discovered_modules: HashSet::new(), embedded_assets: Vec::new(), precompile_capture: false, precompile_results: HashMap::new(),