diff --git a/changelog.d/8040-runtime-relative-chunk-require.md b/changelog.d/8040-runtime-relative-chunk-require.md new file mode 100644 index 0000000000..27c7b32b9f --- /dev/null +++ b/changelog.d/8040-runtime-relative-chunk-require.md @@ -0,0 +1,15 @@ +A compiled production Next.js App Route no longer dies at startup with `Cannot find module './chunks/2.js'`. + +Next's production webpack runtime loads lazy chunks by a *computed* relative specifier — `.next/server/webpack-runtime.js` calls `require("./chunks/" + g.u(a))`. Perry's CJS `require` shim passed that string straight to the path→module registry, which is keyed by each module's **absolute** source path, so the lookup could never hit. Statically-known relative specifiers are resolved at compile time and never reach that branch; only computed ones do, which is why this only showed up on the real production route. + +The result was that every lazy chunk was unreachable at runtime even though it had been compiled into the image — all 104 modules of the #8034 fixture produced object files, `chunks/2.js` among them, and the host still exited during startup. + +Computed relative specifiers are now joined against the requiring module's own directory before the registry lookup. The `./` prefix is stripped textually rather than left to `std::fs::canonicalize`, which only normalizes paths that exist on disk while registration falls back to the raw string when they do not — relying on it would work from a source tree and silently fail in the deployed case the dylib packaging exists for. + +Refs #8040, #5438. + +A second defect sat behind the first. Even with the correct absolute key, the lookup missed: `perry_module_init` ran every eager module init *before* recording the path→init addresses, so a module performing a runtime path-require during its own init — which is exactly when Next's `webpack-runtime` loads a chunk — queried an init registry that was still empty. The addresses it needed were recorded a few instructions later. + +Recording is pure bookkeeping (no init runs at that point), so it now happens before the eager-init loop. + +Both defects had to be fixed to get past startup; either alone still fails, which is why the first fix alone showed no improvement. diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 861d1823c0..ecb57bc9f0 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -630,12 +630,14 @@ pub(super) fn compile_module_entry( if !nextjs_path_inits.is_empty() { blk.call_void("js_globalthis_seed_async_local_storage", &[]); } - for prefix in non_entry_module_prefixes { - if cross_module.deferred_module_prefixes.contains(prefix) { - continue; - } - blk.call_void(&format!("{}__init", prefix), &[]); - } + // #8040: record the path->init addresses BEFORE the eager init + // loop below. Recording is pure bookkeeping — "No init runs here, + // only the address is recorded" — but a module that performs a + // runtime path-require DURING its own eager init (Next's + // webpack-runtime loads chunk 2 while initializing) previously hit + // an empty init registry and died with MODULE_NOT_FOUND, even + // though the chunk was compiled and its init address was about to + // be recorded a few instructions later. // Next.js wall 54 (part 2): record each Deferred `.next/server/**` // module's `__init` address under its absolute path so a runtime // `require(absolutePath)` (turbopack page/chunk loading) can trigger @@ -655,6 +657,12 @@ pub(super) fn compile_module_entry( ], ); } + for prefix in non_entry_module_prefixes { + if cross_module.deferred_module_prefixes.contains(prefix) { + continue; + } + blk.call_void(&format!("{}__init", prefix), &[]); + } } // Mark the boundary between init prelude and user code so // hoisted post-init setup (cached `@perry_class_keys_*` loads diff --git a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs index 75ba6f15d7..249ac98013 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs @@ -204,8 +204,15 @@ fn path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined() .rfind("__perry_register_path_module(") .expect("CJS wrapper must publish its final module.exports value"); assert!(partial < body && body < final_publish, "{wrapped}"); + // #8040: both the value lookup and the presence probe must consult the + // SAME resolved specifier. A computed relative request is joined against + // the module's directory before either call (`__perry_path_spec`), so a + // mismatch here would resolve the value from one path and the + // exists-but-undefined bit from another. assert!( - wrapped.contains("__perry_path_mod !== undefined || __perry_has_path_module(specifier)"), + wrapped.contains( + "__perry_path_mod !== undefined || __perry_has_path_module(__perry_path_spec)" + ), "an exported undefined value must not be mistaken for a registry miss\n{wrapped}" ); @@ -214,3 +221,43 @@ fn path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined() let ast = perry_parser::parse_typescript(&wrapped, "lazy.js").unwrap(); perry_hir::lower_module(&ast, "lazy", &path.to_string_lossy()).unwrap(); } + +/// #8040: Next's production webpack runtime loads lazy chunks with a *computed* +/// relative specifier — `.next/server/webpack-runtime.js` calls +/// `require("./chunks/" + g.u(a))`. The path->module registry is keyed by each +/// module's ABSOLUTE source path, so handing it the raw `./chunks/2.js` could +/// never hit: the compiled App Route died at startup with +/// `Cannot find module './chunks/2.js'` even though that chunk had been +/// compiled into the image alongside the other 103 modules. +/// +/// Statically-known relative specifiers are resolved at compile time and never +/// reach that branch, which is why only the real production route exposed it. +#[test] +fn computed_relative_requires_are_joined_against_the_module_dir() { + let path = Path::new("/tmp/perry-canary/.next/server/webpack-runtime.js"); + let wrapped = wrap_commonjs_for_target(CJS_FIXTURE, path, None); + + // Anti-vacuity: if the wrap stops consulting the registry at all, the + // assertions below would be about a branch that no longer exists. + assert!( + wrapped.contains("__perry_require_path_module("), + "the wrap no longer consults the path->module registry:\n{wrapped}" + ); + // The join needs the module's own directory as a literal. + assert!( + wrapped.contains("/tmp/perry-canary/.next/server"), + "the wrap lost the module-dir literal the join needs:\n{wrapped}" + ); + // The registry lookup must use the JOINED path... + assert!( + wrapped.contains("__perry_require_path_module(__perry_path_spec)"), + "computed relative requires are not joined before the registry lookup (#8040):\n{wrapped}" + ); + // ...and must not still be handed the raw specifier, which is the shape + // that made every lazy chunk miss. + assert!( + !wrapped.contains("__perry_require_path_module(specifier)"), + "the raw-specifier registry lookup is still present; a computed \ + './chunks/N.js' will miss it (#8040)" + ); +} diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 3112ef1392..700ff71836 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -828,8 +828,31 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // compiled module self-registers into at init; `undefined` = not // registered, fall through to the `.json` read / MODULE_NOT_FOUND throw. {{ - const __perry_path_mod = __perry_require_path_module(specifier); - if (__perry_path_mod !== undefined || __perry_has_path_module(specifier)) return __perry_path_mod; + // A runtime-COMPUTED *relative* specifier never matches that + // registry, which is keyed by absolute source path. Next's + // production webpack runtime does exactly this — `.next/server/ + // webpack-runtime.js` calls `require("./chunks/" + g.u(a))` — so + // every lazy chunk missed and the App Route died at startup with + // `Cannot find module './chunks/2.js'` even though that chunk WAS + // compiled into the image. Statically-known relative specifiers are + // already handled by the cases above; only computed ones reach + // here, so join them against this module's own directory. + // + // The `./` prefix is stripped textually rather than left to + // `std::fs::canonicalize`: that only normalizes a path that exists + // on disk, and registration falls back to the raw string when it + // does not, so `