Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
1e2e1cc22d fix(bundler): respect --external flag for Node.js built-ins with browser target
When using `--external` with Node.js built-in modules like `path`, `crypto`,
`buffer`, etc., and targeting browsers (the default), the bundler would ignore
the external flag and bundle the browser polyfill instead.

The issue was that the Node.js polyfill/fallback logic in the resolver ran
before checking if the module was marked as external. This fix adds an early
check for external patterns and the external modules map specifically for
Node.js built-ins, before the polyfill logic applies.

Fixes #2701

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 01:03:21 +00:00
2 changed files with 207 additions and 0 deletions

View File

@@ -1214,6 +1214,48 @@ pub const Resolver = struct {
const had_node_prefix = strings.hasPrefixComptime(import_path, "node:");
const import_path_without_node_prefix = if (had_node_prefix) import_path["node:".len..] else import_path;
// Check if this Node.js built-in is marked as external before applying polyfills.
// This allows --external to work with node built-ins like "path", "crypto", etc.
if (NodeFallbackModules.Map.has(import_path_without_node_prefix) or
bun.jsc.ModuleLoader.HardcodedModule.Alias.has(import_path_without_node_prefix, .node, .{}))
{
// Check external patterns (handles --external "path" style)
if (r.isExternalPattern(import_path)) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("The node builtin \"{s}\" was marked as external by pattern", .{import_path});
r.flushDebugLogs(.success) catch {};
}
return .{
.success = Result{
.path_pair = .{ .primary = Path.init(import_path) },
.module_type = .esm,
.flags = .{ .is_external = true },
},
};
}
// Check external node_modules map (handles --external path style for bare specifiers)
if (r.opts.external.node_modules.count() > 0) {
var query = import_path;
while (true) {
if (r.opts.external.node_modules.contains(query)) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("The node builtin \"{s}\" was marked as external by the user", .{query});
r.flushDebugLogs(.success) catch {};
}
return .{
.success = Result{
.path_pair = .{ .primary = Path.init(import_path) },
.module_type = .esm,
.flags = .{ .is_external = true },
},
};
}
const slash = strings.lastIndexOfChar(query, '/') orelse break;
query = query[0..slash];
}
}
}
if (NodeFallbackModules.Map.get(import_path_without_node_prefix)) |*fallback_module| {
result.path_pair.primary = fallback_module.path;
result.module_type = .cjs;

View File

@@ -0,0 +1,165 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Test that --external flag works with Node.js built-in modules when using browser target.
// Issue: https://github.com/oven-sh/bun/issues/2701
test("--external works with Node.js built-in 'path' for browser target", async () => {
using dir = tempDir("external-node-builtins", {
"index.js": `import path from 'path';
console.log(path.join('a', 'b'));`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "index.js", "--outfile", "out.js", "--external", "path"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const output = await Bun.file(`${dir}/out.js`).text();
// The output should contain an external import, not the polyfill
expect(output).toContain('from "path"');
// Should NOT contain the polyfill implementation
expect(output).not.toContain("normalizeArray");
// Output should be small (just the preserved import), not the full polyfill (~10KB)
expect(output.length).toBeLessThan(500);
});
test("--external works with Node.js built-in 'crypto' for browser target", async () => {
using dir = tempDir("external-node-builtins", {
"index.js": `import crypto from 'crypto';
console.log(crypto.randomBytes(16));`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "index.js", "--outfile", "out.js", "--external", "crypto"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const output = await Bun.file(`${dir}/out.js`).text();
// The output should contain an external import, not the polyfill
expect(output).toContain('from "crypto"');
// Output should be small (just the preserved import), not the full polyfill
expect(output.length).toBeLessThan(500);
});
test("--external works with node: prefixed built-in for browser target", async () => {
using dir = tempDir("external-node-builtins", {
"index.js": `import path from 'node:path';
console.log(path.join('a', 'b'));`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "index.js", "--outfile", "out.js", "--external", "node:path"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const output = await Bun.file(`${dir}/out.js`).text();
// The output should contain an external import, not the polyfill
expect(output).toContain('from "node:path"');
// Output should be small (just the preserved import), not the full polyfill
expect(output.length).toBeLessThan(500);
});
test("--external works with 'fs' for browser target (no polyfill)", async () => {
using dir = tempDir("external-node-builtins", {
"index.js": `import fs from 'fs';
console.log(fs.readFileSync('test.txt'));`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "index.js", "--outfile", "out.js", "--external", "fs"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const output = await Bun.file(`${dir}/out.js`).text();
// The output should contain an external import, not the stub
expect(output).toContain('from "fs"');
// Should NOT contain the empty stub
expect(output).not.toContain("(() => ({}))");
// Output should be small
expect(output.length).toBeLessThan(500);
});
test("--external with subpath works for Node.js built-in for browser target", async () => {
using dir = tempDir("external-node-builtins", {
"index.js": `import { join } from 'path/posix';
console.log(join('a', 'b'));`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "index.js", "--outfile", "out.js", "--external", "path"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const output = await Bun.file(`${dir}/out.js`).text();
// The output should contain an external import for path/posix
expect(output).toContain('from "path/posix"');
// Output should be small
expect(output.length).toBeLessThan(500);
});
test("without --external, Node.js built-ins are still polyfilled for browser target", async () => {
using dir = tempDir("external-node-builtins", {
"index.js": `import path from 'path';
console.log(path.join('a', 'b'));`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "index.js", "--outfile", "out.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const output = await Bun.file(`${dir}/out.js`).text();
// Without --external, the polyfill should be bundled
expect(output).toContain("normalizeStringPosix");
// Output should be large (the full polyfill)
expect(output.length).toBeGreaterThan(1000);
});