feat(bundler): add statically-analyzable dead-code elimination via feature flags (#25462)

## 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>
This commit is contained in:
robobun
2025-12-11 17:44:14 -08:00
committed by GitHub
parent 1d50af7fe8
commit c59a6997cd
25 changed files with 1067 additions and 36 deletions

View File

@@ -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` | |

View File

@@ -1141,6 +1141,84 @@ Remove function calls from a bundle. For example, `--drop=console` will remove a
</Tab>
</Tabs>
### 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");
}
```
<Tabs>
<Tab title="JavaScript">
```ts title="build.ts" icon="/icons/typescript.svg"
await Bun.build({
entrypoints: ['./app.ts'],
outdir: './out',
features: ["PREMIUM"], // PREMIUM=true, DEBUG=false
})
```
</Tab>
<Tab title="CLI">
```bash terminal icon="terminal"
bun build ./app.ts --outdir ./out --feature PREMIUM
```
</Tab>
</Tabs>
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<BuildOutput>`, defined as:

View File

@@ -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}`

74
packages/bun-types/bundle.d.ts vendored Normal file
View File

@@ -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;
}

View File

@@ -23,6 +23,7 @@
/// <reference path="./serve.d.ts" />
/// <reference path="./sql.d.ts" />
/// <reference path="./security.d.ts" />
/// <reference path="./bundle.d.ts" />
/// <reference path="./bun.ns.d.ts" />

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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| {

View File

@@ -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 };
}
};
};
}

View File

@@ -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_);

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -66,6 +66,7 @@ pub const transpiler_params_ = [_]ParamType{
clap.parseParam("--tsconfig-override <STR> Specify custom tsconfig.json. Default <d>$cwd<r>/tsconfig.json") catch unreachable,
clap.parseParam("-d, --define <STR>... Substitute K:V while parsing, e.g. --define process.env.NODE_ENV:\"development\". Values are parsed as JSON.") catch unreachable,
clap.parseParam("--drop <STR>... Remove function calls, e.g. --drop=console removes all console.* calls.") catch unreachable,
clap.parseParam("--feature <STR>... Enable a feature flag for dead-code elimination, e.g. --feature=SUPER_SECRET") catch unreachable,
clap.parseParam("-l, --loader <STR>... 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 <STR> 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.

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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));

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -161,6 +161,8 @@ export interface BundlerTestInput {
footer?: string;
define?: Record<string, string | number>;
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,

View File

@@ -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: {

View File

@@ -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<boolean>();
// Import alias works
import { feature as checkFeature } from "bun:bundle";
expectType(checkFeature("FLAG")).is<boolean>();
// 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"]);