fix(build): spawn esbuild cross-platform in the postbuild hook - #11159
Merged
diegosouzapw merged 1 commit intoAug 23, 2026
Merged
diegosouzapw merged 1 commit into
diegosouzapw merged 1 commit into
Conversation
`scripts/build/colocate-standalone.mjs` spawned `node_modules/.bin/esbuild`
directly. That extensionless path is a POSIX shell script and does not exist on
Windows, so the `postbuild` hook died with
Error: spawnSync C:\...\node_modules\.bin\esbuild ENOENT
immediately AFTER `next build` printed "✓ Compiled successfully" — leaving a
complete `.build/next/standalone` tree beside a failed `npm run build`, with
neither worker bundle colocated. The misleading ordering makes this read as a
Next.js failure rather than a spawn failure.
`scripts/build/prepublish.ts` already solved this exact problem, but its
resolution helpers were private to that file, and `postbuild` runs under plain
`node`, which cannot import a `.ts` sibling. Extract them to
`scripts/build/buildToolRunner.mjs` and route both esbuild call sites through
it: read the tool's own `bin` entry from its package.json and run that with
`process.execPath`. This deliberately avoids the two shim traps — Node >= 20
refuses to spawn a `.cmd` without a shell (CVE-2024-27980 hardening), and
`shell: true` in turn disables argument escaping (DEP0190). Native `bin`
entries (esbuild >= 0.25 ships ELF/Mach-O on Linux/macOS) keep being exec'd
directly, since feeding those to Node crashes with "Invalid or unexpected
token".
`prepublish.ts` now imports the shared helpers instead of duplicating them; its
own `runBuildTool` and npx fallback are untouched, so the release path behaves
exactly as before.
`planBuildToolSpawn()` takes the platform as a parameter — the same seam as
`resolveNextBuildEnv()` in `build-next-isolated.mjs` — so the Windows decisions
are asserted from CI's Linux runners.
muhamadgalihsaputra
pushed a commit
to niyatna/NiyatnaRoute
that referenced
this pull request
Sep 27, 2026
…souzapw#11159) Validated on the combined batch board over tip e8ec7cb: static gates clean, typecheck:core clean, 430+ focused tests green across 5 groups. postbuild's esbuild spawn now resolves cross-platform (no more ENOENT after a successful Next compile on Windows). build-tool-runner-win-shim suite green. Thank you @aliyosufi — first contribution, welcome!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On Windows,
npm run buildfails after Next.js reports success:The build leaves a complete
.build/next/standalonetree on disk next to anon-zero exit, and neither worker bundle is colocated — so the standalone
output is silently incomplete. Because the error appears immediately after
✓ Compiled successfully, it reads like a Next.js problem rather than a spawnproblem, which makes it expensive to diagnose.
Root cause
scripts/build/colocate-standalone.mjsspawned the.binshim directly, at twocall sites:
node_modules/.bin/esbuild(no extension) is a POSIX shell script. On Windowsthe executable shim is
esbuild.cmd, so the extensionless path simply does notexist →
ENOENT.Why not just append
.cmdTwo traps make the shim the wrong target on any platform:
.cmd/.batwithout a shell (EINVAL).shell: truethen disables argument escaping (DEP0190), and buildarguments are absolute paths —
C:\Users\First Last\...is an ordinaryWindows home directory.
Fix
scripts/build/prepublish.tshad already solved this exact problem, but itsresolution helpers were private to that file, and
postbuildruns under plainnode(node scripts/build/colocate-standalone.mjs), which cannot import a.tssibling.So this PR extracts them into
scripts/build/buildToolRunner.mjsand routes bothesbuild call sites through it. The preferred path avoids the shim entirely: read
the tool's own
binentry from itspackage.jsonand run that withprocess.execPath— no shim, no shell, nothing to escape, identical behaviour onevery platform. The
.binshim survives only as a last resort for a tool that isnot resolvable in the local dependency tree (
.cmd+ shell + explicit quoting onWindows).
Native
binentries are still exec'd directly: esbuild >= 0.25 shipsbin/esbuildas the native platform executable (ELF / Mach-O) on Linux andmacOS, and feeding that to
process.execPathmakes Node parse machine code asJavaScript (
SyntaxError: Invalid or unexpected token).Deliberately out of scope
prepublish.tsnow imports the shared helpers instead of duplicating them.Its own
runBuildTooland npx fallback are untouched, so the release-criticalpath behaves exactly as before — this is a de-duplication, not a rewrite.
Validation
planBuildToolSpawn()takes the platform as a parameter — the same seam asresolveNextBuildEnv()inbuild-next-isolated.mjs— so the Windows decisionsare asserted from CI's Linux runners.
tests/unit/build/build-tool-runner-win-shim.test.ts— 10 tests, all passing:The last two are the load-bearing ones: a real end-to-end
runBuildToolspawn(the bug was a spawn failure, so only a real spawn is conclusive), and a source
guard so the
.binpattern cannot come back.Real Windows before/after
Same machine, same command, using the existing
OMNIROUTE_STANDALONE_DIRseam sothe hook runs against a synthetic standalone tree instead of a full
next build.Windows 11, Node v24.19.0.
Before (unmodified
release/v3.8.50):After (this branch):
The emitted
callLogArtifactWorker.jsis a genuine ESM bundle(
import { parentPort } from "node:worker_threads";), not an empty file.Other gates run locally
npm run typecheck:core— clean.tscoverscripts/build/prepublish.tswith--checkJs(the file isnot in any
typecheck:*project, and it now consumes JSDoc-typed.mjsexports) — no errors in either changed file.
npx eslinton the changed files — 0 errors (scripts/is eslint-ignored; thenew test file is clean).
check-docs-sync,check:any-budget:t11,check-tracked-artifacts— all pass.tests/unit/build/— 389 pass / 11 fail. All 11 failures are pre-existing andWindows-local (hardcoded Linux runner paths, workflow-YAML gates, and tests
requiring a prebuilt bundle); none of the failing files import the modules this
PR touches.
colocate-standalone-esm-scope.test.tspasses.Follow-ups (intentionally not in this PR)
The same
.binpattern remains in six CI gate scripts. Those run on Linux in CI,so they only bite Windows contributors locally — a separate, larger change:
scripts/check/check-bundle-size.mjs:43scripts/check/check-dead-code.mjs:23scripts/check/check-duplication.mjs:23scripts/check/check-licenses.mjs:26scripts/check/check-lockfile.mjs:83scripts/check/check-type-coverage.mjs:88Separately,
mcp-bundle-startup.test.tsandmcp-bundle-no-eager-ioredis.test.tsfail on Windows with
spawnSync npx ENOENT— the same spawn class, in testscaffolding rather than the build.