Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions src/bundler/defines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use crate::defines_table::{
// directly with no cross-crate hook.
// ══════════════════════════════════════════════════════════════════════════
pub use bun_js_parser::defines::{
Define, DefineData, DotDefine, Flags, IdentifierDefine, Options, RawDefines, UserDefines,
UserDefinesArray, are_parts_equal,
Define, DefineData, DotDefine, EnvDotsMap, Flags, IdentifierDefine, Options, RawDefines,
UserDefines, UserDefinesArray, are_parts_equal,
};

/// Alias for `Options` so `options.rs` can write `DefineData::init(DefineDataInit { .. })`.
Expand Down Expand Up @@ -132,15 +132,20 @@ pub fn copy_env_for_define(
debug_assert!(!prefix.is_empty());
}

// Windows env-var names are case-insensitive; match the prefix the same way.
let has_prefix = |k: &[u8]| -> bool {
if cfg!(windows) {
bun_core::strings::starts_with_case_insensitive_ascii(k, prefix)
} else {
bun_core::strings::starts_with(k, prefix)
}
};

// When `behavior == .prefix` and NO env key starts
// with `prefix`, the entire second walk (including the framework-hash `else` arm)
// must be skipped. Pre-scan for a prefix match before emitting.
let any_prefix_match = if behavior == DotEnvBehavior::Prefix {
env.map
.map
.keys()
.iter()
.any(|k| bun_core::strings::starts_with(k, prefix))
env.map.map.keys().iter().any(|k| has_prefix(k))
} else {
true
};
Expand All @@ -158,7 +163,7 @@ pub fn copy_env_for_define(
let value: &[u8] = &v.value;

if behavior == DotEnvBehavior::Prefix {
if bun_core::strings::starts_with(k, prefix) {
if has_prefix(k) {
key_buf.clear();
key_buf.extend_from_slice(PROCESS_ENV);
key_buf.extend_from_slice(k);
Expand Down Expand Up @@ -254,6 +259,7 @@ impl DefineExt for Define {
let mut define = Box::new(Define {
identifiers: StringHashMap::default(),
dots: StringHashMap::default(),
env_dots: EnvDotsMap::default(),
drop_debugger,
});
define.dots.reserve(124);
Expand Down Expand Up @@ -296,13 +302,24 @@ impl DefineExt for Define {
// Step 4. Load environment data into hash tables.
// These are only strings. We do not parse them as JSON.
if let Some(string_defines_) = &string_defines {
define.insert_from_iterator(
string_defines_
.keys()
.iter()
.zip(string_defines_.values().iter())
.map(|(k, v)| (k.as_ref(), v)),
)?;
const PROCESS_ENV: &[u8] = b"process.env.";
define.env_dots.reserve(string_defines_.len());
for (k, v) in string_defines_
.keys()
.iter()
.zip(string_defines_.values().iter())
{
let k: &[u8] = k.as_ref();
// Env-derived `process.env.X` entries go into `env_dots` keyed
// by `X` so the Windows lookup can be case-insensitive (matching
// runtime `process.env` semantics). Everything else stays on the
// regular `dots`/`identifiers` path.
if k.len() > PROCESS_ENV.len() && &k[..PROCESS_ENV.len()] == PROCESS_ENV {
define.env_dots.put(&k[PROCESS_ENV.len()..], v.clone())?;
} else {
define.insert(k, v.clone())?;
}
}
}

Ok(define)
Expand Down
2 changes: 2 additions & 0 deletions src/bundler/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,7 @@ impl<'a> BundleOptions<'a> {
define: Box::new(defines::Define {
identifiers: self.define.identifiers.clone(),
dots: self.define.dots.clone(),
env_dots: bun_core::handle_oom(self.define.env_dots.clone()),
drop_debugger: self.define.drop_debugger,
}),
drop: self.drop.clone(),
Expand Down Expand Up @@ -1747,6 +1748,7 @@ impl<'a> BundleOptions<'a> {
define: Box::new(defines::Define {
identifiers: Default::default(),
dots: Default::default(),
env_dots: Default::default(),
drop_debugger: false,
}),
loaders,
Expand Down
10 changes: 10 additions & 0 deletions src/js_parser/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,10 +499,20 @@ pub mod defines {
}
}

/// `process.env.X` defines emitted by `--env inline` / `--env PREFIX_*`.
/// Keyed by the env-var name (last segment). On Windows `process.env`
/// reads are case-insensitive at runtime, so the bundle-time lookup must
/// be too; on other platforms it stays case-sensitive.
#[cfg(windows)]
pub type EnvDotsMap = bun_collections::CaseInsensitiveAsciiStringArrayHashMap<DefineData>;
#[cfg(not(windows))]
pub type EnvDotsMap = StringArrayHashMap<DefineData>;

#[derive(Default)]
pub struct Define {
pub identifiers: StringHashMap<IdentifierDefine>,
pub dots: StringHashMap<Vec<DotDefine>>,
pub env_dots: EnvDotsMap,
pub drop_debugger: bool,
}

Expand Down
19 changes: 11 additions & 8 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6688,10 +6688,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
Expr { data: value, loc }
}

// `parts` is `&[Box<[u8]>]` to match the active `DotDefine.parts:
// Vec<Box<[u8]>>` shape (auto-derefs at call sites). The full draft uses
// `StoreSlice<StoreStr>`; both index to a `[u8]` so the body is unchanged.
pub fn is_dot_define_match(&mut self, expr: Expr, parts: &[Box<[u8]>]) -> bool {
// Generic over `AsRef<[u8]>` so callers can pass either the owned
// `DotDefine.parts: Vec<Box<[u8]>>` or a const `&[&[u8]]` (e.g. the
// `["process","env"]` prefix for env-inline lookups).
pub fn is_dot_define_match<S: AsRef<[u8]>>(&mut self, expr: Expr, parts: &[S]) -> bool {
match expr.data {
js_ast::ExprData::EDot(ex) => {
if parts.len() > 1 {
Expand All @@ -6700,12 +6700,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
// Intermediates must be dot expressions
let last = parts.len() - 1;
let is_tail_match = strings::eql(&parts[last], &ex.name);
let is_tail_match = strings::eql(parts[last].as_ref(), &ex.name);
return is_tail_match && self.is_dot_define_match(ex.target, &parts[..last]);
}
}
js_ast::ExprData::EImportMeta(_) => {
return parts.len() == 2 && &*parts[0] == b"import" && &*parts[1] == b"meta";
return parts.len() == 2
&& parts[0].as_ref() == b"import"
&& parts[1].as_ref() == b"meta";
}
// Note: this behavior differs from esbuild
// esbuild does not try to match index accessors
Expand All @@ -6719,7 +6721,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
return false;
}
let last = parts.len() - 1;
let is_tail_match = strings::eql(&parts[last], s.slice(self.arena));
let is_tail_match =
strings::eql(parts[last].as_ref(), s.slice(self.arena));
return is_tail_match
&& self.is_dot_define_match(index.target, &parts[..last]);
}
Expand All @@ -6730,7 +6733,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
// The last expression must be an identifier
if parts.len() == 1 {
let name = self.load_name_from_ref(ex.ref_);
if !strings::eql(name, &parts[0]) {
if !strings::eql(name, parts[0].as_ref()) {
return false;
}

Expand Down
31 changes: 31 additions & 0 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,37 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
}

// `--env inline` / `--env PREFIX_*` derived `process.env.X` defines.
// Checked after `dots` so an explicit `--define process.env.X=...`
// (which lives in `dots`) wins. On Windows `env_dots` is keyed
// case-insensitively so `process.env.PATH` matches the OS's `Path`.
if !defines.env_dots.is_empty() && e_.optional_chain.is_none() {
const PROCESS_ENV: &[&[u8]; 2] = &[b"process", b"env"];
if p.is_dot_define_match(e_.target, PROCESS_ENV) {
if let Some(data) = defines.env_dots.get(e_.name.slice()) {
if in_.assign_target == js_ast::AssignTarget::None {
if !data.valueless() {
*e = p.value_for_define(
expr.loc,
in_.assign_target,
is_delete_target,
data,
);
return;
}
}
if data.can_be_removed_if_unused() {
e_.can_be_removed_if_unused = true;
}
if data.call_can_be_unwrapped_if_unused() != E::CallUnwrap::Never
&& !p.options.ignore_dce_annotations
{
e_.call_can_be_unwrapped_if_unused = data.call_can_be_unwrapped_if_unused();
}
}
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Track ".then().catch()" chains
if is_call_target
&& matches!(p.then_catch_chain.next_target, Data::EDot(nt) if core::ptr::eq(&raw const *e_, &raw const *nt))
Expand Down
155 changes: 154 additions & 1 deletion test/bundler/bundler_env.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe } from "bun:test";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { itBundled } from "./expectBundled";

for (let backend of ["api", "cli"] as const) {
Expand Down Expand Up @@ -46,6 +47,91 @@
},
});

// An explicit `--define process.env.X=...` must beat an env-derived value
// for the same key. This is the cross-platform observable guarantee of
// routing env-derived defines through a separate map that is consulted
// only after the user define table.
if (backend === "cli")
itBundled("env/inline-explicit-define-wins", {
env: {
BUN_TEST_ENV_DEFINE_WINS: "from_env",
},
define: {
"process.env.BUN_TEST_ENV_DEFINE_WINS": '"from_define"',
},
backend: backend,
dotenv: "inline",
files: {
"/a.js": `
console.log(process.env.BUN_TEST_ENV_DEFINE_WINS);
`,
},
onAfterBundle(api) {
const out = api.readFile("out.js");
expect(out).toContain('"from_define"');
expect(out).not.toContain("from_env");
},
run: {
env: {
BUN_TEST_ENV_DEFINE_WINS: "from_runtime",
},
stdout: "from_define\n",
},
});

// On Windows the OS reports env-var names in their stored case (`Path`,
// `SystemRoot`), but `process.env` reads are case-insensitive at runtime.
// `env: "inline"` must match that: any casing in source resolves to the
// same value. On POSIX env vars are case-sensitive, so only the exact
// spelling inlines and the other two read the (unset) runtime env.
if (backend === "cli")
itBundled("env/inline-env-var-name-case", {
env: {
BUN_TEST_Env_Inline_MixedCase: "inlined",
},
backend: backend,
dotenv: "inline",
files: {
"/a.js": `
console.log(process.env.BUN_TEST_Env_Inline_MixedCase);
console.log(process.env.BUN_TEST_ENV_INLINE_MIXEDCASE);
console.log(process.env.bun_test_env_inline_mixedcase);
`,
},
onAfterBundle(api) {
const out = api.readFile("out.js");
if (isWindows) expect(out).not.toContain("process.env.");
},
run: {
env: {
BUN_TEST_Env_Inline_MixedCase: "runtime",
},
stdout: isWindows ? "inlined\ninlined\ninlined\n" : "inlined\nundefined\nundefined\n",
},
});

// `--env PREFIX_*` prefix matching is likewise case-insensitive on
// Windows only.
if (backend === "cli")
itBundled("env/prefix-env-var-name-case", {
env: {
Bun_Test_Prefix_A: "a",
BUN_TEST_PREFIX_B: "b",
},
backend: backend,
dotenv: "BUN_TEST_PREFIX_*",
files: {
"/a.js": `
console.log(process.env.Bun_Test_Prefix_A);
console.log(process.env.bun_test_prefix_a);
console.log(process.env.BUN_TEST_PREFIX_B);
`,
},
run: {
stdout: isWindows ? "a\na\nb\n" : "undefined\nundefined\nb\n",
},
});

// Test disable mode - no env vars are inlined
itBundled("env/disable", {
env: {
Expand Down Expand Up @@ -118,3 +204,70 @@
});
});
}

// Direct `bun build` spawn so the Windows path is covered independently of the
// itBundled registration path (which currently skips on Windows, see #34552).
describe("bundler/env via spawn", () => {
async function buildInline(
src: string,
extra: { env?: Record<string, string>; define?: Record<string, string>; dotenv?: string } = {},
) {
using dir = tempDir("bundler-env-inline", { "a.js": src });
const cmd = [bunExe(), "build", String(dir) + "/a.js", "--env", extra.dotenv ?? "inline"];
for (const [k, v] of Object.entries(extra.define ?? {})) cmd.push(`--define:${k}=${v}`);
await using proc = Bun.spawn({
cmd,
env: { ...bunEnv, ...(extra.env ?? {}) },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);

Check warning on line 226 in test/bundler/bundler_env.test.ts

View check run for this annotation

Claude / Claude Code Review

Test asserts stderr is exactly empty

The `buildInline` helper asserts `expect(stderr).toBe("")`, which REVIEW.md's "Tests reviewers reject" section explicitly discourages — ASAN/debug builds can emit benign warnings to stderr and would spuriously fail all three spawn tests. Prefer asserting a combined object (e.g. `expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 })` or a normalized snapshot). Not blocking: `bunEnv` sets `BUN_DEBUG_QUIET_LOGS=1` and this pattern already appears widely in `test/bundler/`, but new cod
Comment thread
robobun marked this conversation as resolved.
return stdout;
}

test("process.env.X matches the env-var name case-insensitively on Windows only", async () => {
const out = await buildInline(
`console.log(process.env.BUN_TEST_Env_Mixed);
console.log(process.env.BUN_TEST_ENV_MIXED);
console.log(process.env.bun_test_env_mixed);
`,
{ env: { BUN_TEST_Env_Mixed: "inlined" } },
);
const inlined = (out.match(/"inlined"/g) ?? []).length;
if (isWindows) {
expect(out).not.toContain("process.env.");
expect(inlined).toBe(3);
} else {
expect(inlined).toBe(1);
expect(out).toContain("process.env.BUN_TEST_ENV_MIXED");
expect(out).toContain("process.env.bun_test_env_mixed");
}
});

test("--env PREFIX_* matches case-insensitively on Windows only", async () => {
const out = await buildInline(
`console.log(process.env.Bun_Test_EnvPfx_A);
console.log(process.env.BUN_TEST_ENVPFX_B);
`,
{ env: { Bun_Test_EnvPfx_A: "a", BUN_TEST_ENVPFX_B: "b" }, dotenv: "BUN_TEST_ENVPFX_*" },
);
if (isWindows) {
expect(out).not.toContain("process.env.");
expect(out).toContain('"a"');
expect(out).toContain('"b"');
} else {
expect(out).toContain("process.env.Bun_Test_EnvPfx_A");
expect(out).toContain('"b"');
}
});

test("process.env?.X is not inlined by --env inline", async () => {
const out = await buildInline(`console.log(process.env?.BUN_TEST_ENV_OPT);\n`, {
env: { BUN_TEST_ENV_OPT: "inlined" },
});
expect(out).toContain("process.env?.BUN_TEST_ENV_OPT");
expect(out).not.toContain('"inlined"');
});
});
Loading