From 8030ccc3fe1bec7bb39f5568d311c30e18abd8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:03:35 +0200 Subject: [PATCH 1/4] fix(cjs): resolve computed relative requires against the module's own directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next's production webpack runtime loads lazy chunks with a computed relative specifier (`require("./chunks/" + g.u(a))` from `.next/server/webpack-runtime.js`). The CJS shim handed that raw string to the path->module registry, which is keyed by absolute source path, so every lazy chunk missed and the compiled App Route died at startup with `Cannot find module './chunks/2.js'` — despite that chunk being compiled into the image. Static relative specifiers are resolved at compile time and never reach this branch, so only computed ones need the join. Strip `./` textually rather than leaning on std::fs::canonicalize: it only normalizes paths that exist on disk, and registration falls back to the raw string when they do not. Refs #8040, #5438. --- .../8040-runtime-relative-chunk-require.md | 9 +++++++ .../src/commands/compile/cjs_wrap/wrap.rs | 27 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 changelog.d/8040-runtime-relative-chunk-require.md 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..aa3fe58275 --- /dev/null +++ b/changelog.d/8040-runtime-relative-chunk-require.md @@ -0,0 +1,9 @@ +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. 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 `/./chunks/2.js` would miss `/chunks/2.js` + // in exactly the deployed case where the sources are absent. + var __perry_path_spec = specifier; + if (specifier.charCodeAt(0) === 46) {{ + if (specifier.charCodeAt(1) === 47) {{ + __perry_path_spec = {module_dir_literal} + '/' + specifier.slice(2); + }} else if (specifier.charCodeAt(1) === 46 && specifier.charCodeAt(2) === 47) {{ + __perry_path_spec = {module_dir_literal} + '/' + specifier; + }} + }} + const __perry_path_mod = __perry_require_path_module(__perry_path_spec); + if (__perry_path_mod !== undefined || __perry_has_path_module(__perry_path_spec)) return __perry_path_mod; }} // Runtime `require(absolutePath)` of a `.json` file (Next.js loads // manifests this way: `require(this.middlewareManifestPath)`). Node's From 963cc75bc2f6bca1cfd561ad313504312c8970fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:11:46 +0200 Subject: [PATCH 2/4] test(cjs): pin the computed-relative-require join Asserts the registry lookup uses the joined `__perry_path_spec` AND that the raw-`specifier` form is gone, so a revert fails it in both directions. Both strings are decided by the fix itself, unlike an earlier attempt on the RS4GC side that passed because the pass under test never ran. Also asserts the registry branch and the module-dir literal still exist, so the test cannot pass by being about code that no longer exists. Refs #8040. --- .../compile/cjs_wrap/preamble_canary_tests.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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..df5b62298c 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 @@ -214,3 +214,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)" + ); +} From 2645516877bd8f8f473857c6c80e57d1bc9dfe5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:58:39 +0200 Subject: [PATCH 3/4] fix(codegen): record path->init addresses before the eager init loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perry_module_init called every non-entry module's __init first and only then emitted the js_register_path_init calls. A module that performs a runtime path-require during its own eager init therefore queried an empty init registry: Next's webpack-runtime loads chunk 2 while initializing, missed, and the App Route died with `Cannot find module './chunks/2.js'` — moments before that chunk's init address would have been recorded. Recording runs no init, only ptrtoint bookkeeping, so hoisting it above the eager-init loop is safe by the emission's own reasoning. Verified in the emitted object, not just the source: `otool -tV` on the app dylib previously showed 103 `js_register_path_init` call sites but only ONE executing at runtime, because an interleaved `register, init, register, init` sequence let the first module's init throw before the rest were recorded. After this change all 103 execute before any init, chunks/2.js is registered, and path-require misses go from 1 to 0. Refs #8040, #5438. --- .../8040-runtime-relative-chunk-require.md | 6 ++++++ crates/perry-codegen/src/codegen/entry.rs | 20 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/changelog.d/8040-runtime-relative-chunk-require.md b/changelog.d/8040-runtime-relative-chunk-require.md index aa3fe58275..27c7b32b9f 100644 --- a/changelog.d/8040-runtime-relative-chunk-require.md +++ b/changelog.d/8040-runtime-relative-chunk-require.md @@ -7,3 +7,9 @@ The result was that every lazy chunk was unreachable at runtime even though it h 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 From 48c1b7aeda1766cd99ae094e94d91bf5d081d2dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 18:23:57 +0200 Subject: [PATCH 4/4] test(cjs): the presence probe follows the joined specifier `path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined` pinned the shim's registry line by its literal text, which named `specifier`. Joining a computed relative request against the module directory renamed that operand to `__perry_path_spec`, so the assertion no longer matched. Updated to the current text, with the reason spelled out: the value lookup and the presence probe must consult the SAME resolved specifier, or an exists-but-undefined export is read from a different path than its value. Refs #8040. --- .../commands/compile/cjs_wrap/preamble_canary_tests.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 df5b62298c..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}" );