Skip to content

fix(instrument-bundler): keep the vendored esbuild module out of tree shaking - #1493

Closed
joshunrau wants to merge 3 commits into
DouglasNeuroInformatics:mainfrom
joshunrau:fix/bundler-vendor-esbuild-tree-shaking
Closed

fix(instrument-bundler): keep the vendored esbuild module out of tree shaking#1493
joshunrau wants to merge 3 commits into
DouglasNeuroInformatics:mainfrom
joshunrau:fix/bundler-vendor-esbuild-tree-shaking

Conversation

@joshunrau

Copy link
Copy Markdown
Collaborator

Problem

serve-instrument@2.2.0 cannot compile any instrument. Every request fails with a ZodError that has nothing to do with the instrument being served:

ZodError: [
  { "expected": "array", "code": "invalid_type", "path": ["errors"], ... },
  { "expected": "array", "code": "invalid_type", "path": ["warnings"], ... }
]
...
InstrumentBundlerError: Failed to Compile

The real error is ReferenceError: build is not defined, thrown at the esbuild.build(...) call in build.ts. The published dist/cli.js for 2.2.0 contains zero occurrences of the string esbuild (2.1.4 has two).

Cause

2.2.0 added "sideEffects": ["**/cli.ts"] to packages/instrument-bundler/package.json. That marks every other module in the package as pure, but the entire body of src/vendor/esbuild.ts is a side effect — a conditional top-level await import() assigning to hoisted vars:

if (typeof window === 'undefined') {
  var { build, transform } = await import('esbuild');
} else {
  var { build, transform } = await import('esbuild-wasm');
}
export { build, transform };

esbuild drops that module from the consumer bundle while still inlining the namespace access down to the bare build identifier, leaving a dangling reference.

Rebuilding packages/serve-instrument against each variant:

sideEffects esbuild refs in dist/cli.js result
["**/cli.ts"] (current) 0 ReferenceError on every bundle
field removed 2 works
["**/cli.ts", "**/vendor/esbuild.ts"] (this PR) 2 works

Why it surfaced as a ZodError

The catch branches in build were inverted. A ReferenceError does not satisfy $BuildFailure, so the parse failed and the ZodError describing the schema mismatch was thrown as the cause of Failed to Compile, discarding the actual error. Symmetrically, a genuine esbuild failure — which does parse — was reported as Unknown Error with no kind, so InstrumentErrorFallback never rendered a code frame for real syntax errors.

Changes

  • package.json: add **/vendor/esbuild.ts to sideEffects.
  • build.ts: report an esbuild failure as Failed to Compile / ESBUILD_FAILURE, passing the original error rather than the parsed copy so cause instanceof Error still holds for InstrumentErrorFallback and cause.errors[0].location still reaches CodeErrorBlock; name any other error in the message and keep it as the cause.
  • vendor/esbuild.ts: comment recording the sideEffects coupling, since nothing else in the source hints at it.

Verification

  • pnpm lint clean; all 42 existing instrument-bundler tests pass.
  • Rebuilt serve-instrument: dist/cli.js contains the esbuild import again and compiles a real multilingual form instrument (a .tsx entrypoint with a JSX block) that 2.2.0 rejects.
  • A deliberate syntax error now yields Failed to Compile | kind: ESBUILD_FAILURE | cause.errors[0]: Expected identifier but found end of file instead of Unknown Error.
  • The non-esbuild path was checked with a throwaway test mocking esbuild.build to reject with a ReferenceError: message becomes Unexpected error while invoking esbuild: ReferenceError: build is not defined, with the original error as cause.

Worth a 2.2.1 — 2.2.0 is unusable for local instrument development as published.

🤖 Generated with Claude Code

joshunrau and others added 3 commits August 5, 2026 13:31
… shaking

`sideEffects: ["**/cli.ts"]` marks every other module in the package as
pure, but the whole body of `vendor/esbuild.ts` is a side effect: a
conditional top-level `await import()` assigning to hoisted `var`s. esbuild
therefore drops it from any consumer bundle while still inlining the
namespace access down to the bare `build` identifier, so every call throws
`ReferenceError: build is not defined`. This is why `serve-instrument@2.2.0`
cannot compile any instrument at all — its published `dist/cli.js` contains
no reference to esbuild whatsoever.

That failure was invisible because the catch branches in `build` were
inverted: a `ReferenceError` does not satisfy `$BuildFailure`, so the parse
failed and the ZodError describing the schema mismatch was thrown as the
cause of `Failed to Compile`, discarding the real error. Meanwhile a genuine
esbuild failure, which does parse, was reported as `Unknown Error` with no
`kind`, so `InstrumentErrorFallback` never rendered a code frame for it.

Report an esbuild failure as `Failed to Compile` / `ESBUILD_FAILURE`, passing
the original error rather than the parsed copy so `cause instanceof Error`
still holds downstream, and name any other error in the message while keeping
it as the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joshunrau

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #1482, which fixes the same root cause and was opened first.

I found this from serve-instrument@2.2.0 failing to compile any instrument locally; #1482 found it from GitHub instrument repos importing 0/24 in production Docker. Same dropped vendor module, same masking by the inverted catch.

#1482 is the better fix on every axis this PR touches:

  • It initializes the bindings from their own declaration, so tree shaking must retain them, instead of adding **/vendor/esbuild.ts to sideEffects as this PR does — no glob list to keep in sync.
  • It has a regression test that bundles the vendor module with tree shaking enabled, plus tests pinning both catch branches. This PR has none.
  • It also handles the second failure that only appears once tree shaking is fixed: esbuild's JS refuses to run bundled, so apps/api needs the version-matched native binary staged via libnest nativeDependencies. This PR would leave the API broken in a new way.

The two deltas from this PR worth keeping — passing the original error rather than the parsed copy as the ESBUILD_FAILURE cause, and naming the underlying error in the non-BuildFailure branch — are left as review notes on #1482.

@joshunrau joshunrau closed this Aug 5, 2026
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.

1 participant