Skip to content

traceDeps fails to trace NAPI-RS packages when using Bun #4140

Closed

Description

@nperez0111

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

  1. Install a NAPI-RS package — one that dynamically loads a platform-specific optional dependency at runtime. @takumi-rs/core is an example: its index.js calls require('@takumi-rs/core-linux-arm64-musl') (or the appropriate platform variant) at runtime.

  2. Add it to traceDeps in vite.config.ts:

nitro({
  preset: "bun",
  traceDeps: ["@takumi-rs/core"],
})
  1. Run vite build. The build succeeds, but .output/server/node_modules/@takumi-rs/ is absent.

  2. 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):

  1. tryResolve incorrectly gates bare package specifiers — when importId is a bare specifier like @takumi-rs/core, tryResolve returns undefined under Bun because exsolve cannot traverse Bun's nested .bun/ package layout. This causes the handler to fall through to guessSubpath and ultimately return early before tracedPaths.add() is reached.

  2. guessSubpath has a ./ prefix mismatch — package exports map entries have fsPath values prefixed with ./ (e.g. "./index.js"), but the subpath captured from the file path regex is unprefixed (e.g. "index.js"). The equality check e.fsPath === subpath never matches, so guessSubpath always returns undefined.

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 an index.js that dynamically require()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

  • sharp works because it is hardcoded in nf3's NodeNativePackages list 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 + Bun 1.3.11, preset bun, package @takumi-rs/core@0.71.5.

Logs

Activity

  1. Mishra-coder commented on Mar 23, 2026

    @Mishra-coder

    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!

  2. kane50613 commented on Apr 17, 2026

    @kane50613
    Contributor

    Hi I'm the maintainer of Takumi, can you try if @takumi-rs/core@1 works?

  3. nperez0111 commented on Apr 18, 2026

    @nperez0111
    Author

    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).

  4. kane50613 commented on Apr 18, 2026

    @kane50613
    Contributor

    Bun is unable to use NAPI bindings yet

    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

  5. pi0 commented on Apr 18, 2026

    @pi0
    Member

    @kane50613 i haven't checked but is @takumi-rs/core-linux-arm64-musl anyhow an optional peer dep? (unjs/nf3#35 should have handled it in this situation)

  6. kane50613 commented on Apr 18, 2026

    @kane50613
    Contributor

    @kane50613 i haven't checked but is @takumi-rs/core-linux-arm64-musl anyhow an optional peer dep? (unjs/nf3#35 should have handled it in this situation)

    https://npmx.dev/package-code/@takumi-rs/core/v/1.0.14/package.json

    It is

    And it's written like this

    require(/* turbopackOptional: true */ "@takumi-rs/core-darwin-arm64/core.darwin-arm64.node");
  7. nperez0111 commented on May 14, 2026

    @nperez0111
    Author

    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:

    1. tryResolve fails for bare specifiers under Bun's .bun/ module layout — exsolve can't resolve packages nested in .bun/install/cache/
    2. guessSubpath ./ prefix mismatch — comparing "./index.js" against "index.js" never matches
      These 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.
  8. added
    bugSomething isn't working
    and removed on Sep 2, 2026
  9. pi0x commented on Sep 7, 2026

    @pi0x
    Contributor

    This was fixed in a13bd4e (#4391), which landed in July — names in traceDeps are 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 in traceDeps (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 — flattenExports already strips the ./ prefix from fsPath, so the comparison in guessSubpath does match.

    Posted by an AI triage agent on behalf of a maintainer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions