diff --git a/docs/bundler/esbuild.mdx b/docs/bundler/esbuild.mdx
index 10d6ae7591..a1724d5f3b 100644
--- a/docs/bundler/esbuild.mdx
+++ b/docs/bundler/esbuild.mdx
@@ -65,6 +65,7 @@ In Bun's CLI, simple boolean flags like `--minify` do not accept an argument. Ot
| `--chunk-names` | `--chunk-naming` | Renamed for consistency with naming in JS API |
| `--color` | n/a | Always enabled |
| `--drop` | `--drop` | |
+| n/a | `--feature` | Bun-specific. Enable feature flags for compile-time dead-code elimination via `import { feature } from "bun:bundle"` |
| `--entry-names` | `--entry-naming` | Renamed for consistency with naming in JS API |
| `--global-name` | n/a | Not applicable, Bun does not support `iife` output at this time |
| `--ignore-annotations` | `--ignore-dce-annotations` | |
diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx
index 5a6743318c..e6ce3b5452 100644
--- a/docs/bundler/index.mdx
+++ b/docs/bundler/index.mdx
@@ -1141,6 +1141,84 @@ Remove function calls from a bundle. For example, `--drop=console` will remove a
+### features
+
+Enable compile-time feature flags for dead-code elimination. This provides a way to conditionally include or exclude code paths at bundle time using `import { feature } from "bun:bundle"`.
+
+```ts title="app.ts" icon="/icons/typescript.svg"
+import { feature } from "bun:bundle";
+
+if (feature("PREMIUM")) {
+ // Only included when PREMIUM flag is enabled
+ initPremiumFeatures();
+}
+
+if (feature("DEBUG")) {
+ // Only included when DEBUG flag is enabled
+ console.log("Debug mode");
+}
+```
+
+
+
+ ```ts title="build.ts" icon="/icons/typescript.svg"
+ await Bun.build({
+ entrypoints: ['./app.ts'],
+ outdir: './out',
+ features: ["PREMIUM"], // PREMIUM=true, DEBUG=false
+ })
+ ```
+
+
+ ```bash terminal icon="terminal"
+ bun build ./app.ts --outdir ./out --feature PREMIUM
+ ```
+
+
+
+The `feature()` function is replaced with `true` or `false` at bundle time. Combined with minification, unreachable code is eliminated:
+
+```ts title="Input" icon="/icons/typescript.svg"
+import { feature } from "bun:bundle";
+const mode = feature("PREMIUM") ? "premium" : "free";
+```
+
+```js title="Output (with --feature PREMIUM --minify)" icon="/icons/javascript.svg"
+var mode = "premium";
+```
+
+```js title="Output (without --feature PREMIUM, with --minify)" icon="/icons/javascript.svg"
+var mode = "free";
+```
+
+**Key behaviors:**
+
+- `feature()` requires a string literal argument — dynamic values are not supported
+- The `bun:bundle` import is completely removed from the output
+- Works with `bun build`, `bun run`, and `bun test`
+- Multiple flags can be enabled: `--feature FLAG_A --feature FLAG_B`
+- For type safety, augment the `Registry` interface to restrict `feature()` to known flags (see below)
+
+**Use cases:**
+
+- Platform-specific code (`feature("SERVER")` vs `feature("CLIENT")`)
+- Environment-based features (`feature("DEVELOPMENT")`)
+- Gradual feature rollouts
+- A/B testing variants
+- Paid tier features
+
+**Type safety:** By default, `feature()` accepts any string. To get autocomplete and catch typos at compile time, create an `env.d.ts` file (or add to an existing `.d.ts`) and augment the `Registry` interface:
+
+```ts title="env.d.ts" icon="/icons/typescript.svg"
+declare module "bun:bundle" {
+ interface Registry {
+ features: "DEBUG" | "PREMIUM" | "BETA_FEATURES";
+ }
+}
+```
+
+Ensure the file is included in your `tsconfig.json` (e.g., `"include": ["src", "env.d.ts"]`). Now `feature()` only accepts those flags, and invalid strings like `feature("TYPO")` become type errors.
+
## Outputs
The `Bun.build` function returns a `Promise`, defined as:
diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts
index 18d75fceaa..d137c3a887 100644
--- a/packages/bun-types/bun.d.ts
+++ b/packages/bun-types/bun.d.ts
@@ -1891,6 +1891,24 @@ declare module "bun" {
*/
drop?: string[];
+ /**
+ * Enable feature flags for dead-code elimination via `import { feature } from "bun:bundle"`.
+ *
+ * When `feature("FLAG_NAME")` is called, it returns `true` if FLAG_NAME is in this array,
+ * or `false` otherwise. This enables static dead-code elimination at bundle time.
+ *
+ * Equivalent to the CLI `--feature` flag.
+ *
+ * @example
+ * ```ts
+ * await Bun.build({
+ * entrypoints: ['./src/index.ts'],
+ * features: ['FEATURE_A', 'FEATURE_B'],
+ * });
+ * ```
+ */
+ features?: string[];
+
/**
* - When set to `true`, the returned promise rejects with an AggregateError when a build failure happens.
* - When set to `false`, returns a {@link BuildOutput} with `{success: false}`
diff --git a/packages/bun-types/bundle.d.ts b/packages/bun-types/bundle.d.ts
new file mode 100644
index 0000000000..0a5ea91957
--- /dev/null
+++ b/packages/bun-types/bundle.d.ts
@@ -0,0 +1,74 @@
+/**
+ * The `bun:bundle` module provides compile-time utilities for dead-code elimination.
+ *
+ * @example
+ * ```ts
+ * import { feature } from "bun:bundle";
+ *
+ * if (feature("SUPER_SECRET")) {
+ * console.log("Secret feature enabled!");
+ * } else {
+ * console.log("Normal mode");
+ * }
+ * ```
+ *
+ * Enable feature flags via CLI:
+ * ```bash
+ * # During build
+ * bun build --feature=SUPER_SECRET index.ts
+ *
+ * # At runtime
+ * bun run --feature=SUPER_SECRET index.ts
+ *
+ * # In tests
+ * bun test --feature=SUPER_SECRET
+ * ```
+ *
+ * @module bun:bundle
+ */
+declare module "bun:bundle" {
+ /**
+ * Registry for type-safe feature flags.
+ *
+ * Augment this interface to get autocomplete and type checking for your feature flags:
+ *
+ * @example
+ * ```ts
+ * // env.d.ts
+ * declare module "bun:bundle" {
+ * interface Registry {
+ * features: "DEBUG" | "PREMIUM" | "BETA";
+ * }
+ * }
+ * ```
+ *
+ * Now `feature()` only accepts `"DEBUG"`, `"PREMIUM"`, or `"BETA"`:
+ * ```ts
+ * feature("DEBUG"); // OK
+ * feature("TYPO"); // Type error
+ * ```
+ */
+ interface Registry {}
+
+ /**
+ * Check if a feature flag is enabled at compile time.
+ *
+ * This function is replaced with a boolean literal (`true` or `false`) at bundle time,
+ * enabling dead-code elimination. The bundler will remove unreachable branches.
+ *
+ * @param flag - The name of the feature flag to check
+ * @returns `true` if the flag was passed via `--feature=FLAG`, `false` otherwise
+ *
+ * @example
+ * ```ts
+ * import { feature } from "bun:bundle";
+ *
+ * // With --feature=DEBUG, this becomes: if (true) { ... }
+ * // Without --feature=DEBUG, this becomes: if (false) { ... }
+ * if (feature("DEBUG")) {
+ * console.log("Debug mode enabled");
+ * }
+ * ```
+ */
+ function feature(flag: Registry extends { features: infer Features extends string } ? Features : string): boolean;
+}
diff --git a/packages/bun-types/index.d.ts b/packages/bun-types/index.d.ts
index aaa3867c0d..0195941eee 100644
--- a/packages/bun-types/index.d.ts
+++ b/packages/bun-types/index.d.ts
@@ -23,6 +23,7 @@
///
///
///
+///
///
diff --git a/src/api/schema.zig b/src/api/schema.zig
index 80cfe546de..46403ac901 100644
--- a/src/api/schema.zig
+++ b/src/api/schema.zig
@@ -1655,6 +1655,9 @@ pub const api = struct {
drop: []const []const u8 = &.{},
+ /// feature_flags for dead-code elimination via `import { feature } from "bun:bundle"`
+ feature_flags: []const []const u8 = &.{},
+
/// preserve_symlinks
preserve_symlinks: ?bool = null,
diff --git a/src/ast/Expr.zig b/src/ast/Expr.zig
index 5e3bbbebdd..8b3052a0d9 100644
--- a/src/ast/Expr.zig
+++ b/src/ast/Expr.zig
@@ -312,8 +312,9 @@ pub fn getObject(expr: *const Expr, name: string) ?Expr {
pub fn getBoolean(expr: *const Expr, name: string) ?bool {
if (expr.asProperty(name)) |query| {
- if (query.expr.data == .e_boolean) {
- return query.expr.data.e_boolean.value;
+ switch (query.expr.data) {
+ .e_boolean, .e_branch_boolean => |b| return b.value,
+ else => {},
}
}
return null;
@@ -510,9 +511,10 @@ pub inline fn asStringZ(expr: *const Expr, allocator: std.mem.Allocator) OOM!?st
pub fn asBool(
expr: *const Expr,
) ?bool {
- if (expr.data != .e_boolean) return null;
-
- return expr.data.e_boolean.value;
+ return switch (expr.data) {
+ .e_boolean, .e_branch_boolean => |b| b.value,
+ else => null,
+ };
}
pub fn asNumber(expr: *const Expr) ?f64 {
@@ -1490,6 +1492,11 @@ pub const Tag = enum {
e_private_identifier,
e_commonjs_export_identifier,
e_boolean,
+ /// Like e_boolean, but produced by `feature()` from `bun:bundle`.
+ /// This tag ensures feature() can only be used directly in conditional
+ /// contexts (if statements, ternaries). Invalid usage is caught during
+ /// the visit phase when this expression appears outside a branch condition.
+ e_branch_boolean,
e_number,
e_big_int,
e_string,
@@ -1513,7 +1520,7 @@ pub const Tag = enum {
// object, regex and array may have had side effects
pub fn isPrimitiveLiteral(tag: Tag) bool {
return switch (tag) {
- .e_null, .e_undefined, .e_string, .e_boolean, .e_number, .e_big_int => true,
+ .e_null, .e_undefined, .e_string, .e_boolean, .e_branch_boolean, .e_number, .e_big_int => true,
else => false,
};
}
@@ -1522,7 +1529,7 @@ pub const Tag = enum {
return switch (tag) {
.e_array, .e_object, .e_null, .e_reg_exp => "object",
.e_undefined => "undefined",
- .e_boolean => "boolean",
+ .e_boolean, .e_branch_boolean => "boolean",
.e_number => "number",
.e_big_int => "bigint",
.e_string => "string",
@@ -1537,7 +1544,7 @@ pub const Tag = enum {
.e_array => writer.writeAll("array"),
.e_unary => writer.writeAll("unary"),
.e_binary => writer.writeAll("binary"),
- .e_boolean => writer.writeAll("boolean"),
+ .e_boolean, .e_branch_boolean => writer.writeAll("boolean"),
.e_super => writer.writeAll("super"),
.e_null => writer.writeAll("null"),
.e_undefined => writer.writeAll("undefined"),
@@ -1627,14 +1634,7 @@ pub const Tag = enum {
}
}
pub fn isBoolean(self: Tag) bool {
- switch (self) {
- .e_boolean => {
- return true;
- },
- else => {
- return false;
- },
- }
+ return self == .e_boolean or self == .e_branch_boolean;
}
pub fn isSuper(self: Tag) bool {
switch (self) {
@@ -1921,7 +1921,7 @@ pub const Tag = enum {
pub fn isBoolean(a: *const Expr) bool {
return switch (a.data) {
- .e_boolean => true,
+ .e_boolean, .e_branch_boolean => true,
.e_if => |ex| ex.yes.isBoolean() and ex.no.isBoolean(),
.e_unary => |ex| ex.op == .un_not or ex.op == .un_delete,
.e_binary => |ex| switch (ex.op) {
@@ -1978,8 +1978,8 @@ pub fn maybeSimplifyNot(expr: *const Expr, allocator: std.mem.Allocator) ?Expr {
.e_null, .e_undefined => {
return expr.at(E.Boolean, E.Boolean{ .value = true }, allocator);
},
- .e_boolean => |b| {
- return expr.at(E.Boolean, E.Boolean{ .value = b.value }, allocator);
+ .e_boolean, .e_branch_boolean => |b| {
+ return expr.at(E.Boolean, E.Boolean{ .value = !b.value }, allocator);
},
.e_number => |n| {
return expr.at(E.Boolean, E.Boolean{ .value = (n.value == 0 or std.math.isNan(n.value)) }, allocator);
@@ -2049,7 +2049,7 @@ pub fn toStringExprWithoutSideEffects(expr: *const Expr, allocator: std.mem.Allo
.e_null => "null",
.e_string => return expr.*,
.e_undefined => "undefined",
- .e_boolean => |data| if (data.value) "true" else "false",
+ .e_boolean, .e_branch_boolean => |data| if (data.value) "true" else "false",
.e_big_int => |bigint| bigint.value,
.e_number => |num| if (num.toString(allocator)) |str|
str
@@ -2151,6 +2151,7 @@ pub const Data = union(Tag) {
e_commonjs_export_identifier: E.CommonJSExportIdentifier,
e_boolean: E.Boolean,
+ e_branch_boolean: E.Boolean,
e_number: E.Number,
e_big_int: *E.BigInt,
e_string: *E.String,
@@ -2589,7 +2590,7 @@ pub const Data = union(Tag) {
const symbol = e.ref.getSymbol(symbol_table);
hasher.update(symbol.original_name);
},
- inline .e_boolean, .e_number => |e| {
+ inline .e_boolean, .e_branch_boolean, .e_number => |e| {
writeAnyToHasher(hasher, e.value);
},
inline .e_big_int, .e_reg_exp => |e| {
@@ -2643,6 +2644,7 @@ pub const Data = union(Tag) {
return switch (this) {
.e_number,
.e_boolean,
+ .e_branch_boolean,
.e_null,
.e_undefined,
.e_inlined_enum,
@@ -2671,6 +2673,7 @@ pub const Data = union(Tag) {
.e_number,
.e_boolean,
+ .e_branch_boolean,
.e_null,
.e_undefined,
// .e_reg_exp,
@@ -2696,7 +2699,7 @@ pub const Data = union(Tag) {
// rope strings can throw when toString is called.
.e_string => |str| str.next == null,
- .e_number, .e_boolean, .e_undefined, .e_null => true,
+ .e_number, .e_boolean, .e_branch_boolean, .e_undefined, .e_null => true,
// BigInt is deliberately excluded as a large enough BigInt could throw an out of memory error.
//
@@ -2707,7 +2710,7 @@ pub const Data = union(Tag) {
pub fn knownPrimitive(data: Expr.Data) PrimitiveType {
return switch (data) {
.e_big_int => .bigint,
- .e_boolean => .boolean,
+ .e_boolean, .e_branch_boolean => .boolean,
.e_null => .null,
.e_number => .number,
.e_string => .string,
@@ -2843,7 +2846,7 @@ pub const Data = union(Tag) {
// +'1' => 1
return stringToEquivalentNumberValue(str.slice8());
},
- .e_boolean => @as(f64, if (data.e_boolean.value) 1.0 else 0.0),
+ .e_boolean, .e_branch_boolean => |b| @as(f64, if (b.value) 1.0 else 0.0),
.e_number => data.e_number.value,
.e_inlined_enum => |inlined| switch (inlined.value.data) {
.e_number => |num| num.value,
@@ -2862,7 +2865,7 @@ pub const Data = union(Tag) {
pub fn toFiniteNumber(data: Expr.Data) ?f64 {
return switch (data) {
- .e_boolean => @as(f64, if (data.e_boolean.value) 1.0 else 0.0),
+ .e_boolean, .e_branch_boolean => |b| @as(f64, if (b.value) 1.0 else 0.0),
.e_number => if (std.math.isFinite(data.e_number.value))
data.e_number.value
else
@@ -2953,12 +2956,12 @@ pub const Data = union(Tag) {
.ok = ok,
};
},
- .e_boolean => |l| {
+ .e_boolean, .e_branch_boolean => |l| {
switch (right) {
- .e_boolean => {
+ .e_boolean, .e_branch_boolean => |r| {
return .{
.ok = true,
- .equal = l.value == right.e_boolean.value,
+ .equal = l.value == r.value,
};
},
.e_number => |num| {
@@ -2996,7 +2999,7 @@ pub const Data = union(Tag) {
.equal = l.value == r.value.data.e_number.value,
};
},
- .e_boolean => |r| {
+ .e_boolean, .e_branch_boolean => |r| {
if (comptime kind == .loose) {
return .{
.ok = true,
@@ -3111,7 +3114,7 @@ pub const Data = union(Tag) {
.e_string => |e| e.toJS(allocator, globalObject),
.e_null => jsc.JSValue.null,
.e_undefined => .js_undefined,
- .e_boolean => |boolean| if (boolean.value)
+ .e_boolean, .e_branch_boolean => |boolean| if (boolean.value)
.true
else
.false,
diff --git a/src/ast/P.zig b/src/ast/P.zig
index 7603ab7bec..a949b03b7d 100644
--- a/src/ast/P.zig
+++ b/src/ast/P.zig
@@ -185,6 +185,13 @@ pub fn NewParser_(
/// it to the symbol so the code generated `e_import_identifier`'s
bun_app_namespace_ref: Ref = Ref.None,
+ /// Used to track the `feature` function from `import { feature } from "bun:bundle"`.
+ /// When visiting e_call, if the target ref matches this, we replace the call with
+ /// a boolean based on whether the feature flag is enabled.
+ bundler_feature_flag_ref: Ref = Ref.None,
+ /// Set to true when visiting an if/ternary condition. feature() calls are only valid in this context.
+ in_branch_condition: bool = false,
+
scopes_in_order_visitor_index: usize = 0,
has_classic_runtime_warned: bool = false,
macro_call_count: MacroCallCountType = 0,
@@ -2653,6 +2660,34 @@ pub fn NewParser_(
return p.s(S.Empty{}, loc);
}
+ // Handle `import { feature } from "bun:bundle"` - this is a special import
+ // that provides static feature flag checking at bundle time.
+ // We handle it here at parse time (similar to macros) rather than at visit time.
+ if (strings.eqlComptime(path.text, "bun:bundle")) {
+ // Look for the "feature" import and validate specifiers
+ for (stmt.items) |*item| {
+ // In ClauseItem from parseImportClause:
+ // - alias is the name from the source module ("feature")
+ // - original_name is the local binding name
+ // - name.ref is the ref for the local binding
+ if (strings.eqlComptime(item.alias, "feature")) {
+ // Check for duplicate imports of feature
+ if (p.bundler_feature_flag_ref.isValid()) {
+ try p.log.addError(p.source, item.alias_loc, "`feature` from \"bun:bundle\" may only be imported once");
+ continue;
+ }
+ // Declare the symbol and store the ref
+ const name = p.loadNameFromRef(item.name.ref.?);
+ const ref = try p.declareSymbol(.other, item.name.loc, name);
+ p.bundler_feature_flag_ref = ref;
+ } else {
+ try p.log.addErrorFmt(p.source, item.alias_loc, p.allocator, "\"bun:bundle\" has no export named \"{s}\"", .{item.alias});
+ }
+ }
+ // Return empty statement - the import is completely removed
+ return p.s(S.Empty{}, loc);
+ }
+
const macro_remap = if (comptime allow_macros)
p.options.macro_context.getRemap(path.text)
else
@@ -3932,6 +3967,7 @@ pub fn NewParser_(
.e_undefined,
.e_missing,
.e_boolean,
+ .e_branch_boolean,
.e_number,
.e_big_int,
.e_string,
diff --git a/src/ast/SideEffects.zig b/src/ast/SideEffects.zig
index 72d208e79f..37d64a0294 100644
--- a/src/ast/SideEffects.zig
+++ b/src/ast/SideEffects.zig
@@ -72,6 +72,7 @@ pub const SideEffects = enum(u1) {
.e_undefined,
.e_string,
.e_boolean,
+ .e_branch_boolean,
.e_number,
.e_big_int,
.e_inlined_enum,
@@ -88,6 +89,7 @@ pub const SideEffects = enum(u1) {
.e_undefined,
.e_missing,
.e_boolean,
+ .e_branch_boolean,
.e_number,
.e_big_int,
.e_string,
@@ -545,6 +547,7 @@ pub const SideEffects = enum(u1) {
.e_null,
.e_undefined,
.e_boolean,
+ .e_branch_boolean,
.e_number,
.e_big_int,
.e_string,
@@ -651,7 +654,7 @@ pub const SideEffects = enum(u1) {
}
switch (exp) {
// Never null or undefined
- .e_boolean, .e_number, .e_string, .e_reg_exp, .e_function, .e_arrow, .e_big_int => {
+ .e_boolean, .e_branch_boolean, .e_number, .e_string, .e_reg_exp, .e_function, .e_arrow, .e_big_int => {
return Result{ .value = false, .side_effects = .no_side_effects, .ok = true };
},
@@ -770,7 +773,7 @@ pub const SideEffects = enum(u1) {
.e_null, .e_undefined => {
return Result{ .ok = true, .value = false, .side_effects = .no_side_effects };
},
- .e_boolean => |e| {
+ .e_boolean, .e_branch_boolean => |e| {
return Result{ .ok = true, .value = e.value, .side_effects = .no_side_effects };
},
.e_number => |e| {
diff --git a/src/ast/visitExpr.zig b/src/ast/visitExpr.zig
index 2b7a941900..cc8bcb1b49 100644
--- a/src/ast/visitExpr.zig
+++ b/src/ast/visitExpr.zig
@@ -923,7 +923,10 @@ pub fn VisitExpr(
const e_ = expr.data.e_if;
const is_call_target = @as(Expr.Data, p.call_target) == .e_if and expr.data.e_if == p.call_target.e_if;
+ const prev_in_branch = p.in_branch_condition;
+ p.in_branch_condition = true;
e_.test_ = p.visitExpr(e_.test_);
+ p.in_branch_condition = prev_in_branch;
e_.test_ = SideEffects.simplifyBoolean(p, e_.test_);
@@ -1277,6 +1280,15 @@ pub fn VisitExpr(
}
}
+ // Handle `feature("FLAG_NAME")` calls from `import { feature } from "bun:bundle"`
+ // Check if the bundler_feature_flag_ref is set before calling the function
+ // to avoid stack memory usage from copying values back and forth.
+ if (p.bundler_feature_flag_ref.isValid()) {
+ if (maybeReplaceBundlerFeatureCall(p, e_, expr.loc)) |result| {
+ return result;
+ }
+ }
+
if (e_.target.data == .e_require_call_target) {
e_.can_be_unwrapped_if_unused = .never;
@@ -1631,6 +1643,66 @@ pub fn VisitExpr(
return expr;
}
+
+ /// Handles `feature("FLAG_NAME")` calls from `import { feature } from "bun:bundle"`.
+ /// This enables statically analyzable dead-code elimination through feature gating.
+ ///
+ /// When a feature flag is enabled via `--feature=FLAG_NAME`, `feature("FLAG_NAME")`
+ /// is replaced with `true`, otherwise it's replaced with `false`. This allows
+ /// bundlers to eliminate dead code branches at build time.
+ ///
+ /// Returns the replacement expression if this is a feature() call, or null otherwise.
+ /// Note: Caller must check `p.bundler_feature_flag_ref.isValid()` before calling.
+ fn maybeReplaceBundlerFeatureCall(p: *P, e_: *E.Call, loc: logger.Loc) ?Expr {
+ // Check if the target is the `feature` function from "bun:bundle"
+ // It could be e_identifier (for unbound) or e_import_identifier (for imports)
+ const target_ref: ?Ref = switch (e_.target.data) {
+ .e_identifier => |ident| ident.ref,
+ .e_import_identifier => |ident| ident.ref,
+ else => null,
+ };
+
+ if (target_ref == null or !target_ref.?.eql(p.bundler_feature_flag_ref)) {
+ return null;
+ }
+
+ // If control flow is dead, just return false without validation errors
+ if (p.is_control_flow_dead) {
+ return p.newExpr(E.Boolean{ .value = false }, loc);
+ }
+
+ // Validate: exactly one argument required
+ if (e_.args.len != 1) {
+ p.log.addError(p.source, loc, "feature() requires exactly one string argument") catch unreachable;
+ return p.newExpr(E.Boolean{ .value = false }, loc);
+ }
+
+ const arg = e_.args.slice()[0];
+
+ // Validate: argument must be a string literal
+ if (arg.data != .e_string) {
+ p.log.addError(p.source, arg.loc, "feature() argument must be a string literal") catch unreachable;
+ return p.newExpr(E.Boolean{ .value = false }, loc);
+ }
+
+ // Check if the feature flag is enabled
+ // Use the underlying string data directly without allocation.
+ // Feature flag names should be ASCII identifiers, so UTF-16 is unexpected.
+ const flag_string = arg.data.e_string;
+ if (flag_string.is_utf16) {
+ p.log.addError(p.source, arg.loc, "feature() flag name must be an ASCII string") catch unreachable;
+ return p.newExpr(E.Boolean{ .value = false }, loc);
+ }
+
+ // feature() can only be used directly in an if statement or ternary condition
+ if (!p.in_branch_condition) {
+ p.log.addError(p.source, loc, "feature() from \"bun:bundle\" can only be used directly in an if statement or ternary condition") catch unreachable;
+ return p.newExpr(E.Boolean{ .value = false }, loc);
+ }
+
+ const is_enabled = p.options.features.bundler_feature_flags.map.contains(flag_string.data);
+ return .{ .data = .{ .e_branch_boolean = .{ .value = is_enabled } }, .loc = loc };
+ }
};
};
}
diff --git a/src/ast/visitStmt.zig b/src/ast/visitStmt.zig
index 6280c4d39e..44b3642711 100644
--- a/src/ast/visitStmt.zig
+++ b/src/ast/visitStmt.zig
@@ -1000,7 +1000,10 @@ pub fn VisitStmt(
try stmts.append(stmt.*);
}
pub fn s_if(noalias p: *P, noalias stmts: *ListManaged(Stmt), noalias stmt: *Stmt, noalias data: *S.If) !void {
+ const prev_in_branch = p.in_branch_condition;
+ p.in_branch_condition = true;
data.test_ = p.visitExpr(data.test_);
+ p.in_branch_condition = prev_in_branch;
if (p.options.features.minify_syntax) {
data.test_ = SideEffects.simplifyBoolean(p, data.test_);
diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig
index 83136594fa..6e77655062 100644
--- a/src/bun.js/api/JSBundler.zig
+++ b/src/bun.js/api/JSBundler.zig
@@ -38,6 +38,7 @@ pub const JSBundler = struct {
footer: OwnedString = OwnedString.initEmpty(bun.default_allocator),
css_chunking: bool = false,
drop: bun.StringSet = bun.StringSet.init(bun.default_allocator),
+ features: bun.StringSet = bun.StringSet.init(bun.default_allocator),
has_any_on_before_parse: bool = false,
throw_on_error: bool = true,
env_behavior: api.DotEnvBehavior = .disable,
@@ -571,6 +572,15 @@ pub const JSBundler = struct {
}
}
+ if (try config.getOwnArray(globalThis, "features")) |features| {
+ var iter = try features.arrayIterator(globalThis);
+ while (try iter.next()) |entry| {
+ var slice = try entry.toSliceOrNull(globalThis);
+ defer slice.deinit();
+ try this.features.insert(slice.slice());
+ }
+ }
+
// if (try config.getOptional(globalThis, "dir", ZigString.Slice)) |slice| {
// defer slice.deinit();
// this.appendSliceExact(slice.slice()) catch unreachable;
@@ -814,6 +824,7 @@ pub const JSBundler = struct {
self.public_path.deinit();
self.conditions.deinit();
self.drop.deinit();
+ self.features.deinit();
self.banner.deinit();
if (self.compile) |*compile| {
compile.deinit();
diff --git a/src/bun.zig b/src/bun.zig
index aac41acb5a..ca572ae8af 100644
--- a/src/bun.zig
+++ b/src/bun.zig
@@ -1622,6 +1622,15 @@ pub const StringSet = struct {
};
}
+ /// Initialize an empty StringSet at comptime (for use as a static constant).
+ /// WARNING: The resulting set must not be mutated. Any attempt to call insert(),
+ /// clone(), or other allocating methods will result in undefined behavior.
+ pub fn initComptime() StringSet {
+ return StringSet{
+ .map = Map.initContext(undefined, .{}),
+ };
+ }
+
pub fn isEmpty(self: *const StringSet) bool {
return self.count() == 0;
}
diff --git a/src/bundler/ParseTask.zig b/src/bundler/ParseTask.zig
index 95bbc25e15..e89fef671f 100644
--- a/src/bundler/ParseTask.zig
+++ b/src/bundler/ParseTask.zig
@@ -1176,6 +1176,7 @@ fn runWithSourceCode(
opts.features.minify_whitespace = transpiler.options.minify_whitespace;
opts.features.emit_decorator_metadata = transpiler.options.emit_decorator_metadata;
opts.features.unwrap_commonjs_packages = transpiler.options.unwrap_commonjs_packages;
+ opts.features.bundler_feature_flags = transpiler.options.bundler_feature_flags;
opts.features.hot_module_reloading = output_format == .internal_bake_dev and !source.index.isRuntime();
opts.features.auto_polyfill_require = output_format == .esm and !opts.features.hot_module_reloading;
opts.features.react_fast_refresh = target == .browser and
diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig
index 669e84de69..a6b60e9ebb 100644
--- a/src/bundler/bundle_v2.zig
+++ b/src/bundler/bundle_v2.zig
@@ -1836,6 +1836,8 @@ pub const BundleV2 = struct {
);
transpiler.options.env.behavior = config.env_behavior;
transpiler.options.env.prefix = config.env_prefix.slice();
+ // Use the StringSet directly instead of the slice passed through TransformOptions
+ transpiler.options.bundler_feature_flags = &config.features;
if (config.force_node_env != .unspecified) {
transpiler.options.force_node_env = config.force_node_env;
}
diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig
index 5d9f8751e2..2a11d83320 100644
--- a/src/cli/Arguments.zig
+++ b/src/cli/Arguments.zig
@@ -66,6 +66,7 @@ pub const transpiler_params_ = [_]ParamType{
clap.parseParam("--tsconfig-override Specify custom tsconfig.json. Default $cwd/tsconfig.json") catch unreachable,
clap.parseParam("-d, --define ... Substitute K:V while parsing, e.g. --define process.env.NODE_ENV:\"development\". Values are parsed as JSON.") catch unreachable,
clap.parseParam("--drop ... Remove function calls, e.g. --drop=console removes all console.* calls.") catch unreachable,
+ clap.parseParam("--feature ... Enable a feature flag for dead-code elimination, e.g. --feature=SUPER_SECRET") catch unreachable,
clap.parseParam("-l, --loader ... Parse files with .ext:loader, e.g. --loader .js:jsx. Valid loaders: js, jsx, ts, tsx, json, toml, text, file, wasm, napi") catch unreachable,
clap.parseParam("--no-macros Disable macros from being executed in the bundler, transpiler and runtime") catch unreachable,
clap.parseParam("--jsx-factory Changes the function called when compiling JSX elements using the classic JSX runtime") catch unreachable,
@@ -591,6 +592,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
}
opts.drop = args.options("--drop");
+ opts.feature_flags = args.options("--feature");
// Node added a `--loader` flag (that's kinda like `--register`). It's
// completely different from ours.
diff --git a/src/cli/build_command.zig b/src/cli/build_command.zig
index 04d19e0c6e..4c0253084d 100644
--- a/src/cli/build_command.zig
+++ b/src/cli/build_command.zig
@@ -82,6 +82,7 @@ pub const BuildCommand = struct {
this_transpiler.options.banner = ctx.bundler_options.banner;
this_transpiler.options.footer = ctx.bundler_options.footer;
this_transpiler.options.drop = ctx.args.drop;
+ this_transpiler.options.bundler_feature_flags = Runtime.Features.initBundlerFeatureFlags(allocator, ctx.args.feature_flags);
this_transpiler.options.css_chunking = ctx.bundler_options.css_chunking;
@@ -655,6 +656,7 @@ const resolve_path = @import("../resolver/resolve_path.zig");
const std = @import("std");
const BundleV2 = @import("../bundler/bundle_v2.zig").BundleV2;
const Command = @import("../cli.zig").Command;
+const Runtime = @import("../runtime.zig").Runtime;
const bun = @import("bun");
const Global = bun.Global;
diff --git a/src/js_printer.zig b/src/js_printer.zig
index c1733e4606..65333720cd 100644
--- a/src/js_printer.zig
+++ b/src/js_printer.zig
@@ -735,7 +735,7 @@ fn NewPrinter(
.e_await, .e_undefined, .e_number => {
left_level.* = .call;
},
- .e_boolean => {
+ .e_boolean, .e_branch_boolean => {
// When minifying, booleans are printed as "!0 and "!1"
if (p.options.minify_syntax) {
left_level.* = .call;
@@ -2677,7 +2677,7 @@ fn NewPrinter(
p.print(")");
}
},
- .e_boolean => |e| {
+ .e_boolean, .e_branch_boolean => |e| {
p.addSourceMapping(expr.loc);
if (p.options.minify_syntax) {
if (level.gte(Level.prefix)) {
diff --git a/src/options.zig b/src/options.zig
index 2d212e686c..80fc687768 100644
--- a/src/options.zig
+++ b/src/options.zig
@@ -1717,6 +1717,9 @@ pub const BundleOptions = struct {
banner: string = "",
define: *defines.Define,
drop: []const []const u8 = &.{},
+ /// Set of enabled feature flags for dead-code elimination via `import { feature } from "bun:bundle"`.
+ /// Initialized once from the CLI --feature flags.
+ bundler_feature_flags: *const bun.StringSet = &Runtime.Features.empty_bundler_feature_flags,
loaders: Loader.HashTable,
resolve_dir: string = "/",
jsx: JSX.Pragma = JSX.Pragma{},
@@ -1907,8 +1910,13 @@ pub const BundleOptions = struct {
this.defines_loaded = true;
}
- pub fn deinit(this: *const BundleOptions) void {
+ pub fn deinit(this: *BundleOptions, allocator: std.mem.Allocator) void {
this.define.deinit();
+ // Free bundler_feature_flags if it was allocated (not the static empty set)
+ if (this.bundler_feature_flags != &Runtime.Features.empty_bundler_feature_flags) {
+ @constCast(this.bundler_feature_flags).deinit();
+ allocator.destroy(@constCast(this.bundler_feature_flags));
+ }
}
pub fn loader(this: *const BundleOptions, ext: string) Loader {
@@ -2009,6 +2017,7 @@ pub const BundleOptions = struct {
.transform_options = transform,
.css_chunking = false,
.drop = transform.drop,
+ .bundler_feature_flags = Runtime.Features.initBundlerFeatureFlags(allocator, transform.feature_flags),
};
analytics.Features.define += @as(usize, @intFromBool(transform.define != null));
diff --git a/src/runtime.zig b/src/runtime.zig
index 7da3a78703..ecb33e4f97 100644
--- a/src/runtime.zig
+++ b/src/runtime.zig
@@ -211,6 +211,27 @@ pub const Runtime = struct {
// TODO: make this a bitset of all unsupported features
lower_using: bool = true,
+ /// Feature flags for dead-code elimination via `import { feature } from "bun:bundle"`
+ /// When `feature("FLAG_NAME")` is called, it returns true if FLAG_NAME is in this set.
+ bundler_feature_flags: *const bun.StringSet = &empty_bundler_feature_flags,
+
+ pub const empty_bundler_feature_flags: bun.StringSet = bun.StringSet.initComptime();
+
+ /// Initialize bundler feature flags for dead-code elimination via `import { feature } from "bun:bundle"`.
+ /// Returns a pointer to a StringSet containing the enabled flags, or the empty set if no flags are provided.
+ pub fn initBundlerFeatureFlags(allocator: std.mem.Allocator, feature_flags: []const []const u8) *const bun.StringSet {
+ if (feature_flags.len == 0) {
+ return &empty_bundler_feature_flags;
+ }
+
+ const set = bun.handleOom(allocator.create(bun.StringSet));
+ set.* = bun.StringSet.init(allocator);
+ for (feature_flags) |flag| {
+ bun.handleOom(set.insert(flag));
+ }
+ return set;
+ }
+
const hash_fields_for_runtime_transpiler = .{
.top_level_await,
.auto_import_jsx,
diff --git a/src/transpiler.zig b/src/transpiler.zig
index f3980e3dd9..fae832160f 100644
--- a/src/transpiler.zig
+++ b/src/transpiler.zig
@@ -438,7 +438,7 @@ pub const Transpiler = struct {
}
pub fn deinit(this: *Transpiler) void {
- this.options.deinit();
+ this.options.deinit(this.allocator);
this.log.deinit();
this.resolver.deinit();
this.fs.deinit();
@@ -806,6 +806,7 @@ pub const Transpiler = struct {
.runtime_transpiler_cache = runtime_transpiler_cache,
.print_dce_annotations = transpiler.options.emit_dce_annotations,
.hmr_ref = ast.wrapper_ref,
+ .mangled_props = null,
},
enable_source_map,
),
@@ -1113,6 +1114,7 @@ pub const Transpiler = struct {
opts.features.minify_identifiers = transpiler.options.minify_identifiers;
opts.features.dead_code_elimination = transpiler.options.dead_code_elimination;
opts.features.remove_cjs_module_wrapper = this_parse.remove_cjs_module_wrapper;
+ opts.features.bundler_feature_flags = transpiler.options.bundler_feature_flags;
if (transpiler.macro_context == null) {
transpiler.macro_context = js_ast.Macro.MacroContext.init(transpiler);
diff --git a/test/bundler/bundler_feature_flag.test.ts b/test/bundler/bundler_feature_flag.test.ts
new file mode 100644
index 0000000000..327763902a
--- /dev/null
+++ b/test/bundler/bundler_feature_flag.test.ts
@@ -0,0 +1,535 @@
+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);
+ });
+});
diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts
index 9bf60bacff..768b3ca4ab 100644
--- a/test/bundler/expectBundled.ts
+++ b/test/bundler/expectBundled.ts
@@ -161,6 +161,8 @@ export interface BundlerTestInput {
footer?: string;
define?: Record;
drop?: string[];
+ /** Feature flags for dead-code elimination via `import { feature } from "bun:bundle"` */
+ features?: string[];
/** Use for resolve custom conditions */
conditions?: string[];
@@ -444,6 +446,7 @@ function expectBundled(
external,
packages,
drop = [],
+ features = [],
files,
footer,
format,
@@ -748,6 +751,7 @@ function expectBundled(
minifySyntax && `--minify-syntax`,
minifyWhitespace && `--minify-whitespace`,
drop?.length && drop.map(x => ["--drop=" + x]),
+ features?.length && features.map(x => ["--feature=" + x]),
globalName && `--global-name=${globalName}`,
jsx.runtime && ["--jsx-runtime", jsx.runtime],
jsx.factory && ["--jsx-factory", jsx.factory],
@@ -1116,6 +1120,7 @@ function expectBundled(
emitDCEAnnotations,
ignoreDCEAnnotations,
drop,
+ features,
define: define ?? {},
throw: _throw ?? false,
compile,
diff --git a/test/integration/bun-types/bun-types.test.ts b/test/integration/bun-types/bun-types.test.ts
index 26075b8661..0a0d98e5de 100644
--- a/test/integration/bun-types/bun-types.test.ts
+++ b/test/integration/bun-types/bun-types.test.ts
@@ -360,6 +360,100 @@ describe("@types/bun integration test", () => {
});
});
+ describe("bun:bundle feature() type safety with Registry", () => {
+ test("Registry augmentation restricts feature() to known flags", async () => {
+ const testCode = `
+ // Augment the Registry to define known flags
+ declare module "bun:bundle" {
+ interface Registry {
+ features: "DEBUG" | "PREMIUM" | "BETA";
+ }
+ }
+
+ import { feature } from "bun:bundle";
+
+ // Valid flags work
+ const a: boolean = feature("DEBUG");
+ const b: boolean = feature("PREMIUM");
+ const c: boolean = feature("BETA");
+
+ // Invalid flags are caught at compile time
+ // @ts-expect-error - "INVALID_FLAG" is not assignable to "DEBUG" | "PREMIUM" | "BETA"
+ const invalid: boolean = feature("INVALID_FLAG");
+
+ // @ts-expect-error - typos are caught
+ const typo: boolean = feature("DEUBG");
+ `;
+
+ const { diagnostics, emptyInterfaces } = await diagnose(TEMP_FIXTURE_DIR, {
+ files: {
+ "registry-test.ts": testCode,
+ },
+ });
+
+ expect(emptyInterfaces).toEqual(expectedEmptyInterfacesWhenNoDOM);
+ // Filter to only our test file - no diagnostics because @ts-expect-error suppresses errors
+ const relevantDiagnostics = diagnostics.filter(d => d.line?.startsWith("registry-test.ts"));
+ expect(relevantDiagnostics).toEqual([]);
+ });
+
+ test("Registry augmentation produces type errors for invalid flags", async () => {
+ // Verify that without @ts-expect-error, invalid flags actually produce errors
+ const invalidTestCode = `
+ declare module "bun:bundle" {
+ interface Registry {
+ features: "ALLOWED_FLAG";
+ }
+ }
+
+ import { feature } from "bun:bundle";
+
+ // This should cause a type error - INVALID_FLAG is not in Registry.features
+ const invalid: boolean = feature("INVALID_FLAG");
+ `;
+
+ const { diagnostics, emptyInterfaces } = await diagnose(TEMP_FIXTURE_DIR, {
+ files: {
+ "registry-invalid-test.ts": invalidTestCode,
+ },
+ });
+
+ expect(emptyInterfaces).toEqual(expectedEmptyInterfacesWhenNoDOM);
+ const relevantDiagnostics = diagnostics.filter(d => d.line?.startsWith("registry-invalid-test.ts"));
+ expect(relevantDiagnostics).toMatchInlineSnapshot(`
+ [
+ {
+ "code": 2345,
+ "line": "registry-invalid-test.ts:11:42",
+ "message": "Argument of type '\"INVALID_FLAG\"' is not assignable to parameter of type '\"ALLOWED_FLAG\"'.",
+ },
+ ]
+ `);
+ });
+
+ test("without Registry augmentation, feature() accepts any string", async () => {
+ // When Registry is not augmented, feature() falls back to accepting any string
+ const testCode = `
+ import { feature } from "bun:bundle";
+
+ // Any string works when Registry.features is not defined
+ const a: boolean = feature("ANY_FLAG");
+ const b: boolean = feature("ANOTHER_FLAG");
+ const c: boolean = feature("whatever");
+ `;
+
+ const { diagnostics, emptyInterfaces } = await diagnose(TEMP_FIXTURE_DIR, {
+ files: {
+ "no-registry-test.ts": testCode,
+ },
+ });
+
+ expect(emptyInterfaces).toEqual(expectedEmptyInterfacesWhenNoDOM);
+ const relevantDiagnostics = diagnostics.filter(d => d.line?.startsWith("no-registry-test.ts"));
+ expect(relevantDiagnostics).toEqual([]);
+ });
+ });
+
test("checks with no lib at all", async () => {
const { diagnostics, emptyInterfaces } = await diagnose(TEMP_FIXTURE_DIR, {
options: {
diff --git a/test/integration/bun-types/fixture/bundle.ts b/test/integration/bun-types/fixture/bundle.ts
new file mode 100644
index 0000000000..632c00fec6
--- /dev/null
+++ b/test/integration/bun-types/fixture/bundle.ts
@@ -0,0 +1,46 @@
+/**
+ * Type tests for the "bun:bundle" module.
+ */
+
+import { feature } from "bun:bundle";
+import { expectType } from "./utilities";
+
+// feature() returns boolean
+expectType(feature("DEBUG")).is();
+
+// Import alias works
+import { feature as checkFeature } from "bun:bundle";
+expectType(checkFeature("FLAG")).is();
+
+// Bun.build features option accepts string array
+Bun.build({
+ entrypoints: ["./index.ts"],
+ outdir: "./dist",
+ features: ["FEATURE_A", "FEATURE_B"],
+});
+
+// Error cases:
+
+// @ts-expect-error - feature() requires exactly one argument
+feature();
+
+// @ts-expect-error - feature() requires a string argument
+feature(123);
+
+// @ts-expect-error - feature() requires a string argument
+feature(true);
+
+// @ts-expect-error - feature() requires a string argument
+feature(null);
+
+// @ts-expect-error - feature() requires a string argument
+feature(undefined);
+
+// @ts-expect-error - feature() doesn't accept multiple arguments
+feature("A", "B");
+
+// @ts-expect-error - feature() doesn't accept objects
+feature({ flag: "DEBUG" });
+
+// @ts-expect-error - feature() doesn't accept arrays
+feature(["DEBUG"]);