Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
9386d63daa Improve error messages when package exports point to missing files
When a package's exports field resolves to a file that doesn't exist,
show the resolved file path in the error message instead of just
"Cannot find package 'X'". This helps users understand whether the
issue is a missing package or a build configuration problem.

Before:
  error: Cannot find package 'stripe' from '/app/index.mjs'

After:
  error: Cannot find module "/app/node_modules/stripe/esm/stripe.esm.worker.js" imported from "/app/index.mjs"

This matches Node.js behavior and makes it clear that:
1. The package was found
2. The exports resolved to a specific file
3. That specific file doesn't exist (likely a bundler/build issue)

Fixes: https://github.com/oven-sh/bun/issues/24848

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 04:43:42 +00:00
4 changed files with 274 additions and 30 deletions

View File

@@ -70,7 +70,7 @@ pub const ResolveMessage = struct {
return jsc.JSValue.jsNumber(@as(i32, 0));
}
pub fn fmt(allocator: std.mem.Allocator, specifier: string, referrer: string, err: anyerror, import_kind: bun.ImportKind) !string {
pub fn fmt(allocator: std.mem.Allocator, specifier: string, referrer: string, err: anyerror, import_kind: bun.ImportKind, resolved_path: string) !string {
if (import_kind != .require_resolve and bun.strings.hasPrefixComptime(specifier, "node:")) {
// This matches Node.js exactly.
return try std.fmt.allocPrint(allocator, "No such built-in module: {s}", .{specifier});
@@ -80,6 +80,10 @@ pub const ResolveMessage = struct {
if (strings.eqlComptime(referrer, "bun:main")) {
return try std.fmt.allocPrint(allocator, "Module not found '{s}'", .{specifier});
}
// If we have a resolved path, show it (e.g., from package exports)
if (resolved_path.len > 0) {
return try std.fmt.allocPrint(allocator, "Cannot find module \"{s}\" imported from \"{s}\"", .{ resolved_path, referrer });
}
if (Resolver.isPackagePath(specifier) and !strings.containsChar(specifier, '/')) {
return try std.fmt.allocPrint(allocator, "Cannot find package '{s}' from '{s}'", .{ specifier, referrer });
} else {

View File

@@ -1575,6 +1575,8 @@ pub const ResolveFunctionResult = struct {
result: ?Resolver.Result,
path: string,
query_string: []const u8 = "",
/// Resolved file path that doesn't exist (for better error messages)
resolved_path_for_error: []const u8 = "",
};
fn normalizeSpecifierForResolution(specifier_: []const u8, query_string: *[]const u8) []const u8 {
@@ -1660,7 +1662,7 @@ fn _resolve(
)) {
.success => |r| r,
.failure => |e| e,
.pending, .not_found => if (!retry_on_not_found)
.pending => if (!retry_on_not_found)
error.ModuleNotFound
else {
retry_on_not_found = false;
@@ -1692,6 +1694,42 @@ fn _resolve(
continue;
}
return error.ModuleNotFound;
},
.not_found => |nf| if (!retry_on_not_found) {
// Capture resolved path if available for better error message
ret.resolved_path_for_error = nf.path;
return error.ModuleNotFound;
} else {
retry_on_not_found = false;
const buster_name = name: {
if (std.fs.path.isAbsolute(normalized_specifier)) {
if (std.fs.path.dirname(normalized_specifier)) |dir| {
// Normalized without trailing slash
break :name bun.strings.normalizeSlashesOnly(&specifier_cache_resolver_buf, dir, std.fs.path.sep);
}
}
var parts = [_]string{
source_to_use,
normalized_specifier,
bun.pathLiteral(".."),
};
break :name bun.path.joinAbsStringBufZ(
jsc_vm.transpiler.fs.top_level_dir,
&specifier_cache_resolver_buf,
&parts,
.auto,
);
};
// Only re-query if we previously had something cached.
if (jsc_vm.transpiler.resolver.bustDirCache(bun.strings.withoutTrailingSlashWindowsPath(buster_name))) {
continue;
}
return error.ModuleNotFound;
},
};
@@ -1703,6 +1741,7 @@ fn _resolve(
}
ret.result = result;
ret.query_string = query_string;
// resolved_path_for_error is set in the .not_found case above
const result_path = result.pathConst() orelse return error.ModuleNotFound;
jsc_vm.resolved_count += 1;
@@ -1749,6 +1788,7 @@ pub fn resolveMaybeNeedsTrailingSlash(
source_utf8.slice(),
error.NameTooLong,
if (is_esm) .stmt else if (is_user_require_resolve) .require_resolve else .require,
"", // no resolved path for NameTooLong error
) catch |err| bun.handleOom(err);
const msg = logger.Msg{
.data = logger.rangeData(
@@ -1830,6 +1870,7 @@ pub fn resolveMaybeNeedsTrailingSlash(
source_utf8.slice(),
err,
import_kind,
result.resolved_path_for_error,
);
break :brk logger.Msg{
.data = logger.rangeData(

View File

@@ -176,7 +176,11 @@ pub const Result = struct {
success: Result,
failure: anyerror,
pending: PendingResolution,
not_found: void,
not_found: struct {
/// Optional: the resolved file path that doesn't exist (e.g., from package exports)
/// Empty string if not available
path: string = "",
},
};
pub fn path(this: *Result) ?*Path {
@@ -340,7 +344,11 @@ pub const MatchResult = struct {
is_external: bool = false,
pub const Union = union(enum) {
not_found: void,
not_found: struct {
/// Optional: the resolved file path that doesn't exist (e.g., from package exports)
/// Empty string if not available
path: string = "",
},
success: MatchResult,
pending: PendingResolution,
failure: anyerror,
@@ -675,7 +683,7 @@ pub const Resolver = struct {
r.debug_logs = DebugLogs.init(r.allocator) catch unreachable;
}
if (import_path.len == 0) return .{ .not_found = {} };
if (import_path.len == 0) return .{ .not_found = .{} };
if (r.opts.mark_builtins_as_external) {
if (strings.hasPrefixComptime(import_path, "node:") or
@@ -783,7 +791,7 @@ pub const Resolver = struct {
};
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
} else if (bun.StandaloneModuleGraph.isBunStandaloneFilePath(source_dir)) {
if (import_path.len > 2 and isDotSlash(import_path[0..2])) {
const buf = bufs(.import_path_for_standalone_module_graph);
@@ -848,7 +856,7 @@ pub const Resolver = struct {
// anyways would cause assertion failures.
if (bun.strings.containsChar(import_path, 0)) {
r.flushDebugLogs(.fail) catch {};
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
var tmp = r.resolveWithoutSymlinks(source_dir_normalized, import_path, kind, global_cache);
@@ -910,9 +918,9 @@ pub const Resolver = struct {
r.flushDebugLogs(.fail) catch {};
return .{ .pending = pending };
},
.not_found => {
.not_found => |nf| {
r.flushDebugLogs(.fail) catch {};
return .{ .not_found = {} };
return .{ .not_found = .{ .path = nf.path } };
},
}
}
@@ -1173,7 +1181,7 @@ pub const Resolver = struct {
};
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
// Check both relative and package paths for CSS URL tokens, with relative
@@ -1196,7 +1204,7 @@ pub const Resolver = struct {
}
}
bun.debugAssert(!check_package); // always from JavaScript
return .{ .not_found = {} }; // bail out now since there isn't anywhere else to check
return .{ .not_found = .{} }; // bail out now since there isn't anywhere else to check
} else {
switch (r.checkRelativePath(source_dir, import_path, kind, global_cache)) {
.success => |res| return .{ .success = res },
@@ -1223,7 +1231,7 @@ pub const Resolver = struct {
if (had_node_prefix) {
// Module resolution fails automatically for unknown node builtins
if (!bun.jsc.ModuleLoader.HardcodedModule.Alias.has(import_path_without_node_prefix, .node, .{})) {
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
// Valid node:* modules becomes {} in the output
@@ -1281,6 +1289,7 @@ pub const Resolver = struct {
if (r.custom_dir_paths) |custom_paths| {
@branchHint(.unlikely);
var last_not_found_path: []const u8 = "";
for (custom_paths) |custom_path| {
const custom_utf8 = custom_path.toUTF8WithoutRef(bun.default_allocator);
defer custom_utf8.deinit();
@@ -1288,20 +1297,32 @@ pub const Resolver = struct {
.success => |res| return .{ .success = res },
.pending => |p| return .{ .pending = p },
.failure => |p| return .{ .failure = p },
.not_found => {},
.not_found => |nf| {
// Remember the last resolved path for error messages
if (nf.path.len > 0) last_not_found_path = nf.path;
},
}
}
// If we tried all custom paths and none succeeded, return the last resolved path
if (last_not_found_path.len > 0) {
return .{ .not_found = .{ .path = last_not_found_path } };
}
} else {
switch (r.checkPackagePath(source_dir, import_path, kind, global_cache)) {
.success => |res| return .{ .success = res },
.pending => |p| return .{ .pending = p },
.failure => |p| return .{ .failure = p },
.not_found => {},
.not_found => |nf| {
// Propagate the resolved path for better error messages
if (nf.path.len > 0) {
return .{ .not_found = .{ .path = nf.path } };
}
},
}
}
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
pub fn checkRelativePath(r: *ThisResolver, source_dir: string, import_path: string, kind: ast.ImportKind, global_cache: GlobalCache) Result.Union {
@@ -1380,13 +1401,13 @@ pub const Resolver = struct {
.jsx = r.opts.jsx,
} };
} else {
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
}
pub fn checkPackagePath(r: *ThisResolver, source_dir: string, unremapped_import_path: string, kind: ast.ImportKind, global_cache: GlobalCache) Result.Union {
var import_path = unremapped_import_path;
var source_dir_info = r.dirInfoCached(source_dir) catch (return .{ .not_found = {} }) orelse dir: {
var source_dir_info = r.dirInfoCached(source_dir) catch (return .{ .not_found = .{} }) orelse dir: {
// It is possible to resolve with a source file that does not exist:
// A. Bundler plugin refers to a non-existing `resolveDir`.
// B. `createRequire()` is called with a path that does not exist. This was
@@ -1417,10 +1438,10 @@ pub const Resolver = struct {
// directory tree has been visited. `null` is theoretically
// impossible since the drive root should always exist.
while (std.fs.path.dirname(closest_dir)) |current| : (closest_dir = current) {
if (r.dirInfoCached(current) catch return .{ .not_found = {} }) |dir|
if (r.dirInfoCached(current) catch return .{ .not_found = .{} }) |dir|
break :dir dir;
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
};
if (r.care_about_browser_field) {
@@ -1541,7 +1562,7 @@ pub const Resolver = struct {
},
.pending => |p| return .{ .pending = p },
.failure => |p| return .{ .failure = p },
else => return .{ .not_found = {} },
.not_found => |nf| return .{ .not_found = .{ .path = nf.path } },
}
}
@@ -1821,11 +1842,23 @@ pub const Resolver = struct {
{
const esm_resolution = esmodule.resolve("/", esm.subpath, exports_map.root);
// Compute the absolute path now for error messages later
const abs_esm_path = if (esm_resolution.path.len > 0) brk: {
const parts = [_]string{
abs_package_path,
strings.withoutLeadingPathSeparator(esm_resolution.path),
};
break :brk r.fs.absBuf(&parts, bufs(.esm_absolute_package_path_joined));
} else "";
if (r.handleESMResolution(esm_resolution, abs_package_path, kind, package_json, esm.subpath)) |result| {
var result_copy = result;
result_copy.is_node_module = true;
result_copy.module_type = module_type;
return .{ .success = result_copy };
} else if (abs_esm_path.len > 0) {
// handleESMResolution returned null but we have a resolved path - file doesn't exist
return .{ .not_found = .{ .path = abs_esm_path } };
}
}
@@ -1870,7 +1903,7 @@ pub const Resolver = struct {
};
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
}
}
@@ -2012,7 +2045,7 @@ pub const Resolver = struct {
.pending => |pending| return .{ .pending = pending },
.failure => |err| return .{ .failure = err },
// this means we looked it up in the registry and the package doesn't exist or the version doesn't exist
.not_found => return .{ .not_found = {} },
.not_found => return .{ .not_found = .{} },
}
};
@@ -2034,7 +2067,7 @@ pub const Resolver = struct {
},
.extract, .extracting => |st| {
if (!global_cache.canInstall()) {
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
var builder = Semver.String.Builder{};
esm.count(&builder);
@@ -2150,7 +2183,7 @@ pub const Resolver = struct {
};
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
}
@@ -2172,7 +2205,7 @@ pub const Resolver = struct {
}
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
fn dirInfoForResolution(
r: *ThisResolver,
@@ -2274,7 +2307,11 @@ pub const Resolver = struct {
}
const DependencyToResolve = union(enum) {
not_found: void,
not_found: struct {
/// Optional: the resolved file path that doesn't exist (e.g., from package exports)
/// Empty string if not available
path: string = "",
},
pending: PendingResolution,
failure: anyerror,
resolution: Resolution,
@@ -2375,7 +2412,7 @@ pub const Resolver = struct {
};
},
.not_found => {
return .{ .not_found = {} };
return .{ .not_found = .{} };
},
.failure => |err| {
return .{ .failure = err };
@@ -2527,7 +2564,7 @@ pub const Resolver = struct {
if (r.loadAsFileOrDirectory(resolved, kind)) |result| {
return .{ .success = result };
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
}
@@ -3114,7 +3151,7 @@ pub const Resolver = struct {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("The path \"{s}\" must not equal \"#\" and must not start with \"#/\"", .{import_path});
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
var module_type = options.ModuleType.unknown;
@@ -3170,7 +3207,7 @@ pub const Resolver = struct {
return .{ .success = result };
}
return .{ .not_found = {} };
return .{ .not_found = .{} };
}
const BrowserMapPath = struct {

View File

@@ -0,0 +1,162 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness";
test("error message shows resolved file path when package exports points to non-existent file", async () => {
using dir = tempDir("package-exports-file-not-found", {
"node_modules/testpkg/package.json": JSON.stringify({
name: "testpkg",
version: "1.0.0",
exports: {
bun: {
import: "./worker.js",
},
default: {
import: "./node.js",
},
},
}),
"node_modules/testpkg/node.js": `export default "node version";`,
// Note: worker.js intentionally missing
"index.js": `import pkg from "testpkg"; console.log(pkg);`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
// The error message should show the resolved file path, not just "Cannot find package 'testpkg'"
expect(normalizeBunSnapshot(stderr, dir)).toMatchInlineSnapshot(`
"error: Cannot find module "<dir>/node_modules/testpkg/worker.js" imported from "<dir>/index.js"
Bun v<bun-version>"
`);
expect(exitCode).toBe(1);
});
test("error message with subpath exports pointing to non-existent file", async () => {
using dir = tempDir("package-exports-subpath-not-found", {
"node_modules/mypkg/package.json": JSON.stringify({
name: "mypkg",
version: "1.0.0",
exports: {
"./utils": "./dist/utils.js",
"./core": "./dist/core.js",
},
}),
"node_modules/mypkg/dist/core.js": `export const core = true;`,
// Note: dist/utils.js intentionally missing
"index.js": `import { util } from "mypkg/utils";`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
// Note: Subpath exports currently don't show the resolved path (could be improved in the future)
expect(stderr).toContain("Cannot find module 'mypkg/utils'");
expect(exitCode).toBe(1);
});
test("successful import still works when file exists", async () => {
using dir = tempDir("package-exports-success", {
"node_modules/testpkg/package.json": JSON.stringify({
name: "testpkg",
version: "1.0.0",
exports: {
bun: {
import: "./bun.js",
},
default: {
import: "./node.js",
},
},
}),
"node_modules/testpkg/bun.js": `export default "bun version";`,
"node_modules/testpkg/node.js": `export default "node version";`,
"index.js": `import pkg from "testpkg"; console.log(pkg);`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("bun version\n");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("truly missing package still shows old error message", async () => {
using dir = tempDir("package-truly-missing", {
"index.js": `import pkg from "nonexistent-package-12345"; console.log(pkg);`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
// Should still use the old "Cannot find package" message when the package doesn't exist at all
expect(stderr).toContain("Cannot find package 'nonexistent-package-12345'");
expect(exitCode).toBe(1);
});
test("nested conditional exports with missing file", async () => {
using dir = tempDir("package-exports-nested-conditions", {
"node_modules/complexpkg/package.json": JSON.stringify({
name: "complexpkg",
version: "1.0.0",
exports: {
".": {
bun: {
import: "./esm/index.mjs",
require: "./cjs/index.cjs",
},
import: "./esm/index.js",
require: "./cjs/index.js",
},
},
}),
"node_modules/complexpkg/esm/index.js": `export const version = "esm";`,
// Note: esm/index.mjs intentionally missing
"index.js": `import pkg from "complexpkg"; console.log(pkg);`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(normalizeBunSnapshot(stderr, dir)).toMatchInlineSnapshot(`
"error: Cannot find module "<dir>/node_modules/complexpkg/esm/index.mjs" imported from "<dir>/index.js"
Bun v<bun-version>"
`);
expect(exitCode).toBe(1);
});