Skip to content

fix(build): spawn esbuild cross-platform in the postbuild hook - #11159

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.50from
aliyosufi:fix/colocate-standalone-win-esbuild
Aug 23, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.50from
aliyosufi:fix/colocate-standalone-win-esbuild

Conversation

@aliyosufi

Copy link
Copy Markdown
Contributor

Problem

On Windows, npm run build fails after Next.js reports success:

 ✓ Compiled successfully
...
> postbuild
> node scripts/build/colocate-standalone.mjs

Error: spawnSync C:\...\node_modules\.bin\esbuild ENOENT
    at Object.spawnSync (node:internal/child_process:1160:20)

The build leaves a complete .build/next/standalone tree on disk next to a
non-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 spawn
problem, which makes it expensive to diagnose.

Root cause

scripts/build/colocate-standalone.mjs spawned the .bin shim directly, at two
call sites:

execFileSync(join(ROOT, "node_modules", ".bin", "esbuild"), [ ... ]);

node_modules/.bin/esbuild (no extension) is a POSIX shell script. On Windows
the executable shim is esbuild.cmd, so the extensionless path simply does not
exist → ENOENT.

Why not just append .cmd

Two traps make the shim the wrong target on any platform:

  • Since the CVE-2024-27980 hardening, Node >= 20 refuses to spawn a
    .cmd/.bat without a shell (EINVAL).
  • shell: true then disables argument escaping (DEP0190), and build
    arguments are absolute paths — C:\Users\First Last\... is an ordinary
    Windows home directory.

Fix

scripts/build/prepublish.ts had already solved this exact problem, but its
resolution helpers were private to that file, and postbuild runs under plain
node (node scripts/build/colocate-standalone.mjs), which cannot import a
.ts sibling.

So this PR extracts them into scripts/build/buildToolRunner.mjs and routes both
esbuild call sites through it. The preferred path avoids the shim entirely: read
the tool's own bin entry from its package.json and run that with
process.execPath — no shim, no shell, nothing to escape, identical behaviour on
every platform. The .bin shim survives only as a last resort for a tool that is
not resolvable in the local dependency tree (.cmd + shell + explicit quoting on
Windows).

Native bin entries are still exec'd directly: esbuild >= 0.25 ships
bin/esbuild as the native platform executable (ELF / Mach-O) on Linux and
macOS, and feeding that to process.execPath makes Node parse machine code as
JavaScript (SyntaxError: Invalid or unexpected token).

Deliberately out of scope

prepublish.ts now imports the shared helpers instead of duplicating them.
Its own runBuildTool and npx fallback are untouched, so the release-critical
path behaves exactly as before — this is a de-duplication, not a rewrite.

Validation

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.

tests/unit/build/build-tool-runner-win-shim.test.ts — 10 tests, all passing:

✔ planBuildToolSpawn prefers the tool's own JS entry over any .bin shim
✔ planBuildToolSpawn execs a NATIVE entry directly instead of feeding it to Node
✔ planBuildToolSpawn falls back to the .cmd shim (with a shell) on win32
✔ planBuildToolSpawn falls back to the extensionless shim (no shell) elsewhere
✔ planBuildToolSpawn quotes whitespace paths when it has to use a shell
✔ resolveLocalBinEntry reads the package's own bin map, never node_modules/.bin
✔ resolveLocalBinEntry returns null for a missing package or a missing entry
✔ isNativeExecutable distinguishes an executable image from a JS shim
✔ runBuildTool actually runs esbuild from this repo's dependency tree
✔ colocate-standalone.mjs never spawns the node_modules/.bin shim again
ℹ pass 10
ℹ fail 0

The last two are the load-bearing ones: a real end-to-end runBuildTool spawn
(the bug was a spawn failure, so only a real spawn is conclusive), and a source
guard so the .bin pattern cannot come back.

Real Windows before/after

Same machine, same command, using the existing OMNIROUTE_STANDALONE_DIR seam so
the 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):

Error: spawnSync C:\...\node_modules\.bin\esbuild ENOENT
    at Object.spawnSync (node:internal/child_process:1160:20)
EXIT: 1

After (this branch):

[colocate-standalone] ✅ call-log artifact worker bundled
[colocate-standalone] ✅ LLMLingua worker bundled into standalone tree
[colocate-standalone] ✅ optional-dep closure: 4 packages (copied 4)
[colocate-standalone] ✅ ESM scope written: ...\src\lib\usage\package.json
[colocate-standalone] ✅ ESM scope written: ...\open-sse\...\llmlingua\package.json
EXIT: 0

The emitted callLogArtifactWorker.js is a genuine ESM bundle
(import { parentPort } from "node:worker_threads";), not an empty file.

Other gates run locally

  • npm run typecheck:core — clean.
  • Targeted tsc over scripts/build/prepublish.ts with --checkJs (the file is
    not in any typecheck:* project, and it now consumes JSDoc-typed .mjs
    exports) — no errors in either changed file.
  • npx eslint on the changed files — 0 errors (scripts/ is eslint-ignored; the
    new 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 and
    Windows-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.ts passes.

Follow-ups (intentionally not in this PR)

The same .bin pattern 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:43
  • scripts/check/check-dead-code.mjs:23
  • scripts/check/check-duplication.mjs:23
  • scripts/check/check-licenses.mjs:26
  • scripts/check/check-lockfile.mjs:83
  • scripts/check/check-type-coverage.mjs:88

Separately, mcp-bundle-startup.test.ts and mcp-bundle-no-eager-ioredis.test.ts
fail on Windows with spawnSync npx ENOENT — the same spawn class, in test
scaffolding rather than the build.


⚠️ base-red inherited: #9985

`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.
@diegosouzapw
diegosouzapw merged commit fb421bc into diegosouzapw:release/v3.8.50 Aug 23, 2026
3 checks passed
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!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants