feat(meta): add build.nativeDependencies for packages that cannot be bundled - #76
Merged
joshunrau merged 1 commit intoAug 4, 2026
Conversation
…bundled
`buildProd` collapses the dependency graph into a single file, which breaks any
package that loads a native artifact through a path relative to its own source.
`prismaPlugin` already solves this — locate the artifact, emit it into the output
directory, point the package back at it with an environment variable — but the
mechanism is welded to one package name and there is no way to declare a second.
esbuild is the case that motivated this. It not only fails to find its executable
once bundled, it actively refuses to run, throwing "The esbuild JavaScript API
cannot be bundled" after comparing __filename against its own expected layout.
Setting ESBUILD_BINARY_PATH both locates the executable and suppresses that
refusal. Verified end to end: a bundle built with these settings, run from a
directory with no node_modules, transforms successfully once the variable is set
and the executable sits beside it.
Consumers now write:
build: { nativeDependencies: ['esbuild'] }
and the executable is emitted next to the bundle, so it travels with whatever
already copies the output directory. A consumer needing a package without a
built-in recipe supplies `locate`, `outputName`, `packageName` and
`runtimeEnvVar` directly.
Two details worth review attention:
The artifact is located through a `require` rooted at the module that imported
the package during this build, not through `createRequire(import.meta.url)` as
`prismaPlugin` does. That is correct for Prisma, whose engines are a libnest
dependency, but wrong in general: an application frequently resolves a different
version of a transitive dependency than libnest resolves for itself, and pairing
an artifact with a different version of its own JavaScript fails at runtime.
Hooking `onResolve` takes the answer from the actual graph walk instead of
guessing. Resolution is observed, never altered.
Declaring a dependency the application never imports fails the build in `onEnd`,
rather than shipping a bundle whose banner points at a file that was never
emitted. That failure mode is otherwise quiet: a bad path makes esbuild warn and
fall through to normal resolution, which then reports a missing module and blames
the wrong thing entirely.
Also fixes two prerequisites found on the way:
- `prismaPlugin` appended its banner statement without a trailing semicolon. The
banner concatenates statements from every plugin that appends to it with no
separator, so a second appending plugin produced a bundle that did not parse.
There is no newline for ASI to work with.
- `load.ts` validated an `esbuildOptions` field that nothing has read since it was
removed from the user-facing surface in a987e72. A consumer could set it, have
it validate cleanly, and have it silently ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #76 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 60 62 +2
Lines 704 738 +34
Branches 118 125 +7
=========================================
+ Hits 704 738 +34 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
joshunrau
approved these changes
Aug 4, 2026
|
🎉 This PR is included in version 8.4.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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
buildProdcollapses the dependency graph into a single file. That breaks any packagewhich loads a native artifact through a path relative to its own source, because the
JavaScript moves and the artifact does not.
prismaPluginalready solves exactly this, in three steps — locate the artifact(
prisma.ts:19-27), point the package back at it via the banner (prisma.ts:29), emit itinto the output directory (
prisma.ts:31-33). The mechanism is right; it is just welded toone package name, with no way for a consumer to declare a second.
esbuild is the case that motivated this, and it is the nastiest variant: it does not merely
fail to find its executable, it actively refuses to run. From
esbuild/lib/main.js:1812:Note the first clause: setting
ESBUILD_BINARY_PATHboth locates the executable andsuppresses the refusal.
The existing escape hatch does not help.
build.bundle: falseaddsexternalPlugin, whichexternalizes all of
node_modules— for a consumer whose runner image ships only theoutput directory, that trades one broken build for another.
Solution
Generalize the Prisma pattern into a table-driven plugin behind a declarative option:
The executable is emitted next to the bundle, so it travels with whatever already copies the
output directory — no extra deployment step. A consumer needing a package without a built-in
recipe supplies
locate,outputName,packageNameandruntimeEnvVardirectly.The downstream consumer that hit this deletes 14 lines of Dockerfile for that one line.
Two design points worth review attention
Resolution comes from the application's graph, not libnest's.
prismaPluginusesmodule.createRequire(import.meta.url)(prisma.ts:7) — libnest's own location. That iscorrect for Prisma, whose engines are a libnest dependency. It is wrong in general: libnest
depends on
esbuild@^0.27.2, while the consumer that hit this resolvesesbuild@0.23.1through a transitive dependency, and its store holds four esbuild versions. Emitting the
0.27 executable beside bundled 0.23 JavaScript produces
Cannot start service: Host version … does not match binary version …on first use, in production.So the plugin hooks
onResolveand takes the answer from the actual graph walk, then rootslocate'srequireat the module that imported the package. Resolution is observed, neveraltered — the handler always returns
nulland the package is still bundled normally.This is the same
build.resolvepatternexternalPluginuses, and esbuild passespluginNameon that call so it cannot re-enter the plugin.Declaring a dependency the application never imports fails the build. This is deliberate,
because the alternative failure is quiet. If the banner points at a file that was never
emitted, esbuild's
generateBinPathonly warns on a badESBUILD_BINARY_PATH(
main.js:1682-1690) and then falls through to normal resolution — so the operator seesCannot find module 'esbuild', which blames entirely the wrong thing. I verified thatfailure mode directly before choosing to guard against it.
Prerequisite fixes included
Both were found while building this, and the first blocks it outright:
prismaPluginappended its banner statement without a trailing semicolon(
prisma.ts:29). The banner concatenates statements from every appending plugin with noseparator, so a second appender produced a bundle that does not parse — confirmed with
node --check; there is no newline for ASI to rescue. It works today only becauseprismaPluginhappens to be the only appender.load.tsvalidated anesbuildOptionsfield that nothing reads. It was removed fromthe user-facing surface in
a987e72, but the zod line survived, so a consumer could set it,have it validate cleanly, and have it silently ignored.
Verification
src/meta/plugins/__tests__/native-dependencies.test.ts(10 tests) covers the banner,subpath matching, resolve-once, the two throw paths, and that
locatereceives arequirerooted at the importing module rather than at libnest.
src/meta/__tests__/native-dependencies.test.tscovers recipe expansion and that theesbuild recipe asks for the current host's platform package.
src/meta/__tests__/build.test.tsgains three cases, including a real bundle of theexample app that declares a native dependency and asserts both that the artifact lands
beside the output and that the banner assignment is present in the emitted file.
with these exact settings, run from a directory with no
node_modules, transformssuccessfully once the variable is set and the executable sits beside it.
Known limitations, stated rather than discovered
better-sqlite3-style packages that compute an addon path at runtime throughbindingsornode-gyp-build— there is no single variable to set for those.(
prismaPlugincallsgetBinaryTargetForCurrentPlatform), so this inherits the constraintrather than adding it — but it does break under cross-compilation.
Opened from a fork, as I do not have push access to this repository.
🤖 Generated with Claude Code