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