-
-
Notifications
You must be signed in to change notification settings - Fork 799
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.