mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
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:
@@ -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` | |
|
||||
|
||||
@@ -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:
|
||||
|
||||
18
packages/bun-types/bun.d.ts
vendored
18
packages/bun-types/bun.d.ts
vendored
@@ -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
74
packages/bun-types/bundle.d.ts
vendored
Normal 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;
|
||||
}
|
||||
1
packages/bun-types/index.d.ts
vendored
1
packages/bun-types/index.d.ts
vendored
@@ -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" />
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
535
test/bundler/bundler_feature_flag.test.ts
Normal file
535
test/bundler/bundler_feature_flag.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
46
test/integration/bun-types/fixture/bundle.ts
Normal file
46
test/integration/bun-types/fixture/bundle.ts
Normal 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"]);
|
||||
Reference in New Issue
Block a user