Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
3b345c831a feat(bundler): support optional chaining and import.meta.env in env:inline
- Allow `process?.env?.VAR` and `process?.env?.["VAR"]` to be inlined
  when using `env:inline` in bun.build. This matches esbuild's behavior
  for define matching with optional chain expressions.

- Add `import.meta.env.*` inlining for Vite compatibility. Environment
  variables are now exposed via both `process.env.*` and `import.meta.env.*`
  when using `env:inline` or prefix patterns like `VITE_*`.

- Add define checking for e_index (bracket notation) with optional chains
  to ensure `process?.env?.["VAR"]` patterns work correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 21:01:15 +00:00
4 changed files with 198 additions and 56 deletions

View File

@@ -5398,18 +5398,21 @@ pub fn NewParser_(
};
}
pub fn isDotDefineMatch(noalias p: *P, expr: Expr, parts: []const string) bool {
/// Check if an expression matches a dot define pattern like "process.env.NODE_ENV".
/// When `allow_optional_chain` is true, expressions like `process?.env?.NODE_ENV` will also match.
/// This should only be true when we're substituting a value (the optional chain becomes irrelevant).
/// When just setting flags like `can_be_removed_if_unused`, optional chains should NOT match
/// because the chain itself has observable behavior (checking if the object exists).
pub fn isDotDefineMatch(noalias p: *P, expr: Expr, parts: []const string, allow_optional_chain: bool) bool {
switch (expr.data) {
.e_dot => |ex| {
if (parts.len > 1) {
if (ex.optional_chain != null) {
if (!allow_optional_chain and ex.optional_chain != null) {
return false;
}
// Intermediates must be dot expressions
const last = parts.len - 1;
const is_tail_match = strings.eql(parts[last], ex.name);
return is_tail_match and p.isDotDefineMatch(ex.target, parts[0..last]);
return is_tail_match and p.isDotDefineMatch(ex.target, parts[0..last], allow_optional_chain);
}
},
.e_import_meta => {
@@ -5421,13 +5424,12 @@ pub fn NewParser_(
// the intent is to handle people using this form instead of E.Dot. So we really only want to do this if the accessor can also be an identifier
.e_index => |index| {
if (parts.len > 1 and index.index.data == .e_string and index.index.data.e_string.isUTF8()) {
if (index.optional_chain != null) {
if (!allow_optional_chain and index.optional_chain != null) {
return false;
}
const last = parts.len - 1;
const is_tail_match = strings.eql(parts[last], index.index.data.e_string.slice(p.allocator));
return is_tail_match and p.isDotDefineMatch(index.target, parts[0..last]);
return is_tail_match and p.isDotDefineMatch(index.target, parts[0..last], allow_optional_chain);
}
},
.e_identifier => |ex| {

View File

@@ -69,7 +69,8 @@ pub fn VisitExpr(
if (p.define.dots.get("meta")) |meta| {
for (meta) |define| {
// TODO: clean up how we do define matches
if (p.isDotDefineMatch(expr, define.parts)) {
// Allow optional chains since we're substituting a value
if (p.isDotDefineMatch(expr, define.parts, true)) {
// Substitute user-specified defines
return p.valueForDefine(expr.loc, in.assign_target, is_delete_target, &define.data);
}
@@ -518,6 +519,26 @@ pub fn VisitExpr(
const is_call_target = p.call_target == .e_index and expr.data.e_index == p.call_target.e_index;
const is_delete_target = p.delete_target == .e_index and expr.data.e_index == p.delete_target.e_index;
// Check for defines with bracket notation (e.g., process.env["VAR"])
// This is checked first before any transformations, similar to e_dot handling
if (e_.index.data == .e_string and e_.index.data.e_string.isUTF8()) {
const index_str = e_.index.data.e_string.slice(p.allocator);
if (p.define.dots.get(index_str)) |parts| {
for (parts) |*define| {
// Allow optional chains since we're substituting a value
if (p.isDotDefineMatch(expr, define.parts, true)) {
if (in.assign_target == .none) {
// Substitute user-specified defines
if (!define.data.valueless()) {
return p.valueForDefine(expr.loc, in.assign_target, is_delete_target, &define.data);
}
}
break;
}
}
}
}
// "a['b']" => "a.b"
if (p.options.features.minify_syntax and
e_.index.data == .e_string and
@@ -832,10 +853,15 @@ pub fn VisitExpr(
if (p.define.dots.get(e_.name)) |parts| {
for (parts) |*define| {
if (p.isDotDefineMatch(expr, define.parts)) {
// When substituting a value, allow optional chains (e.g. process?.env?.NODE_ENV)
// because the substitution makes the chain irrelevant.
// When just setting flags, don't allow optional chains because the chain
// itself has observable behavior (checking if the object exists).
const has_value = !define.data.valueless();
if (p.isDotDefineMatch(expr, define.parts, has_value)) {
if (in.assign_target == .none) {
// Substitute user-specified defines
if (!define.data.valueless()) {
if (has_value) {
return p.valueForDefine(expr.loc, in.assign_target, is_delete_target, &define.data);
}

View File

@@ -349,7 +349,7 @@ pub const Loader = struct {
}
}
// We have to copy all the keys to prepend "process.env" :/
// We have to copy all the keys to prepend "process.env" and "import.meta.env" :/
var key_buf_len: usize = 0;
var e_strings_to_allocate: usize = 0;
@@ -379,11 +379,15 @@ pub const Loader = struct {
if (key_buf_len > 0) {
iter.reset();
key_buf = try allocator.alloc(u8, key_buf_len + key_count * "process.env.".len);
// Allocate space for both "process.env." and "import.meta.env." prefixes
// We double key_buf_len for the env var names, and add space for both prefixes per key
key_buf = try allocator.alloc(u8, key_buf_len * 2 + key_count * ("process.env.".len + "import.meta.env.".len));
var key_writer = std.Io.Writer.fixed(key_buf);
const js_ast = bun.ast;
var e_strings = try allocator.alloc(js_ast.E.String, e_strings_to_allocate * 2);
// Allocate e_strings for both process.env and import.meta.env defines
// Each env var needs 2 e_strings (one for process.env, one for import.meta.env)
var e_strings = try allocator.alloc(js_ast.E.String, e_strings_to_allocate * 4);
errdefer allocator.free(e_strings);
errdefer allocator.free(key_buf);
@@ -392,29 +396,32 @@ pub const Loader = struct {
const value: string = entry.value_ptr.value;
if (strings.startsWith(entry.key_ptr.*, prefix)) {
key_writer.print("process.env.{s}", .{entry.key_ptr.*}) catch |err| switch (err) {
error.WriteFailed => unreachable, // miscalculated length of key_buf above
};
const key_str = key_writer.buffered();
key_writer = std.Io.Writer.fixed(key_writer.unusedCapacitySlice());
// Add defines for both process.env.* and import.meta.env.* (Vite compat)
inline for (.{ "process.env.", "import.meta.env." }) |env_prefix| {
key_writer.print(env_prefix ++ "{s}", .{entry.key_ptr.*}) catch |err| switch (err) {
error.WriteFailed => unreachable, // miscalculated length of key_buf above
};
const key_str = key_writer.buffered();
key_writer = std.Io.Writer.fixed(key_writer.unusedCapacitySlice());
e_strings[0] = js_ast.E.String{
.data = if (value.len > 0)
@as([*]u8, @ptrFromInt(@intFromPtr(value.ptr)))[0..value.len]
else
&[_]u8{},
};
const expr_data = js_ast.Expr.Data{ .e_string = &e_strings[0] };
e_strings[0] = js_ast.E.String{
.data = if (value.len > 0)
@as([*]u8, @ptrFromInt(@intFromPtr(value.ptr)))[0..value.len]
else
&[_]u8{},
};
const expr_data = js_ast.Expr.Data{ .e_string = &e_strings[0] };
_ = try to_string.getOrPutValue(
key_str,
.init(.{
.can_be_removed_if_unused = true,
.call_can_be_unwrapped_if_unused = .if_unused,
.value = expr_data,
}),
);
e_strings = e_strings[1..];
_ = try to_string.getOrPutValue(
key_str,
.init(.{
.can_be_removed_if_unused = true,
.call_can_be_unwrapped_if_unused = .if_unused,
.value = expr_data,
}),
);
e_strings = e_strings[1..];
}
} else {
const hash = bun.hash(entry.key_ptr.*);
@@ -446,30 +453,33 @@ pub const Loader = struct {
while (iter.next()) |entry| {
const value: string = entry.value_ptr.value;
key_writer.print("process.env.{s}", .{entry.key_ptr.*}) catch |err| switch (err) {
error.WriteFailed => unreachable, // miscalculated length of key_buf above
};
const key_str = key_writer.buffered();
key_writer = std.Io.Writer.fixed(key_writer.unusedCapacitySlice());
// Add defines for both process.env.* and import.meta.env.* (Vite compat)
inline for (.{ "process.env.", "import.meta.env." }) |env_prefix| {
key_writer.print(env_prefix ++ "{s}", .{entry.key_ptr.*}) catch |err| switch (err) {
error.WriteFailed => unreachable, // miscalculated length of key_buf above
};
const key_str = key_writer.buffered();
key_writer = std.Io.Writer.fixed(key_writer.unusedCapacitySlice());
e_strings[0] = js_ast.E.String{
.data = if (entry.value_ptr.value.len > 0)
@as([*]u8, @ptrFromInt(@intFromPtr(entry.value_ptr.value.ptr)))[0..value.len]
else
&[_]u8{},
};
e_strings[0] = js_ast.E.String{
.data = if (value.len > 0)
@as([*]u8, @ptrFromInt(@intFromPtr(value.ptr)))[0..value.len]
else
&[_]u8{},
};
const expr_data = js_ast.Expr.Data{ .e_string = &e_strings[0] };
const expr_data = js_ast.Expr.Data{ .e_string = &e_strings[0] };
_ = try to_string.getOrPutValue(
key_str,
.init(.{
.can_be_removed_if_unused = true,
.call_can_be_unwrapped_if_unused = .if_unused,
.value = expr_data,
}),
);
e_strings = e_strings[1..];
_ = try to_string.getOrPutValue(
key_str,
.init(.{
.can_be_removed_if_unused = true,
.call_can_be_unwrapped_if_unused = .if_unused,
.value = expr_data,
}),
);
e_strings = e_strings[1..];
}
}
}
}

View File

@@ -116,5 +116,109 @@ for (let backend of ["api", "cli"] as const) {
stdout: "process.env.BASE_URL\n$BASE_URL",
},
});
// Test optional chaining with process?.env?.VAR
if (backend === "cli")
itBundled("env/optional-chaining", {
env: {
MY_VAR: "my_value",
ANOTHER: "another_value",
},
backend: backend,
dotenv: "inline",
files: {
"/a.js": `
// Test optional chaining patterns
console.log(process?.env?.MY_VAR);
console.log(process?.env?.ANOTHER);
// Mixed optional chaining
console.log(process?.env.MY_VAR);
console.log(process.env?.MY_VAR);
`,
},
run: {
env: {
MY_VAR: "wrong",
ANOTHER: "wrong",
},
stdout: "my_value\nanother_value\nmy_value\nmy_value\n",
},
});
// Test optional chaining with bracket notation
if (backend === "cli")
itBundled("env/optional-chaining-bracket", {
env: {
BRACKET_VAR: "bracket_value",
},
backend: backend,
dotenv: "inline",
files: {
"/a.js": `
// Test optional chaining with bracket notation
console.log(process?.env?.["BRACKET_VAR"]);
console.log(process?.env["BRACKET_VAR"]);
console.log(process.env?.["BRACKET_VAR"]);
`,
},
run: {
env: {
BRACKET_VAR: "wrong",
},
stdout: "bracket_value\nbracket_value\nbracket_value\n",
},
});
// Test import.meta.env.* inlining
if (backend === "cli")
itBundled("env/import-meta-env", {
env: {
VITE_API_URL: "https://api.example.com",
MY_SECRET: "secret123",
},
backend: backend,
dotenv: "inline",
files: {
"/a.js": `
// Test import.meta.env.* inlining (Vite compatibility)
console.log(import.meta.env.VITE_API_URL);
console.log(import.meta.env.MY_SECRET);
`,
},
run: {
env: {
VITE_API_URL: "wrong",
MY_SECRET: "wrong",
},
stdout: "https://api.example.com\nsecret123\n",
},
});
// Test import.meta.env with prefix matching
if (backend === "cli")
itBundled("env/import-meta-env-prefix", {
env: {
VITE_PUBLIC: "public_value",
VITE_PRIVATE: "private_value",
OTHER_VAR: "other_value",
},
backend: backend,
dotenv: "VITE_*",
files: {
"/a.js": `
// Test import.meta.env with prefix matching
console.log(import.meta.env.VITE_PUBLIC);
console.log(import.meta.env.VITE_PRIVATE);
console.log(import.meta.env.OTHER_VAR);
`,
},
run: {
env: {
VITE_PUBLIC: "wrong",
VITE_PRIVATE: "wrong",
},
stdout: "public_value\nprivate_value\nundefined\n",
},
});
});
}