mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
## Summary
- Adds `import { feature } from "bun:bundle"` for compile-time feature
flag checking
- `feature("FLAG_NAME")` calls are replaced with `true`/`false` at
bundle time
- Enables dead-code elimination through `--feature=FLAG_NAME` CLI
argument
- Works in `bun build`, `bun run`, and `bun test`
- Available in both CLI and `Bun.build()` JavaScript API
## Usage
```ts
import { feature } from "bun:bundle";
if (feature("SUPER_SECRET")) {
console.log("Secret feature enabled!");
} else {
console.log("Normal mode");
}
```
### CLI
```bash
# Enable feature during build
bun build --feature=SUPER_SECRET index.ts
# Enable at runtime
bun run --feature=SUPER_SECRET index.ts
# Enable in tests
bun test --feature=SUPER_SECRET
```
### JavaScript API
```ts
await Bun.build({
entrypoints: ['./index.ts'],
outdir: './out',
features: ['SUPER_SECRET', 'ANOTHER_FLAG'],
});
```
## Implementation
- Added `bundler_feature_flags` (as `*const bun.StringSet`) to
`RuntimeFeatures` and `BundleOptions`
- Added `bundler_feature_flag_ref` to Parser struct to track the
`feature` import
- Handle `bun:bundle` import at parse time (similar to macros) - capture
ref, return empty statement
- Handle `feature()` calls in `e_call` visitor - replace with boolean
based on flags
- Wire feature flags through CLI arguments and `Bun.build()` API to
bundler options
- Added `features` option to `JSBundler.zig` for JavaScript API support
- Added TypeScript types in `bun.d.ts`
- Added documentation to `docs/bundler/index.mdx`
## Test plan
- [x] Basic feature flag enabled/disabled tests (both CLI and API
backends)
- [x] Multiple feature flags test
- [x] Dead code elimination verification tests
- [x] Error handling for invalid arguments
- [x] Runtime tests with `bun run --feature=FLAG`
- [x] Test runner tests with `bun test --feature=FLAG`
- [x] Aliased import tests (`import { feature as checkFeature }`)
- [x] Ternary operator DCE tests
- [x] Tests use `itBundled` with both `backend: "cli"` and `backend:
"api"`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Alistair Smith <hi@alistair.sh>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
536 lines
14 KiB
TypeScript
536 lines
14 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { bunEnv, bunExe, tempDir } from "harness";
|
|
import { itBundled } from "./expectBundled";
|
|
|
|
describe("bundler feature flags", () => {
|
|
// Test both CLI and API backends
|
|
for (const backend of ["cli", "api"] as const) {
|
|
describe(`backend: ${backend}`, () => {
|
|
itBundled(`feature_flag/${backend}/FeatureReturnsTrue`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("SUPER_SECRET")) {
|
|
console.log("feature enabled");
|
|
} else {
|
|
console.log("feature disabled");
|
|
}
|
|
`,
|
|
},
|
|
features: ["SUPER_SECRET"],
|
|
onAfterBundle(api) {
|
|
// The output should contain `true` since the feature is enabled
|
|
api.expectFile("out.js").toInclude("true");
|
|
api.expectFile("out.js").not.toInclude("feature(");
|
|
api.expectFile("out.js").not.toInclude("bun:bundle");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/FeatureReturnsFalse`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("SUPER_SECRET")) {
|
|
console.log("feature enabled");
|
|
} else {
|
|
console.log("feature disabled");
|
|
}
|
|
`,
|
|
},
|
|
// No features enabled
|
|
onAfterBundle(api) {
|
|
// The output should contain `false` since the feature is not enabled
|
|
api.expectFile("out.js").toInclude("false");
|
|
api.expectFile("out.js").not.toInclude("feature(");
|
|
api.expectFile("out.js").not.toInclude("bun:bundle");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/MultipleFlags`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("FLAG_A")) console.log("FLAG_A");
|
|
if (feature("FLAG_B")) console.log("FLAG_B");
|
|
if (feature("FLAG_C")) console.log("FLAG_C");
|
|
`,
|
|
},
|
|
features: ["FLAG_A", "FLAG_C"],
|
|
onAfterBundle(api) {
|
|
// FLAG_A and FLAG_C are enabled, FLAG_B is not
|
|
api.expectFile("out.js").toInclude("true");
|
|
api.expectFile("out.js").toInclude("false");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/DeadCodeElimination`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("ENABLED_FEATURE")) {
|
|
console.log("this should be kept");
|
|
}
|
|
if (feature("DISABLED_FEATURE")) {
|
|
console.log("this should be removed");
|
|
}
|
|
`,
|
|
},
|
|
features: ["ENABLED_FEATURE"],
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
// With minification, dead code should be eliminated
|
|
api.expectFile("out.js").toInclude("this should be kept");
|
|
api.expectFile("out.js").not.toInclude("this should be removed");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/ImportRemoved`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("TEST")) {
|
|
console.log("test enabled");
|
|
}
|
|
`,
|
|
},
|
|
onAfterBundle(api) {
|
|
// The import should be completely removed
|
|
api.expectFile("out.js").not.toInclude("bun:bundle");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/IfBlockRemoved`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
function expensiveComputation() {
|
|
return "expensive result";
|
|
}
|
|
if (feature("DISABLED")) {
|
|
const result = expensiveComputation();
|
|
console.log("This entire block should be removed:", result);
|
|
}
|
|
console.log("This should remain");
|
|
`,
|
|
},
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
// The expensive computation and related code should be completely eliminated
|
|
api.expectFile("out.js").not.toInclude("expensiveComputation");
|
|
api.expectFile("out.js").not.toInclude("expensive result");
|
|
api.expectFile("out.js").not.toInclude("This entire block should be removed");
|
|
api.expectFile("out.js").toInclude("This should remain");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/KeepsElseBranch`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("DISABLED")) {
|
|
console.log("if branch - should be removed");
|
|
} else {
|
|
console.log("else branch - should be kept");
|
|
}
|
|
`,
|
|
},
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").not.toInclude("if branch - should be removed");
|
|
api.expectFile("out.js").toInclude("else branch - should be kept");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/RemovesElseBranch`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("ENABLED")) {
|
|
console.log("if branch - should be kept");
|
|
} else {
|
|
console.log("else branch - should be removed");
|
|
}
|
|
`,
|
|
},
|
|
features: ["ENABLED"],
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("if branch - should be kept");
|
|
api.expectFile("out.js").not.toInclude("else branch - should be removed");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/AliasedImport`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature as checkFeature } from "bun:bundle";
|
|
if (checkFeature("ALIASED")) {
|
|
console.log("aliased feature enabled");
|
|
} else {
|
|
console.log("aliased feature disabled");
|
|
}
|
|
`,
|
|
},
|
|
features: ["ALIASED"],
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("true");
|
|
api.expectFile("out.js").not.toInclude("checkFeature");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/TernaryDisabled`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const result = feature("TERNARY_FLAG") ? "ternary_enabled" : "ternary_disabled";
|
|
console.log(result);
|
|
`,
|
|
},
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("ternary_disabled");
|
|
api.expectFile("out.js").not.toInclude("ternary_enabled");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/TernaryEnabled`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const result = feature("TERNARY_FLAG") ? "ternary_enabled" : "ternary_disabled";
|
|
console.log(result);
|
|
`,
|
|
},
|
|
features: ["TERNARY_FLAG"],
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("ternary_enabled");
|
|
api.expectFile("out.js").not.toInclude("ternary_disabled");
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
// Error cases - only test with CLI since error handling might differ
|
|
itBundled("feature_flag/NonStringArgError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const flag = "DYNAMIC";
|
|
if (feature(flag)) {
|
|
console.log("dynamic");
|
|
}
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ["feature() argument must be a string literal"],
|
|
},
|
|
});
|
|
|
|
itBundled("feature_flag/NoArgsError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature()) {
|
|
console.log("no args");
|
|
}
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ["feature() requires exactly one string argument"],
|
|
},
|
|
});
|
|
|
|
// Error cases for invalid usage of feature() - must be in if/ternary condition
|
|
itBundled("feature_flag/ConstAssignmentError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const x = feature("FLAG");
|
|
console.log(x);
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ['feature() from "bun:bundle" can only be used directly in an if statement or ternary condition'],
|
|
},
|
|
});
|
|
|
|
itBundled("feature_flag/LetAssignmentError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
let x = feature("FLAG");
|
|
console.log(x);
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ['feature() from "bun:bundle" can only be used directly in an if statement or ternary condition'],
|
|
},
|
|
});
|
|
|
|
itBundled("feature_flag/ExportDefaultError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
export default feature("FLAG");
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ['feature() from "bun:bundle" can only be used directly in an if statement or ternary condition'],
|
|
},
|
|
});
|
|
|
|
itBundled("feature_flag/FunctionArgumentError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
console.log(feature("FLAG"));
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ['feature() from "bun:bundle" can only be used directly in an if statement or ternary condition'],
|
|
},
|
|
});
|
|
|
|
itBundled("feature_flag/ReturnStatementError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
function foo() {
|
|
return feature("FLAG");
|
|
}
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ['feature() from "bun:bundle" can only be used directly in an if statement or ternary condition'],
|
|
},
|
|
});
|
|
|
|
itBundled("feature_flag/ArrayLiteralError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const arr = [feature("FLAG")];
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ['feature() from "bun:bundle" can only be used directly in an if statement or ternary condition'],
|
|
},
|
|
});
|
|
|
|
itBundled("feature_flag/ObjectPropertyError", {
|
|
backend: "cli",
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const obj = { flag: feature("FLAG") };
|
|
`,
|
|
},
|
|
bundleErrors: {
|
|
"/a.js": ['feature() from "bun:bundle" can only be used directly in an if statement or ternary condition'],
|
|
},
|
|
});
|
|
|
|
// Valid usage patterns - these should work without errors
|
|
for (const backend of ["cli", "api"] as const) {
|
|
itBundled(`feature_flag/${backend}/ValidIfStatement`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("FLAG")) {
|
|
console.log("enabled");
|
|
}
|
|
`,
|
|
},
|
|
features: ["FLAG"],
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("true");
|
|
api.expectFile("out.js").not.toInclude("feature(");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/ValidTernary`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const x = feature("FLAG") ? "yes" : "no";
|
|
console.log(x);
|
|
`,
|
|
},
|
|
features: ["FLAG"],
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("yes");
|
|
api.expectFile("out.js").not.toInclude("no");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/ValidElseIf`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
if (feature("A")) {
|
|
console.log("A");
|
|
} else if (feature("B")) {
|
|
console.log("B");
|
|
} else {
|
|
console.log("neither");
|
|
}
|
|
`,
|
|
},
|
|
features: ["B"],
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("B");
|
|
api.expectFile("out.js").not.toInclude("neither");
|
|
},
|
|
});
|
|
|
|
itBundled(`feature_flag/${backend}/ValidNestedTernary`, {
|
|
backend,
|
|
files: {
|
|
"/a.js": `
|
|
import { feature } from "bun:bundle";
|
|
const x = feature("A") ? "A" : feature("B") ? "B" : "C";
|
|
console.log(x);
|
|
`,
|
|
},
|
|
features: ["B"],
|
|
minifySyntax: true,
|
|
onAfterBundle(api) {
|
|
api.expectFile("out.js").toInclude("B");
|
|
api.expectFile("out.js").not.toInclude("A");
|
|
},
|
|
});
|
|
}
|
|
|
|
// Runtime tests - these must remain as manual tests since they test bun run and bun test
|
|
test("works correctly at runtime with bun run", async () => {
|
|
using dir = tempDir("bundler-feature-flag", {
|
|
"index.ts": `
|
|
import { feature } from "bun:bundle";
|
|
|
|
if (feature("RUNTIME_FLAG")) {
|
|
console.log("runtime flag enabled");
|
|
} else {
|
|
console.log("runtime flag disabled");
|
|
}
|
|
`,
|
|
});
|
|
|
|
// First, test without the flag
|
|
await using proc1 = Bun.spawn({
|
|
cmd: [bunExe(), "run", "./index.ts"],
|
|
cwd: String(dir),
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout1, , exitCode1] = await Promise.all([
|
|
new Response(proc1.stdout).text(),
|
|
new Response(proc1.stderr).text(),
|
|
proc1.exited,
|
|
]);
|
|
|
|
expect(stdout1.trim()).toBe("runtime flag disabled");
|
|
expect(exitCode1).toBe(0);
|
|
|
|
// Now test with the flag enabled
|
|
await using proc2 = Bun.spawn({
|
|
cmd: [bunExe(), "run", "--feature=RUNTIME_FLAG", "./index.ts"],
|
|
cwd: String(dir),
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout2, , exitCode2] = await Promise.all([
|
|
new Response(proc2.stdout).text(),
|
|
new Response(proc2.stderr).text(),
|
|
proc2.exited,
|
|
]);
|
|
|
|
expect(stdout2.trim()).toBe("runtime flag enabled");
|
|
expect(exitCode2).toBe(0);
|
|
});
|
|
|
|
test("works correctly in bun test", async () => {
|
|
using dir = tempDir("bundler-feature-flag", {
|
|
"test.test.ts": `
|
|
import { test, expect } from "bun:test";
|
|
import { feature } from "bun:bundle";
|
|
|
|
test("feature flag in test", () => {
|
|
if (feature("TEST_FLAG")) {
|
|
console.log("TEST_FLAG_ENABLED");
|
|
} else {
|
|
console.log("TEST_FLAG_DISABLED");
|
|
}
|
|
expect(true).toBe(true);
|
|
});
|
|
`,
|
|
});
|
|
|
|
// First, test without the flag
|
|
await using proc1 = Bun.spawn({
|
|
cmd: [bunExe(), "test", "./test.test.ts"],
|
|
cwd: String(dir),
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout1, , exitCode1] = await Promise.all([
|
|
new Response(proc1.stdout).text(),
|
|
new Response(proc1.stderr).text(),
|
|
proc1.exited,
|
|
]);
|
|
|
|
expect(stdout1).toContain("TEST_FLAG_DISABLED");
|
|
expect(stdout1).not.toContain("TEST_FLAG_ENABLED");
|
|
expect(exitCode1).toBe(0);
|
|
|
|
// Now test with the flag enabled
|
|
await using proc2 = Bun.spawn({
|
|
cmd: [bunExe(), "test", "--feature=TEST_FLAG", "./test.test.ts"],
|
|
cwd: String(dir),
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout2, , exitCode2] = await Promise.all([
|
|
new Response(proc2.stdout).text(),
|
|
new Response(proc2.stderr).text(),
|
|
proc2.exited,
|
|
]);
|
|
|
|
expect(stdout2).toContain("TEST_FLAG_ENABLED");
|
|
expect(stdout2).not.toContain("TEST_FLAG_DISABLED");
|
|
expect(exitCode2).toBe(0);
|
|
});
|
|
});
|