From b9b517281d92f6a5b90fce8ac3134ad5e2f13b2a Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 20 Aug 2026 20:42:52 -0300 Subject: [PATCH] fix(cli): repair hollow externalized package dirs in nested distDir node_modules (#7346) --- ...6-electron-hollow-nested-package-repair.md | 1 + scripts/build/assembleStandalone.mjs | 28 +++++++--- ...empty-external-package-dirs-nested.test.ts | 55 +++++++++++++++++++ 3 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fixes/7346-electron-hollow-nested-package-repair.md create mode 100644 tests/unit/build/repair-empty-external-package-dirs-nested.test.ts diff --git a/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md b/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md new file mode 100644 index 0000000000..fd7e61988b --- /dev/null +++ b/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md @@ -0,0 +1 @@ +- fix(cli): repair hollow externalized package dirs in the nested `/node_modules` bundle location too, not just the top-level one, fixing macOS/Linux Electron `ERR_MODULE_NOT_FOUND` on Turbopack-externalized packages (#7346) diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index b4c8d12c1f..ee8d730ccf 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -628,12 +628,11 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { * This keeps the fix narrowly scoped to packages the standalone already expects. * * @param {string} projectRoot - * @param {string} resolvedOutDir + * @param {string} bundleNodeModules * @returns {{repaired: number, packages: string[]}} */ -function repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir) { +function repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules) { const summary = { repaired: 0, packages: [] }; - const bundleNodeModules = path.join(resolvedOutDir, "node_modules"); const sourceNodeModules = path.join(projectRoot, "node_modules"); if (!fsSync.existsSync(bundleNodeModules) || !fsSync.existsSync(sourceNodeModules)) { return summary; @@ -899,12 +898,23 @@ export function assembleStandalone({ // 6. Optionally copy native assets + extra modules (synchronous) if (copyNatives) { copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir); - const emptyPkgRepair = repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir); - if (emptyPkgRepair.repaired > 0) { - console.log( - `[assembleStandalone] Repaired ${emptyPkgRepair.repaired} hollow external package dir(s): ` + - emptyPkgRepair.packages.join(", ") - ); + // Repair hollow externalized package dirs in BOTH locations Turbopack's standalone + // tracer can populate: the top-level bundle node_modules, and — for projects with a + // custom distDir (see next.config.mjs) — the nested /node_modules mirrored + // alongside the traced server chunks. materializeBundledSymlinks (step 7 below) already + // treats these as two distinct targets; #9913 only covered the top-level one, which left + // the nested location's hollow dirs unrepaired (#7346). + for (const bundleNodeModules of [ + path.join(resolvedOutDir, "node_modules"), + path.join(resolvedOutDir, relDistDir, "node_modules"), + ]) { + const emptyPkgRepair = repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules); + if (emptyPkgRepair.repaired > 0) { + console.log( + `[assembleStandalone] Repaired ${emptyPkgRepair.repaired} hollow external package dir(s) in ` + + `${path.relative(resolvedOutDir, bundleNodeModules) || "."}: ${emptyPkgRepair.packages.join(", ")}` + ); + } } // #9166: dynamically imported LLMLingua packages are not reliably traced diff --git a/tests/unit/build/repair-empty-external-package-dirs-nested.test.ts b/tests/unit/build/repair-empty-external-package-dirs-nested.test.ts new file mode 100644 index 0000000000..50af15cadc --- /dev/null +++ b/tests/unit/build/repair-empty-external-package-dirs-nested.test.ts @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { assembleStandalone } from "../../../scripts/build/assembleStandalone.mjs"; + +// #7346: on macOS (and Linux AppImage) Electron builds, Turbopack's standalone tracer can leave +// a hollow (directory exists, contains zero files) externalized-package directory behind. #9913 +// added `repairEmptyExternalPackageDirs` to overlay the real source package on top of a hollow +// bundle dir — but it only scans the TOP-LEVEL `/node_modules`. This project builds with +// a custom, non-default `distDir` (".build/next", see next.config.mjs), and Next's standalone +// tracer also emits a SECOND, nested `node_modules` under `//node_modules` +// (the same location `materializeBundledSymlinks` already treats as a distinct target — see +// assembleStandalone() step 7). A hollow externalized package dir landing in that nested +// location is never repaired, which reproduces the exact ERR_MODULE_NOT_FOUND class reported on +// #7346 even after #6794/#7353/#9913 all landed. +test("assembleStandalone repairs a hollow externalized package dir in the nested node_modules, not just the top-level one", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "repair-nested-empty-pkg-")); + const projectRoot = path.join(tmp, "project"); + const relDistDir = ".build/next"; + const distDir = path.join(projectRoot, relDistDir); + const outDir = path.join(tmp, "dist"); + + // Real source package the repair should copy from. + const sourcePkgDir = path.join(projectRoot, "node_modules", "some-nested-pkg"); + fs.mkdirSync(sourcePkgDir, { recursive: true }); + fs.writeFileSync(path.join(sourcePkgDir, "package.json"), '{"name":"some-nested-pkg"}'); + fs.writeFileSync(path.join(sourcePkgDir, "index.js"), "module.exports = {};"); + + // Fake standalone tree with a hollow externalized package dir under the NESTED + // /node_modules (directory exists but contains zero files — the exact + // "hollow" shape repairEmptyExternalPackageDirs already repairs at the top level). + const standaloneDir = path.join(distDir, "standalone"); + fs.mkdirSync(standaloneDir, { recursive: true }); + fs.writeFileSync(path.join(standaloneDir, "server.js"), "// server"); + const hollowNestedPkgDir = path.join(standaloneDir, relDistDir, "node_modules", "some-nested-pkg"); + fs.mkdirSync(hollowNestedPkgDir, { recursive: true }); + + assembleStandalone({ + distDir, + outDir, + projectRoot, + copyNatives: true, + }); + + const repairedIndexPath = path.join(outDir, relDistDir, "node_modules", "some-nested-pkg", "index.js"); + assert.ok( + fs.existsSync(repairedIndexPath), + "hollow nested externalized package dir must be repaired with the real source package (index.js present)" + ); + + fs.rmSync(tmp, { recursive: true, force: true }); +});