mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(build): isolate Windows HOME/AppData during next build (#7249)
* fix(build): isolate Windows HOME/AppData during next build
next build's static-generation glob scan and framework cache helpers walk
%USERPROFILE%/AppData, which on GitHub-hosted Windows runners (and some
OneDrive-backed dev profiles) contains reparse points/junctions that raise
EPERM during Next's file-system scans. .github/workflows/electron-release.yml
already patches USERPROFILE for that one CI job ("Sanitize Windows home
directory" step), but a local `npm run build` on Windows — or any other
Windows CI path that calls scripts/build/build-next-isolated.mjs directly —
hits the same EPERM unprotected, and the existing CI patch does not touch
APPDATA/LOCALAPPDATA at all.
Folds the isolation into resolveNextBuildEnv() (the existing seam every
caller of build-next-isolated.mjs already goes through), rather than adding
a second build entrypoint the way upstream's scripts/build-app.js does:
on win32, HOME/USERPROFILE/APPDATA/LOCALAPPDATA are pointed at a fresh
per-process temp profile dir, created just-in-time via the new
ensureWindowsBuildProfileDirs() before spawning `next build`. Skipped when a
caller has already sandboxed the build via NEXT_DIST_DIR (the existing
signal this file reads for isolated-build callers, e.g. CLI packaging), so
nested build invocations are never double-isolated. Non-Windows behavior is
unchanged.
Co-authored-by: KunN21 <kunn21.nv@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2402
* chore(changelog): fragment for #7249
---------
Co-authored-by: KunN21 <kunn21.nv@gmail.com>
This commit is contained in:
committed by
GitHub
parent
b9433fd03a
commit
480491cb3f
1
changelog.d/fixes/7249-windows-build-isolation.md
Normal file
1
changelog.d/fixes/7249-windows-build-isolation.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(build):** isolate Windows HOME/AppData during next build. (thanks @KunN-21)
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
@@ -79,13 +80,26 @@ export async function movePath(sourcePath, destinationPath, fsImpl = fs) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: physically create the isolated Windows profile dirs that
|
||||
* resolveNextBuildEnv() may have pointed APPDATA/LOCALAPPDATA at. No-op when
|
||||
* resolveNextBuildEnv didn't set them (non-Windows, or NEXT_DIST_DIR already set).
|
||||
*/
|
||||
export function ensureWindowsBuildProfileDirs(env, mkdirImpl = mkdirSync) {
|
||||
if (!env?.APPDATA || !env?.LOCALAPPDATA) return;
|
||||
mkdirImpl(env.APPDATA, { recursive: true });
|
||||
mkdirImpl(env.LOCALAPPDATA, { recursive: true });
|
||||
}
|
||||
|
||||
function runNextBuild() {
|
||||
return new Promise((resolve) => {
|
||||
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
|
||||
const buildEnv = resolveNextBuildEnv(process.env);
|
||||
ensureWindowsBuildProfileDirs(buildEnv);
|
||||
const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: resolveNextBuildEnv(process.env),
|
||||
env: buildEnv,
|
||||
});
|
||||
|
||||
const forward = (signal) => {
|
||||
@@ -116,12 +130,45 @@ export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
|
||||
return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack";
|
||||
}
|
||||
|
||||
export function resolveNextBuildEnv(baseEnv = process.env) {
|
||||
/**
|
||||
* Deterministic per-process isolated Windows user-profile directory, used to
|
||||
* sandbox HOME/USERPROFILE/APPDATA/LOCALAPPDATA for the spawned `next build`.
|
||||
* Kept as a separate helper (rather than inline in resolveNextBuildEnv) so the
|
||||
* directory-creation side effect (ensureWindowsBuildProfileDirs) can be invoked
|
||||
* once per real build without re-deriving the path.
|
||||
*/
|
||||
export function getWindowsBuildProfileDir() {
|
||||
return path.join(os.tmpdir(), `omniroute-build-winhome-${process.pid}`);
|
||||
}
|
||||
|
||||
export function resolveNextBuildEnv(baseEnv = process.env, platform = process.platform) {
|
||||
const env = {
|
||||
...baseEnv,
|
||||
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
|
||||
};
|
||||
|
||||
// Windows-only: `next build`'s static-generation glob scan and framework cache
|
||||
// helpers walk %USERPROFILE%/AppData, which on GitHub-hosted Windows runners (and
|
||||
// some OneDrive-backed dev profiles) contains reparse points/junctions that raise
|
||||
// EPERM during Next's file-system scans. `.github/workflows/electron-release.yml`
|
||||
// ("Sanitize Windows home directory" step) already patches USERPROFILE for the CI
|
||||
// runner, but that only covers the electron-release CI job — a local `npm run
|
||||
// build` on Windows (or any other Windows CI path that calls this script
|
||||
// directly) hits the same EPERM unprotected. Doing the isolation here covers
|
||||
// every caller of build-next-isolated.mjs, not just one workflow step. Skipped
|
||||
// when a caller has already sandboxed the build via NEXT_DIST_DIR (the existing
|
||||
// signal this file already reads for "isolated build" callers — see `distDir`
|
||||
// above) to avoid double-isolating nested build invocations.
|
||||
// Port of decolua/9router#2402 ("fix(build): isolate Windows HOME/AppData
|
||||
// during next build").
|
||||
if (platform === "win32" && !baseEnv.NEXT_DIST_DIR) {
|
||||
const buildHomeDir = getWindowsBuildProfileDir();
|
||||
env.HOME = buildHomeDir;
|
||||
env.USERPROFILE = buildHomeDir;
|
||||
env.APPDATA = path.join(buildHomeDir, "AppData", "Roaming");
|
||||
env.LOCALAPPDATA = path.join(buildHomeDir, "AppData", "Local");
|
||||
}
|
||||
|
||||
// Raise the Node heap for the spawned `next build`. The webpack production pass
|
||||
// ("Compiling instrumentation" bundles the whole server graph) is the heaviest
|
||||
// phase and overflows V8's default ~2 GB ceiling on memory-constrained machines,
|
||||
|
||||
93
tests/unit/build-next-isolated-windows-home-2402.test.ts
Normal file
93
tests/unit/build-next-isolated-windows-home-2402.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const {
|
||||
ensureWindowsBuildProfileDirs,
|
||||
getWindowsBuildProfileDir,
|
||||
resolveNextBuildEnv,
|
||||
} = await import("../../scripts/build/build-next-isolated.mjs");
|
||||
|
||||
// Port of decolua/9router#2402 ("fix(build): isolate Windows HOME/AppData during
|
||||
// next build"). Upstream wraps `npm run build` in a new `scripts/build-app.js`
|
||||
// entrypoint; OmniRoute's build already routes through
|
||||
// `scripts/build/build-next-isolated.mjs` → resolveNextBuildEnv(), so the fix is
|
||||
// folded into that existing seam instead of adding a second build entrypoint.
|
||||
// `.github/workflows/electron-release.yml` already sanitizes USERPROFILE for one
|
||||
// CI job; this generalizes the isolation to every caller (local Windows builds,
|
||||
// other CI paths) and adds APPDATA/LOCALAPPDATA, which the CI-only patch does not
|
||||
// touch.
|
||||
|
||||
test("resolveNextBuildEnv leaves HOME/USERPROFILE/APPDATA untouched on non-Windows", () => {
|
||||
const env = resolveNextBuildEnv({ NODE_ENV: "test", HOME: "/home/dev" }, "linux");
|
||||
assert.equal(env.HOME, "/home/dev");
|
||||
assert.equal(env.USERPROFILE, undefined);
|
||||
assert.equal(env.APPDATA, undefined);
|
||||
assert.equal(env.LOCALAPPDATA, undefined);
|
||||
});
|
||||
|
||||
test("resolveNextBuildEnv isolates HOME/USERPROFILE/APPDATA/LOCALAPPDATA on win32", () => {
|
||||
const env = resolveNextBuildEnv(
|
||||
{ NODE_ENV: "test", USERPROFILE: "C:\\Users\\ci-runner" },
|
||||
"win32"
|
||||
);
|
||||
|
||||
assert.ok(env.HOME, "HOME must be set to an isolated profile dir on win32");
|
||||
assert.equal(env.HOME, env.USERPROFILE, "HOME and USERPROFILE must point at the same sandbox");
|
||||
assert.notEqual(
|
||||
env.USERPROFILE,
|
||||
"C:\\Users\\ci-runner",
|
||||
"the real USERPROFILE (with its junctions) must be replaced, not preserved"
|
||||
);
|
||||
assert.match(path.basename(env.APPDATA), /^Roaming$/);
|
||||
assert.match(path.basename(env.LOCALAPPDATA), /^Local$/);
|
||||
assert.equal(path.dirname(path.dirname(env.APPDATA)), env.HOME);
|
||||
assert.equal(path.dirname(path.dirname(env.LOCALAPPDATA)), env.HOME);
|
||||
});
|
||||
|
||||
test("resolveNextBuildEnv skips Windows isolation when a caller already sandboxed the build (NEXT_DIST_DIR)", () => {
|
||||
const env = resolveNextBuildEnv(
|
||||
{ NODE_ENV: "test", USERPROFILE: "C:\\Users\\ci-runner", NEXT_DIST_DIR: ".build/cli-next" },
|
||||
"win32"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
env.USERPROFILE,
|
||||
"C:\\Users\\ci-runner",
|
||||
"must not override a caller-provided sandbox (e.g. CLI packaging)"
|
||||
);
|
||||
assert.equal(env.APPDATA, undefined);
|
||||
assert.equal(env.LOCALAPPDATA, undefined);
|
||||
});
|
||||
|
||||
test("getWindowsBuildProfileDir is stable per-process (repeated calls return the same path)", () => {
|
||||
assert.equal(getWindowsBuildProfileDir(), getWindowsBuildProfileDir());
|
||||
});
|
||||
|
||||
test("ensureWindowsBuildProfileDirs is a no-op when the env has no APPDATA/LOCALAPPDATA", () => {
|
||||
let called = false;
|
||||
ensureWindowsBuildProfileDirs({ NODE_ENV: "test" }, () => {
|
||||
called = true;
|
||||
});
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("ensureWindowsBuildProfileDirs creates the isolated AppData directories", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-win-home-test-"));
|
||||
try {
|
||||
const env = {
|
||||
APPDATA: path.join(tempDir, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: path.join(tempDir, "AppData", "Local"),
|
||||
};
|
||||
|
||||
ensureWindowsBuildProfileDirs(env);
|
||||
|
||||
assert.equal(fsSync.existsSync(env.APPDATA), true);
|
||||
assert.equal(fsSync.existsSync(env.LOCALAPPDATA), true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user