Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
91255e70dc Add top-level outfile support to Bun.build API
This adds support for the `outfile` option at the top level of the
Bun.build() API config, matching the behavior of the CLI `--outfile` flag.

Previously, `outfile` was only available inside the `compile` object for
creating standalone executables. Now it can be used for regular bundling
with a single entry point:

```ts
// Now works!
await Bun.build({
  entrypoints: ['./app.ts'],
  outfile: './dist/bundle.js'
});
```

Restrictions (matching CLI behavior):
- Only works with a single entry point
- Cannot be used with `outdir`
- Cannot be used with code splitting

These restrictions match the CLI's validation for `--outfile`.

Implementation details:
- Added `outfile` field to JSBundler.Config struct
- Parse `outfile` from JavaScript config object
- Split outfile into entry_naming (basename) and output_dir (dirname),
  matching CLI behavior in build_command.zig
- Added validation to prevent misuse (multiple entries, splitting, etc.)
- Updated TypeScript types with detailed documentation
- Added comprehensive test coverage including validation tests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 01:44:34 +00:00
4 changed files with 269 additions and 1 deletions

View File

@@ -1925,7 +1925,39 @@ declare module "bun" {
development?: boolean;
};
/**
* The directory to write output files. Use with `naming` to control output file names.
*/
outdir?: string;
/**
* The output file path for single-entry builds.
*
* Restrictions:
* - Cannot be used with `outdir`
* - Only works with a single entry point (use `outdir` for multiple entries)
* - Cannot be used with code splitting (use `outdir` instead)
*
* When specified, the file will be written to this exact path.
* The directory portion becomes the output directory, and the filename
* portion becomes the entry file name.
*
* @example
* ```ts
* // Single entry point - works
* await Bun.build({
* entrypoints: ['./app.ts'],
* outfile: './dist/bundle.js'
* });
*
* // Multiple entry points - use outdir instead
* await Bun.build({
* entrypoints: ['./app.ts', './worker.ts'],
* outdir: './dist' // Not outfile!
* });
* ```
*/
outfile?: string;
}
interface CompileBuildOptions {

View File

@@ -11,6 +11,7 @@ pub const JSBundler = struct {
loaders: ?api.LoaderMap = null,
dir: OwnedString = OwnedString.initEmpty(bun.default_allocator),
outdir: OwnedString = OwnedString.initEmpty(bun.default_allocator),
outfile: OwnedString = OwnedString.initEmpty(bun.default_allocator),
rootdir: OwnedString = OwnedString.initEmpty(bun.default_allocator),
serve: Serve = .{},
jsx: api.Jsx = .{
@@ -327,6 +328,11 @@ pub const JSBundler = struct {
has_out_dir = true;
}
if (try config.getOptional(globalThis, "outfile", ZigString.Slice)) |slice| {
defer slice.deinit();
try this.outfile.appendSliceExact(slice.slice());
}
if (try config.getOptional(globalThis, "banner", ZigString.Slice)) |slice| {
defer slice.deinit();
try this.banner.appendSliceExact(slice.slice());
@@ -725,6 +731,19 @@ pub const JSBundler = struct {
}
}
// Validate outfile usage - matches CLI validation in build_command.zig
if (this.outfile.list.items.len > 0 and this.compile == null) {
if (this.outdir.list.items.len > 0) {
return globalThis.throwInvalidArguments("Cannot use both 'outfile' and 'outdir'. Use 'outfile' for a single output or 'outdir' for multiple outputs.", .{});
}
if (this.entry_points.count() > 1) {
return globalThis.throwInvalidArguments("Cannot use 'outfile' with multiple entry points. Use 'outdir' instead.", .{});
}
if (this.code_splitting) {
return globalThis.throwInvalidArguments("Cannot use 'outfile' when code splitting is enabled. Use 'outdir' instead.", .{});
}
}
return this;
}
@@ -790,6 +809,7 @@ pub const JSBundler = struct {
}
self.names.deinit();
self.outdir.deinit();
self.outfile.deinit();
self.rootdir.deinit();
self.public_path.deinit();
self.conditions.deinit();

View File

@@ -1888,7 +1888,17 @@ pub const BundleV2 = struct {
transpiler.options.public_path = config.public_path.list.items;
}
transpiler.options.output_dir = config.outdir.slice();
// Handle outfile option like the CLI does
// Split outfile into entry_naming (basename) and output_dir (dirname)
if (config.outfile.list.items.len > 0 and !transpiler.options.compile) {
const outfile = config.outfile.slice();
transpiler.options.entry_naming = try std.fmt.allocPrint(alloc, "./{s}", .{
std.fs.path.basename(outfile),
});
transpiler.options.output_dir = std.fs.path.dirname(outfile) orelse ".";
} else {
transpiler.options.output_dir = config.outdir.slice();
}
transpiler.options.root_dir = config.rootdir.slice();
transpiler.options.minify_syntax = config.minify.syntax;
transpiler.options.minify_whitespace = config.minify.whitespace;

View File

@@ -0,0 +1,206 @@
import { expect, test } from "bun:test";
import { existsSync, readFileSync } from "fs";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("Bun.build with outfile - basic usage", async () => {
using dir = tempDir("bundler-outfile", {
"entry.ts": `console.log("hello from entry");`,
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`await Bun.build({
entrypoints: ["${join(dir, "entry.ts")}"],
target: "node",
outfile: "${join(dir, "bundle.js")}"
}); console.log("done");`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("error");
expect(stderr).not.toContain("panic");
expect(exitCode).toBe(0);
const bundlePath = join(dir, "bundle.js");
expect(existsSync(bundlePath)).toBe(true);
const content = readFileSync(bundlePath, "utf8");
expect(content).toContain("hello from entry");
});
test("Bun.build with outfile - subdirectory", async () => {
using dir = tempDir("bundler-outfile-subdir", {
"src/entry.ts": `console.log("hello from src");`,
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`await Bun.build({
entrypoints: ["${join(dir, "src/entry.ts")}"],
target: "node",
outfile: "${join(dir, "dist/output.js")}"
});`,
],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("error");
expect(exitCode).toBe(0);
const bundlePath = join(dir, "dist/output.js");
expect(existsSync(bundlePath)).toBe(true);
const content = readFileSync(bundlePath, "utf8");
expect(content).toContain("hello from src");
});
test("Bun.build with outfile - validation: multiple entry points", async () => {
using dir = tempDir("bundler-outfile-multi-entry", {
"entry1.ts": `console.log("entry1");`,
"entry2.ts": `console.log("entry2");`,
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`try {
await Bun.build({
entrypoints: ["${join(dir, "entry1.ts")}", "${join(dir, "entry2.ts")}"],
target: "node",
outfile: "${join(dir, "out.js")}"
});
console.log("UNEXPECTED SUCCESS");
} catch(e) {
console.log(e.message);
}`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("Cannot use 'outfile' with multiple entry points");
expect(exitCode).toBe(0);
});
test("Bun.build with outfile - validation: outfile + outdir", async () => {
using dir = tempDir("bundler-outfile-both", {
"entry.ts": `console.log("entry");`,
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`try {
await Bun.build({
entrypoints: ["${join(dir, "entry.ts")}"],
target: "node",
outfile: "${join(dir, "out.js")}",
outdir: "${join(dir, "dist")}"
});
console.log("UNEXPECTED SUCCESS");
} catch(e) {
console.log(e.message);
}`,
],
env: bunEnv,
stdout: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toContain("Cannot use both 'outfile' and 'outdir'");
expect(exitCode).toBe(0);
});
test("Bun.build with outfile - validation: code splitting", async () => {
using dir = tempDir("bundler-outfile-splitting", {
"entry.ts": `console.log("entry");`,
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`try {
await Bun.build({
entrypoints: ["${join(dir, "entry.ts")}"],
target: "node",
outfile: "${join(dir, "out.js")}",
splitting: true
});
console.log("UNEXPECTED SUCCESS");
} catch(e) {
console.log(e.message);
}`,
],
env: bunEnv,
stdout: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toContain("Cannot use 'outfile' when code splitting is enabled");
expect(exitCode).toBe(0);
});
test("Bun.build with outfile matches CLI --outfile behavior", async () => {
using dir = tempDir("bundler-outfile-cli-match", {
"test.ts": `export const value = 42; console.log("test");`,
});
// Test with API
await using proc1 = Bun.spawn({
cmd: [
bunExe(),
"-e",
`await Bun.build({
entrypoints: ["${join(dir, "test.ts")}"],
target: "node",
outfile: "${join(dir, "api-output.js")}"
});`,
],
env: bunEnv,
stderr: "pipe",
});
const apiExitCode = await proc1.exited;
// Test with CLI
await using proc2 = Bun.spawn({
cmd: [bunExe(), "build", join(dir, "test.ts"), "--target=node", `--outfile=${join(dir, "cli-output.js")}`],
env: bunEnv,
stderr: "pipe",
});
const cliExitCode = await proc2.exited;
expect(apiExitCode).toBe(0);
expect(cliExitCode).toBe(0);
const apiOutput = readFileSync(join(dir, "api-output.js"), "utf8");
const cliOutput = readFileSync(join(dir, "cli-output.js"), "utf8");
// Both should produce similar output
expect(apiOutput).toContain("value = 42");
expect(cliOutput).toContain("value = 42");
expect(existsSync(join(dir, "api-output.js"))).toBe(true);
expect(existsSync(join(dir, "cli-output.js"))).toBe(true);
});