Repository navigation
traceDeps fails to trace NAPI-RS packages when using Bun #4140
Description
Environment
- Nitro:
3.0.1-nightly - Runtime: Bun
1.3.x - Preset:
bun - Package manager: Bun (uses
.bun/<pkg>@<ver>/node_modules/<pkg>/nested layout with symlinks)
Reproduction
-
Install a NAPI-RS package — one that dynamically loads a platform-specific optional dependency at runtime.
@takumi-rs/coreis an example: itsindex.jscallsrequire('@takumi-rs/core-linux-arm64-musl')(or the appropriate platform variant) at runtime. -
Add it to
traceDepsinvite.config.ts:
nitro({
preset: "bun",
traceDeps: ["@takumi-rs/core"],
})-
Run
vite build. The build succeeds, but.output/server/node_modules/@takumi-rs/is absent. -
Run the output on a different platform (e.g. Docker Alpine arm64). On startup:
Error: Cannot find native binding for @takumi-rs/core
Describe the bug
NAPI-RS packages (e.g. @takumi-rs/core) are never written to .output/server/node_modules/ even when listed in traceDeps. The root cause is two bugs in the nitro:externals plugin (src/build/plugins/externals.ts):
-
tryResolveincorrectly gates bare package specifiers — whenimportIdis a bare specifier like@takumi-rs/core,tryResolvereturnsundefinedunder Bun becauseexsolvecannot traverse Bun's nested.bun/package layout. This causes the handler to fall through toguessSubpathand ultimately return early beforetracedPaths.add()is reached. -
guessSubpathhas a./prefix mismatch — packageexportsmap entries havefsPathvalues prefixed with./(e.g."./index.js"), but thesubpathcaptured from the file path regex is unprefixed (e.g."index.js"). The equality checke.fsPath === subpathnever matches, soguessSubpathalways returnsundefined.
Additional context
Root Cause Analysis
Bug 1: tryResolve blocks bare package specifiers under Bun
In src/build/plugins/externals.ts, after importId is determined to be a bare specifier (e.g. @takumi-rs/core), the handler calls tryResolve(importId, importer) to verify the package is resolvable before adding it to tracedPaths:
// Current code
if (!tryResolve(importId, importer)) {
const guessed = await guessSubpath(resolvedPath, opts.conditions);
if (!guessed) return resolved; // <-- exits here, tracedPaths never updated
importId = guessed;
}
tracedPaths.add(resolvedPath);tryResolve calls resolveModulePath (via exsolve). Under Bun, packages are installed at:
node_modules/.bun/@takumi-rs+image-response@0.71.5/node_modules/@takumi-rs/image-response/
with a symlink at node_modules/@takumi-rs/image-response. The importer path that Rolldown provides is the real path — inside the .bun/ directory. exsolve walks up the directory tree looking for a node_modules/@takumi-rs/core folder, but it cannot find one because it never escapes the .bun/<dep>/node_modules/ scope. tryResolve returns undefined, causing the handler to fall through.
This is incorrect for bare specifiers: we already have a valid resolved path from this.resolve(id, importer). The tryResolve check was meant to guard against phantom/unresolvable imports, but this.resolve() already performed that check implicitly.
Bug 2: guessSubpath ./ prefix mismatch
guessSubpath is the fallback when tryResolve fails. It reads the package's exports map and tries to match the resolved file path to an export entry:
// Current code
if (e.fsPath === subpath) return join(name, e.subpath);The subpath is captured from a file path like:
/…/node_modules/@takumi-rs/core/index.js
^^^^^^^^
subpath = "index.js"
But flattenExports produces fsPath values normalized from the exports map, which retain the ./ prefix from the source JSON:
{ "exports": { ".": "./index.js" } }→ e.fsPath = "./index.js", e.subpath = "."
The comparison "./index.js" === "index.js" is always false, so guessSubpath always returns undefined for standard single-export packages.
Fix
Fix 1: Skip tryResolve for bare package specifiers
Only run the tryResolve/guessSubpath path for relative or absolute importId values. Bare specifiers are already validated by the successful this.resolve() call above.
- if (!tryResolve(importId, importer)) {
- const guessed = await guessSubpath(resolvedPath, opts.conditions);
- if (!guessed) return resolved;
- importId = guessed;
- }
+ // If importId is a bare package specifier, trust it directly — tryResolve may
+ // fail with Bun's .bun/ symlink layout (exsolve can't traverse nested .bun/ paths).
+ if (importId.startsWith(".") || isAbsolute(importId)) {
+ if (!tryResolve(importId, importer)) {
+ const guessed = await guessSubpath(resolvedPath, opts.conditions);
+ if (!guessed) return resolved;
+ importId = guessed;
+ }
+ }Fix 2: Normalize ./ prefix in guessSubpath
- if (e.fsPath === subpath) return join(name, e.subpath);
+ if (e.fsPath === subpath || e.fsPath === `./${subpath}`) return join(name, e.subpath);Why This Matters
NAPI-RS has become the standard for native Node.js addons in the Rust ecosystem. It follows a consistent pattern:
- A pure-JS package (e.g.
@org/package) contains anindex.jsthat dynamicallyrequire()s one of several optional platform-specific packages at runtime (@org/package-linux-arm64-musl,@org/package-darwin-arm64, etc.). - Only the matching platform binary is installed.
- Without tracing, the binary is missing from the output and the server crashes on startup.
Packages using this pattern include: @napi-rs/canvas, @img/sharp-*, lightningcss, @parcel/css, @nicolo-ribaudo/chokidar-*, and many others.
The traceDeps option exists precisely for this use case, but the two bugs above prevent it from working under Bun.
Notes
sharpworks because it is hardcoded in nf3'sNodeNativePackageslist and goes through a separate tracing path. NAPI-RS packages not in that list are affected.- Both fixes are safe: Fix 1 only changes behavior for bare specifiers that were already successfully resolved by
this.resolve(); Fix 2 only adds a second string comparison branch. - Tested with Nitro
3.0.1-20260320+ Bun1.3.11, presetbun, package@takumi-rs/core@0.71.5.
Activity
Mishra-coder commentedon Mar 23, 2026
Hi! I’d like to work on this issue. Could you please assign it to me? I’ll make sure to follow the contribution guidelines. Thanks!
kane50613 commentedon Apr 17, 2026
Hi I'm the maintainer of Takumi, can you try if
@takumi-rs/core@1works?nperez0111 commentedon Apr 18, 2026
Hi @kane50613, unfortunately this still does not seem to work. I think I know what needs to change though.
Bun, has node compat but is unable to use NAPI bindings yet. So this check here makes the program believe it is running on node (even though it is on bun) and failed to load properly, I think you'd just need to add
typeof process.versions?.bun !== "string" && typeof process.versions.deno !== "string"so that it does not think bun or deno can run the NAPI version.And, from here the resolution works and is able to fallback to WASM (which both deno and bun can run natively).
fix: patch takumi to fall back to WASM under Bun, drop NAPI workarounds
fix: patch takumi to fall back to WASM under Bun, drop NAPI workaroun…
kane50613 commentedon Apr 18, 2026
Thats not true I use bun for all takumi testing work
In your case in order to run cross platform, you would need to manually install the target platform package explicitly (like
bun i @takumi-rs/core-linux-arm64-musl)Maybe use the target container image to build so everything resolves correctly instead of copying in the artifacts.
Btw takumi is already marked as external in unjs/nf3#31
pi0 commentedon Apr 18, 2026
@kane50613 i haven't checked but is
@takumi-rs/core-linux-arm64-muslanyhow an optional peer dep? (unjs/nf3#35 should have handled it in this situation)kane50613 commentedon Apr 18, 2026
https://npmx.dev/package-code/@takumi-rs/core/v/1.0.14/package.json
It is
And it's written like this
nperez0111 commentedon May 14, 2026
Update: I resolved this for my project by adding a side-effect import (import "@takumi-rs/core") in server code that Nitro's Rolldown bundler can see. This gets the package into tracedPaths, and nf3 then correctly traces its optionalDependencies (the platform-specific .node bindings).
However, the two underlying bugs still exist:
tryResolvefails for bare specifiers under Bun's .bun/ module layout —exsolvecan't resolve packages nested in .bun/install/cache/guessSubpath./ prefix mismatch — comparing "./index.js" against "index.js" never matchesThese would still affect anyone importing an NAPI-RS package directly in Nitro server code with Bun as the package manager and relying on traceDeps to include the native bindings.
fix: guard @takumi-rs/core import for Vite dev compatibility
pi0x commentedon Sep 7, 2026
This was fixed in a13bd4e (#4391), which landed in July — names in
traceDepsare now traced by name, so a package that cannot be resolved from its importer still gets copied. I checked on current main with a package that has a nested platform binding and is only listed intraceDeps(never imported): both it and the nested binding land in.output/server/node_modules/. On the build just before that commit, nothing is copied at all. Please retest on a recent beta.The second bug in your analysis is not real, for what it is worth —
flattenExportsalready strips the./prefix fromfsPath, so the comparison inguessSubpathdoes match.Posted by an AI triage agent on behalf of a maintainer.