Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
fd4c0144d4 fix(resolver): fall back to next condition when export target file doesn't exist
When resolving conditional exports (e.g., "bun", "node", "default"), Bun now
verifies that the resolved file actually exists before returning it. If the
file doesn't exist, resolution continues to the next matching condition.

This fixes an issue with Next.js standalone builds where the file tracer
(@vercel/nft) only copies files needed for the Node.js runtime. When
react-dom/server is resolved, the "bun" condition points to server.bun.js
which doesn't exist in the standalone output, but server.node.js does exist.

Previously, Bun would fail with "Cannot find module 'react-dom/server'"
because it selected server.bun.js and never tried the fallback conditions.

Fixes #24184

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:10:07 +00:00
3 changed files with 354 additions and 0 deletions

View File

@@ -1302,10 +1302,22 @@ pub const ExportsMap = struct {
pub const ESModule = struct {
pub const ConditionsMap = bun.StringArrayHashMap(void);
/// Callback structure for checking if a resolved path exists.
/// Used to fall back to other conditions when a file doesn't exist.
pub const FileExistsCheck = struct {
context: *anyopaque,
call: *const fn (ctx: *anyopaque, resolved_path: string) bool,
pub fn check(self: @This(), resolved_path: string) bool {
return self.call(self.context, resolved_path);
}
};
debug_logs: ?*resolver.DebugLogs = null,
conditions: ConditionsMap,
allocator: std.mem.Allocator,
module_type: *options.ModuleType = undefined,
file_exists_check: ?FileExistsCheck = null,
pub const Resolution = struct {
status: Status = Status.Undefined,
@@ -1842,6 +1854,26 @@ pub const ESModule = struct {
continue;
}
// If a file existence check is provided and the result is a concrete path,
// verify the file exists. If it doesn't, continue to the next condition.
// This handles cases like Next.js standalone builds where the "bun" condition
// points to a file that wasn't copied, but the "node" fallback exists.
if (r.file_exists_check) |checker| {
if (result.status == .Exact or result.status == .ExactEndsWithStar or result.status == .Inexact) {
if (result.path.len > 0 and result.path[0] == '/') {
if (!checker.check(result.path)) {
if (r.debug_logs) |log| {
log.addNoteFmt("The resolved path \"{s}\" does not exist, trying next condition", .{result.path});
}
did_find_map_entry = true;
last_map_entry_i = i;
r.module_type.* = prev_module_type;
continue;
}
}
}
}
if (strings.eqlComptime(key, "import")) {
r.module_type.* = .esm;
}

View File

@@ -1805,6 +1805,41 @@ pub const Resolver = struct {
// The condition set is determined by the kind of import
var module_type = package_json.module_type;
// File existence check context for falling back to next condition
// when a conditional export target file doesn't exist.
// This handles cases like Next.js standalone builds where the "bun"
// condition points to a file that wasn't copied (e.g., server.bun.js),
// but the "node" fallback exists (e.g., server.node.js).
const FileExistsContext = struct {
resolver: *ThisResolver,
abs_package_path: string,
gen: bun.Generation,
fn check(ctx_ptr: *anyopaque, resolved_path: string) bool {
const ctx: *@This() = @ptrCast(@alignCast(ctx_ptr));
// Build absolute path by joining package path with resolved path
var parts = [_]string{
ctx.abs_package_path,
strings.withoutLeadingPathSeparator(resolved_path),
};
const abs_esm_path = ctx.resolver.fs.absBuf(&parts, bufs(.esm_absolute_package_path_joined));
const esm_dir_path = std.fs.path.dirname(abs_esm_path) orelse return false;
const base = std.fs.path.basename(abs_esm_path);
// Check if the directory and file exist
const esm_dir_info = ctx.resolver.dirInfoCached(esm_dir_path) catch return false;
if (esm_dir_info == null) return false;
const entries = esm_dir_info.?.getEntries(ctx.gen) orelse return false;
return entries.get(base) != null;
}
};
var file_exists_ctx = FileExistsContext{
.resolver = r,
.abs_package_path = abs_package_path,
.gen = r.generation,
};
const esmodule = ESModule{
.conditions = switch (kind) {
ast.ImportKind.require, ast.ImportKind.require_resolve => r.opts.conditions.require,
@@ -1814,6 +1849,10 @@ pub const Resolver = struct {
.allocator = r.allocator,
.debug_logs = if (r.debug_logs) |*debug| debug else null,
.module_type = &module_type,
.file_exists_check = .{
.context = @ptrCast(&file_exists_ctx),
.call = FileExistsContext.check,
},
};
// Resolve against the path "/", then join it with the absolute
@@ -2082,6 +2121,34 @@ pub const Resolver = struct {
var module_type = options.ModuleType.unknown;
if (pkg_dir_info.package_json) |package_json| {
if (package_json.exports) |exports_map| {
// File existence check context for falling back to next condition
const FileExistsContext = struct {
resolver: *ThisResolver,
abs_package_path: string,
gen: bun.Generation,
fn check(ctx_ptr: *anyopaque, resolved_path: string) bool {
const ctx: *@This() = @ptrCast(@alignCast(ctx_ptr));
var parts = [_]string{
ctx.abs_package_path,
strings.withoutLeadingPathSeparator(resolved_path),
};
const abs_esm_path = ctx.resolver.fs.absBuf(&parts, bufs(.esm_absolute_package_path_joined));
const esm_dir_path = std.fs.path.dirname(abs_esm_path) orelse return false;
const base = std.fs.path.basename(abs_esm_path);
const esm_dir_info = ctx.resolver.dirInfoCached(esm_dir_path) catch return false;
if (esm_dir_info == null) return false;
const entries = esm_dir_info.?.getEntries(ctx.gen) orelse return false;
return entries.get(base) != null;
}
};
var file_exists_ctx = FileExistsContext{
.resolver = r,
.abs_package_path = abs_package_path,
.gen = r.generation,
};
// The condition set is determined by the kind of import
const esmodule = ESModule{
.conditions = switch (kind) {
@@ -2096,6 +2163,10 @@ pub const Resolver = struct {
debug
else
null,
.file_exists_check = .{
.context = @ptrCast(&file_exists_ctx),
.call = FileExistsContext.check,
},
};
// Resolve against the path "/", then join it with the absolute
@@ -3122,6 +3193,34 @@ pub const Resolver = struct {
}
var module_type = options.ModuleType.unknown;
// File existence check context for imports map
const FileExistsContext = struct {
resolver: *ThisResolver,
abs_package_path: string,
gen: bun.Generation,
fn check(ctx_ptr: *anyopaque, resolved_path: string) bool {
const ctx: *@This() = @ptrCast(@alignCast(ctx_ptr));
var parts = [_]string{
ctx.abs_package_path,
strings.withoutLeadingPathSeparator(resolved_path),
};
const abs_esm_path = ctx.resolver.fs.absBuf(&parts, bufs(.esm_absolute_package_path_joined));
const dir_path = std.fs.path.dirname(abs_esm_path) orelse return false;
const base = std.fs.path.basename(abs_esm_path);
const dir_info_result = ctx.resolver.dirInfoCached(dir_path) catch return false;
if (dir_info_result == null) return false;
const entries = dir_info_result.?.getEntries(ctx.gen) orelse return false;
return entries.get(base) != null;
}
};
var file_exists_ctx = FileExistsContext{
.resolver = r,
.abs_package_path = package_json.source.path.name.dir,
.gen = r.generation,
};
const esmodule = ESModule{
.conditions = switch (kind) {
ast.ImportKind.require,
@@ -3132,6 +3231,10 @@ pub const Resolver = struct {
.allocator = r.allocator,
.debug_logs = if (r.debug_logs) |*debug| debug else null,
.module_type = &module_type,
.file_exists_check = .{
.context = @ptrCast(&file_exists_ctx),
.call = FileExistsContext.check,
},
};
const esm_resolution = esmodule.resolveImports(import_path, imports_map.root);

View File

@@ -0,0 +1,219 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Test that Bun falls back to the next conditional export when a file doesn't exist
// https://github.com/oven-sh/bun/issues/24184
//
// This issue occurs in Next.js standalone builds where the file tracer only copies
// files needed for the Node.js runtime, not Bun-specific files like server.bun.js.
// When react-dom/server is resolved, Bun should fall back from "bun" to "node" condition
// when server.bun.js doesn't exist.
test("conditional export fallback when bun condition file is missing", async () => {
// Create a test directory simulating Next.js standalone output
using dir = tempDir("issue-24184", {
"node_modules/react-dom/package.json": JSON.stringify({
name: "react-dom",
exports: {
"./server": {
bun: "./server.bun.js",
node: "./server.node.js",
default: "./server.node.js",
},
},
}),
// Only create the node version, simulating Next.js file tracing
"node_modules/react-dom/server.node.js": `module.exports = { renderToStaticMarkup: () => "rendered" };`,
// Note: server.bun.js is intentionally NOT created
"index.js": `
const { renderToStaticMarkup } = require('react-dom/server');
console.log(typeof renderToStaticMarkup);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// Should resolve to server.node.js and output "function"
expect(stdout.trim()).toBe("function");
expect(exitCode).toBe(0);
});
test("conditional export fallback with ESM import", async () => {
using dir = tempDir("issue-24184-esm", {
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
type: "module",
exports: {
".": {
bun: "./index.bun.js",
node: "./index.node.js",
default: "./index.node.js",
},
},
}),
// Only create the node version
"node_modules/test-pkg/index.node.js": `export const value = "from-node";`,
"index.mjs": `
import { value } from 'test-pkg';
console.log(value);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.mjs"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("from-node");
expect(exitCode).toBe(0);
});
test("conditional export uses first matching file that exists", async () => {
// When bun condition file exists, it should be used
using dir = tempDir("issue-24184-exists", {
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
exports: {
".": {
bun: "./index.bun.js",
node: "./index.node.js",
default: "./index.node.js",
},
},
}),
// Both files exist, bun should be preferred
"node_modules/test-pkg/index.bun.js": `module.exports = { source: "bun" };`,
"node_modules/test-pkg/index.node.js": `module.exports = { source: "node" };`,
"index.js": `
const { source } = require('test-pkg');
console.log(source);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// Should use bun version when it exists
expect(stdout.trim()).toBe("bun");
expect(exitCode).toBe(0);
});
test("conditional export fallback with multiple missing conditions", async () => {
// When multiple conditions don't exist, should fall through to one that does
using dir = tempDir("issue-24184-multiple", {
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
exports: {
".": {
bun: "./index.bun.js",
deno: "./index.deno.js",
node: "./index.node.js",
default: "./index.default.js",
},
},
}),
// Only default exists
"node_modules/test-pkg/index.default.js": `module.exports = { source: "default" };`,
"index.js": `
const { source } = require('test-pkg');
console.log(source);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// Should fall through to default when bun and node files don't exist
expect(stdout.trim()).toBe("default");
expect(exitCode).toBe(0);
});
test("conditional export fallback with subpath exports", async () => {
// Test with subpath exports like react-dom/server
using dir = tempDir("issue-24184-subpath", {
"node_modules/my-pkg/package.json": JSON.stringify({
name: "my-pkg",
exports: {
".": {
bun: "./index.bun.js",
default: "./index.js",
},
"./server": {
bun: "./server.bun.js",
node: "./server.node.js",
default: "./server.default.js",
},
},
}),
// Main entry exists with bun condition
"node_modules/my-pkg/index.bun.js": `module.exports = { main: true };`,
// Subpath only has node version (like Next.js tracing)
"node_modules/my-pkg/server.node.js": `module.exports = { server: "node" };`,
"index.js": `
const main = require('my-pkg');
const server = require('my-pkg/server');
console.log(main.main, server.server);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// Main uses bun (exists), server falls back to node
expect(stdout.trim()).toBe("true node");
expect(exitCode).toBe(0);
});