fix(build): assemble static/server-files/chunks under distDir, not literal .next

CRITICAL white-screen bug from the build-output-isolation refactor: the standalone
server.js bakes distDir ("./.build/next") into its config and serves /_next/static
from <root>/.build/next/static — but assembleStandalone hard-coded the destination
to <outDir>/.next/static (+ sanitised/patched <outDir>/.next/{required-server-files,
server}). Result: the server's static dir was EMPTY → every JS/CSS chunk 404'd →
blank login page (health stayed 200, so it slipped past the health-only dry-run).

Mirror the distDir path (relative to projectRoot) for static, required-server-files
sanitization (was a silent no-op → 0 paths sanitised, now 11), and the Turbopack
chunk patch. Verified: booting the assembled bundle serves the webpack chunk 200.
Affects every consumer (npm/Docker/Electron/VPS).
This commit is contained in:
diegosouzapw
2026-06-04 02:17:51 -03:00
parent b6bda19919
commit 49dedecc42
2 changed files with 30 additions and 9 deletions

View File

@@ -283,11 +283,13 @@ async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) {
* @param {string} outDir - assembled standalone output directory
* @returns {number} number of path replacements made
*/
export function assemblePathSanitize(projectRoot, outDir) {
export function assemblePathSanitize(projectRoot, outDir, distDir = ".next") {
const buildRoot = projectRoot.replace(/\\/g, "/"); // normalise for regex safety
const sanitizeTargets = [
path.join(outDir, "server.js"),
path.join(outDir, ".next", "required-server-files.json"),
// required-server-files.json lives under the distDir (e.g. .build/next), not
// a literal .next — the standalone preserves the configured distDir path.
path.join(outDir, distDir, "required-server-files.json"),
];
let sanitisedCount = 0;
@@ -316,8 +318,8 @@ export function assemblePathSanitize(projectRoot, outDir) {
* @param {string} outDir - assembled standalone output directory
* @returns {{ patchedFiles: number, patchedMatches: number }}
*/
export function patchTurbopackChunks(outDir) {
const serverOutput = path.join(outDir, ".next", "server");
export function patchTurbopackChunks(outDir, distDir = ".next") {
const serverOutput = path.join(outDir, distDir, "server");
const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+?-[0-9a-f]{16}(?:\/[^'"\\]+)?)\1/g;
let patchedFiles = 0;
let patchedMatches = 0;
@@ -392,6 +394,12 @@ export function assembleStandalone({
if (!distDir) throw new Error("[assembleStandalone] distDir is required");
if (!outDir) throw new Error("[assembleStandalone] outDir is required");
// The standalone bundle preserves the distDir path RELATIVE to projectRoot
// (the server's baked config uses e.g. "./.build/next"), so output dest paths
// for static / required-server-files / server chunks must use the relative
// distDir appended to outDir — never the absolute build-machine distDir.
const relDistDir = path.isAbsolute(distDir) ? path.relative(projectRoot, distDir) : distDir;
const standaloneDir = path.resolve(path.join(distDir, "standalone"));
const resolvedOutDir = path.resolve(outDir);
if (!fsSync.existsSync(standaloneDir)) {
@@ -425,9 +433,13 @@ export function assembleStandalone({
}
}
// 2. Copy <distDir>/static -> resolvedOutDir/.next/static
// 2. Copy <distDir>/static -> resolvedOutDir/<distDir>/static
// CRITICAL: the standalone server.js is built with distDir baked into its config
// (e.g. "./.build/next"), so it serves /_next/static from <outDir>/<distDir>/static,
// NOT a literal <outDir>/.next/static. Copying to .next/static leaves the server's
// static dir empty → every JS/CSS chunk 404s → blank page. Mirror the distDir path.
const staticSrc = path.join(distDir, "static");
const staticDest = path.join(resolvedOutDir, ".next", "static");
const staticDest = path.join(resolvedOutDir, relDistDir, "static");
if (fsSync.existsSync(staticSrc)) {
fsSync.mkdirSync(path.dirname(staticDest), { recursive: true });
fsSync.cpSync(staticSrc, staticDest, { recursive: true, force: true });
@@ -442,7 +454,7 @@ export function assembleStandalone({
// 4. Optionally sanitize abs paths
if (sanitizePaths) {
const count = assemblePathSanitize(projectRoot, resolvedOutDir);
const count = assemblePathSanitize(projectRoot, resolvedOutDir, relDistDir);
if (count > 0) {
console.log(`[assembleStandalone] Sanitised ${count} hardcoded path references`);
}
@@ -450,7 +462,7 @@ export function assembleStandalone({
// 5. Optionally patch Turbopack hashed chunks
if (doPatchChunks) {
const { patchedFiles, patchedMatches } = patchTurbopackChunks(resolvedOutDir);
const { patchedFiles, patchedMatches } = patchTurbopackChunks(resolvedOutDir, relDistDir);
if (patchedMatches > 0) {
console.log(
`[assembleStandalone] Hash-strip: patched ${patchedMatches} hashed require() in ${patchedFiles} server chunk file(s)`

View File

@@ -20,7 +20,16 @@ test("assembleStandalone copies standalone + static + public + sidecars into out
assembleStandalone({ distDir, outDir, projectRoot: tmp, sanitizePaths: false, copyNatives: false });
assert.ok(fs.existsSync(path.join(outDir, "server.js")), "server.js copied");
assert.ok(fs.existsSync(path.join(outDir, ".next/static/x.js")), "static copied");
// Static lands under the distDir path (.build/next/static), where the standalone
// server.js — built with distDir baked into its config — serves /_next/static from.
assert.ok(
fs.existsSync(path.join(outDir, ".build/next/static/x.js")),
"static copied under distDir"
);
assert.ok(
!fs.existsSync(path.join(outDir, ".next/static/x.js")),
"static is NOT placed under a literal .next (would 404 against distDir server)"
);
assert.ok(fs.existsSync(path.join(outDir, "public/logo.svg")), "public copied");
fs.rmSync(tmp, { recursive: true, force: true });
});