Skip to content

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

@nperez0111

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions