fix(instrument-bundler): keep the esbuild switch alive through tree shaking - #1482
Conversation
…haking
Importing instruments from a GitHub repo failed for every instrument in any
production build, while working under `pnpm dev`. The API reported
`Imported 0 instruments`, and each instrument logged a bare
`InstrumentBundlerError: Failed to Compile`.
`src/vendor/esbuild.ts` initialized its exports from a detached statement:
if (typeof window === 'undefined') {
var { build, transform } = await import('esbuild');
} else { ... }
export { build, transform };
`package.json` declares `sideEffects: ['**/cli.ts']`, advertising every other
file in this package as side-effect free, so esbuild is entitled to delete that
standalone `if`/`else` when bundling. It does. The module disappears, `build`
and `transform` are never declared, and the first call throws
`ReferenceError: build is not defined`. Only bundled builds tree shake, which is
why `pnpm dev` was unaffected and the Docker image never worked.
Initializing the bindings in their own declaration ties them to the used export,
so tree shaking retains them.
Two things hid the real error for the length of the investigation:
- The `catch` branches in `build.ts` were inverted (documented as a known defect
in AGENTS.md): a real `BuildFailure` was rethrown as `'Unknown Error'` while a
non-`BuildFailure` was labelled `'Failed to Compile'` with the *Zod issue list*
as its cause. Un-inverting them means `kind: 'ESBUILD_FAILURE'` now actually
fires, so `CodeErrorBlock` can render.
- `instrument-repos.service.ts` logged `String(err)`, discarding `.cause`.
It now logs the structured cause.
With tree shaking fixed, esbuild's JS *does* get inlined into `dist/app.js`,
where esbuild refuses to run because it can no longer locate the native binary
it shells out to. The installer stage now stages the binary belonging to the
exact esbuild that was bundled, and `ESBUILD_BINARY_PATH` both points at it and
suppresses that refusal. The version is resolved rather than pinned because the
store holds several esbuild versions and a mismatched JS/binary pair refuses to
start.
`src/__tests__/vendor.test.ts` bundles the module with tree shaking enabled and
asserts the dynamic import survives. It fails on the old source and passes on
the new one. No existing unit or e2e test could have caught this: the e2e suite
runs against the dev server, which is never bundled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up: the Dockerfile workaround here should not be permanentThe DouglasNeuroInformatics/libnest#76 adds a generic mechanism — This PR is deliberately not blocked on that, for two reasons:
Note that Once libnest#76 merges and ships, a small follow-up here deletes 14 lines from the As a bonus, libnest#76 fails the build when a declared native dependency never enters the |
… staging libnest 8.4.0 generalizes the Prisma native-artifact pattern into a declarative `build.nativeDependencies` option. Adding `['esbuild']` to `libnest.config.ts` makes the build emit the binary beside `dist/app.js` and set `ESBUILD_BINARY_PATH` via the JS banner — replacing 14 lines of manual staging and env-var wiring in the Dockerfile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gdevenyi
left a comment
There was a problem hiding this comment.
Solid fix, and the investigation writeup made it straightforward to verify. I reproduced the guard test failing on the old source, confirmed a local production build emits the version-matched esbuild binary with the banner in place, and then verified the whole mechanism end to end in a real container: built the image from this PR's merge result, confirmed dist/esbuild (0.23.1, executable) and the ESBUILD_BINARY_PATH banner inside it, booted the api against a mongo replica set, and ran setup — the default instrument repo import fetched and bundled 24/24 instruments from DouglasNeuroInformatics/ODC_Instruments with zero per-instrument failures. That is the exact path that imported 0 before this change.
(One caveat for the record, not caused by this PR: freshly built images of current main crash at boot from an unrelated globalThis.__dirname collision between libnest's bundle banner and Prisma's pre-bundled engines chunk — a control build at the merge base reproduces it identically. The smoke test worked around it; it is being reported separately.)
Three small things before this lands:
-
Add a regression test for the
catchyou un-inverted inpackages/instrument-bundler/src/build.ts. Insrc/__tests__/build.test.ts, mockesbuild.buildto reject once with a$BuildFailure-shaped object and assert the thrown error haskind: 'ESBUILD_FAILURE'and message'Failed to Compile', and once with a plainError, asserting'Unknown Error'. These branches shipped inverted once already;vendor.test.tsguards the tree-shaking half, but nothing pins this one. -
.agents/skills/odc-debugging/SKILL.md(lines 44-45) still lists the invertedcatchinbuild.tsas a known, deliberately-unfixed defect. You fixed it and updated the bundlerAGENTS.md, but this reference is now stale — please drop it from that example list. -
apps/api/AGENTS.md, "Build" section: it says non-bundled runtime assets must be copied viabuild.onCompleteor they will not exist in production. The esbuild binary is now such an asset handled by a different mechanism — add a sentence thatnativeDependencies: ['esbuild']makes libnest emit the version-matched binary intodist/and pointESBUILD_BINARY_PATHat it via the bundle banner.
If you hand this to Claude Code, Fable 5 is the right size — the work touches the instrument pipeline.
Reviewed at commit e3d9d4e.
…t and docs - Add two tests to build.test.ts verifying the catch branches: a BuildFailure-shaped error becomes kind ESBUILD_FAILURE, a plain Error becomes an unknown error with no kind. - Remove the stale inverted-catch reference from odc-debugging/SKILL.md (the defect was fixed in aa9010d). - Document nativeDependencies in apps/api/AGENTS.md Build section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… kind The review asked these tests to assert the thrown message as well as the kind. Both matter and they fail differently: `kind` gates whether `CodeErrorBlock` renders, while the message is what reaches the API's import log — which is what made the original inversion so hard to diagnose, since every real compile failure arrived labelled `Unknown Error` with a Zod issue list as its cause. Also asserts the `ESBUILD_FAILURE` cause carries the parsed `BuildFailure`, so a regression that passes `parseResult.error` (the Zod error) instead of `parseResult.data` is caught rather than silently discarding the diagnostics. Verified both tests fail when the catch branches are re-inverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the container-level verification — 24/24 on the exact path that imported 0 is the confirmation that mattered. All three items are addressed. Two were already in 1. Catch-branch regression test — err.name === 'InstrumentBundlerError' &&
err.message === 'Failed to Compile' &&
err.kind === 'ESBUILD_FAILURE' &&
err.cause.errors[0].text === 'Could not resolve "missing"'That last clause matters independently: the original code passed 2. 3.
On the |
`odc-instruments/SKILL.md` still told agents that discovery scans only `lib/forms` and `lib/interactive` and silently skips `lib/file` and `lib/series` — the exact behaviour this branch changes. Left as-is it would actively mislead: an agent would conclude a missing series was expected rather than a bug. Replaces it with what is now true, including the ordering constraint and the cross-repository limitation, so the caveat travels with the capability rather than living only in the architecture doc. Found by applying the review note on #1482 (a fixed defect still listed as known in `odc-debugging/SKILL.md`) to this branch's own doc surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
I hit this same bug from the other end — Two small things from my version that may be worth folding in here: 1.
Passing the original error keeps both, and still satisfies the guard you added — throw new InstrumentBundlerError('Failed to Compile', { cause: err as BuildFailure, kind: 'ESBUILD_FAILURE' });The regression you were guarding against (passing 2. Naming the underlying error in the non- throw new InstrumentBundlerError(`Unexpected error while invoking esbuild: ${describeError(err)}`, { cause: err });
// -> "Unexpected error while invoking esbuild: ReferenceError: build is not defined"That is the string that would have identified this bug on sight. Minor given you have already fixed Neither is blocking; the fix as it stands resolves my case. |
…RE cause `parseResult.data` is the Zod-parsed copy, a plain object rather than the error esbuild threw, and two consumers gate on `instanceof Error`: `InstrumentErrorFallback` renders its `Cause` section only when `error.cause instanceof Error`, so that section stayed hidden for exactly the failures whose `kind` now makes `CodeErrorBlock` render, and libjs `formatError` walks the chain with the same check, so `serve-instrument` logged `Failed to Compile` with no cause beneath it. Passing `err` keeps both; the branch has already established that it parses, so the regression this guarded against — passing `parseResult.error` — remains excluded. The `BuildFailure` mock in the regression test was a plain object, which cannot tell the original apart from the parsed copy. It is now a real `Error` with the diagnostics attached, as esbuild throws, and the test asserts identity and `instanceof Error` alongside the existing `errors[0].text` check. Also name the underlying error in the other branch instead of `'Unknown Error'`: `Unexpected error while invoking esbuild: ReferenceError: build is not defined` is the string that would have identified the tree-shaking bug on sight, and it is what reaches any consumer that surfaces only `.message`. Both tests fail when the two lines in `build.ts` are reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed both suggestions to this branch in ae71723, since it was quicker than describing them.
Your regression test needed a change to go with it: the
|
The bug
Adding a GitHub instrument repository imports 0 instruments on any production
deployment, while the identical repo imports correctly under
pnpm dev.Every instrument fails, and the log gives nothing to work with:
This has never worked in a container. The feature is only usable in development.
Root cause
packages/instrument-bundler/src/vendor/esbuild.tspicked between the native andWASM builds of esbuild with a detached statement:
This relies on
varhoisting: the declarations float to module scope, and astandalone
if/elseassigns them.packages/instrument-bundler/package.jsondeclares:which advertises every other file in the package as side-effect free. A
bundler is therefore entitled to delete any standalone top-level statement in
this module — and esbuild does exactly that when
apps/apiis bundled forproduction. The whole module vanishes from
dist/app.js. Nothing declaresbuildortransform, so the first call throws:Only bundled builds tree shake.
pnpm devloads the module natively and works,which is why this never showed up in development or in CI.
Reproduction
Bundling a module with this shape, inside a package declaring
sideEffects,with the same options
libnestuses (--bundle --format=esm --platform=node --keep-names --target=node22,es2022), produces output where the module is goneentirely and the call site references an undeclared identifier. Running it
reproduces
ReferenceError: build is not definedverbatim.The fix
Initialize the bindings in their own declaration, so they are reachable from the
used export and tree shaking must retain them:
Runtime semantics are unchanged — both dynamic imports are still separate, still
code-split, and still only one branch executes — so the playground's browser path
is unaffected.
Second failure behind the first
With tree shaking fixed, esbuild's JS is now inlined into
dist/app.js, andesbuild ships a guard against precisely that:
It refuses to run from a bundle because it can no longer find the native binary
it shells out to.
libnestexposes no way to mark a package external(
build.bundle: falseexternalizes all ofnode_modules, which would changethe deployment model), so
apps/api/Dockerfilenow:The version is resolved through
require.resolverather than pinned, becausethe pnpm store holds four esbuild versions and a mismatched JS/binary pair
refuses to start. The platform directory is read rather than hardcoded, so this
is not arch-specific.
ESBUILD_BINARY_PATH, which both points esbuild at that binary andsuppresses the guard above.
Verified both ways: without the variable the bundle throws
cannot be bundled;with it, the build succeeds.
Why the error was invisible
Two layers discarded the cause, which is why the symptom was an unactionable
Failed to Compilerather than aReferenceError:The
catchbranches inbuild.tswere inverted. This was alreadyrecorded in
AGENTS.mdas a known, verified, unfixed defect. A realBuildFailureparsed successfully and was rethrown as'Unknown Error',while anything that wasn't a
BuildFailurewas labelled'Failed to Compile'withparseResult.error— the Zod issue list — as its cause.So the one thing the log could have shown was a complaint that the thrown
value lacked
errorsandwarningsarrays. Un-inverting them also meanskind: 'ESBUILD_FAILURE'fires for the first time, soInstrumentErrorFallback'sCodeErrorBlockcan finally render.instrument-repos.service.tsloggedString(err), which stringifies tothe message and drops
.causeentirely. It now logs the structured cause,matching the
{ cause, error }shape used ingateway.synchronizer.ts.Tests
packages/instrument-bundler/src/__tests__/vendor.test.tsbundles the vendormodule with tree shaking enabled and asserts the dynamic import survives.
Confirmed to fail on the old source and pass on the new one.
No existing test could have caught this. Unit tests import the module directly
and never bundle it, and
testing/src/specs/admin-instrument-repos.spec.tsrunsagainst the dev server, which is never tree shaken. A bundling regression is
only observable in a bundled artifact, so the guard has to live at that layer.
Full suite: 413 passed, 1 skipped.
tscandeslintclean.🤖 Generated with Claude Code