Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
6 changes: 6 additions & 0 deletions docs/runtime/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ process.env.BAR; // => "hello$FOO"

Bun reads `.env` files automatically, so `dotenv` and `dotenv-expand` are unnecessary.

### Enumeration

Values that Bun auto-loads from `.env` files are readable via `process.env.NAME` but are **not enumerable**: `Object.keys(process.env)`, `for..in`, and `{ ...process.env }` list only variables from the OS environment and from explicit `--env-file` arguments. This keeps `process.env` enumeration Node-compatible so tools with their own `.env.{mode}` loading (such as Vite's `loadEnv`) do not mistake Bun's auto-loaded values for shell-provided overrides. Assigning to such a key from JavaScript promotes it to an enumerable own property. `Object.getOwnPropertyNames(process.env)` lists all keys including the auto-loaded ones.

Default inherited environments (`Bun.spawn` with no `env`, `child_process` with no `options.env`, `Bun.$`, and `worker_threads`) still receive auto-loaded values. Once a `worker_threads` `SHARE_ENV` tree is founded, `process.env` becomes a shared string map and auto-loaded values enumerate like any other entry on the threads in that tree.

## Reading environment variables

Read the current environment variables from `process.env`.
Expand Down
60 changes: 49 additions & 11 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,14 +449,18 @@ impl Loader {
*cxx_gop.key_ptr = Box::<[u8]>::from(&**cxx_gop.key_ptr);
*cxx_gop.value_ptr = HashTableValue {
value: ccache_path.clone(),
conditional: false,
};
}
let c_gop = self
.map
.get_or_put_without_value(b"CMAKE_C_COMPILER_LAUNCHER")?;
if !c_gop.found_existing {
*c_gop.key_ptr = Box::<[u8]>::from(&**c_gop.key_ptr);
*c_gop.value_ptr = HashTableValue { value: ccache_path };
*c_gop.value_ptr = HashTableValue {
value: ccache_path,
conditional: false,
};
}
}
Ok(())
Expand Down Expand Up @@ -611,7 +615,11 @@ impl Loader {
// `Source.contents: &'static [u8]` lifetime constraint (callers like
// `node:util.parseEnv` pass JS-owned non-'static buffers).
let mut value_buffer: Vec<u8> = Vec::new();
Parser::parse_bytes::<OVERWRITE, false, EXPAND>(str, &mut self.map, &mut value_buffer)
Parser::parse_bytes::<OVERWRITE, false, EXPAND, false>(
str,
&mut self.map,
&mut value_buffer,
)
}

pub fn load<D: DirEntryProbe + ?Sized>(
Expand Down Expand Up @@ -870,7 +878,11 @@ impl Loader {
}
}
ReadEnvFile::Bytes(buf) => {
Parser::parse_bytes::<OVERRIDE, false, true>(&buf, &mut self.map, value_buffer)?;
Parser::parse_bytes::<OVERRIDE, false, true, true>(
&buf,
&mut self.map,
value_buffer,
)?;
}
}

Expand Down Expand Up @@ -917,7 +929,11 @@ impl Loader {
}
}
ReadEnvFile::Bytes(buf) => {
Parser::parse_bytes::<OVERRIDE, false, true>(&buf, &mut self.map, value_buffer)?;
Parser::parse_bytes::<OVERRIDE, false, true, false>(
&buf,
&mut self.map,
value_buffer,
)?;
}
}

Expand Down Expand Up @@ -1207,7 +1223,12 @@ impl<'a> Parser<'a> {
Ok(Some(self.value_buffer.as_slice()))
}

fn _parse<const OVERRIDE: bool, const IS_PROCESS: bool, const EXPAND: bool>(
fn _parse<
const OVERRIDE: bool,
const IS_PROCESS: bool,
const EXPAND: bool,
const CONDITIONAL: bool,
>(
&mut self,
map: &mut Map,
) -> Result<(), AllocError> {
Expand All @@ -1231,7 +1252,10 @@ impl<'a> Parser<'a> {
}
// else: previous value freed by Drop on assignment below
}
*entry.value_ptr = HashTableValue { value: value_owned };
*entry.value_ptr = HashTableValue {
value: value_owned,
conditional: CONDITIONAL,
};
}
if !IS_PROCESS && EXPAND {
// borrowck — index-based iteration: clone the value bytes, run
Expand All @@ -1243,9 +1267,7 @@ impl<'a> Parser<'a> {
while idx < total {
let current: Box<[u8]> = Box::from(&*map.map.values()[idx].value);
if let Some(expanded) = self.expand_value(map, &current)? {
map.map.values_mut()[idx] = HashTableValue {
value: Box::from(expanded),
};
map.map.values_mut()[idx].value = Box::from(expanded);
}
idx += 1;
}
Expand All @@ -1258,7 +1280,12 @@ impl<'a> Parser<'a> {
/// Same as [`parse`] but takes the source bytes directly. Exists so
/// `load_env_file*` can parse a transient `Vec<u8>` without constructing a
/// `bun_ast::Source` (whose `contents` field is currently `&'static [u8]`).
pub(crate) fn parse_bytes<const OVERRIDE: bool, const IS_PROCESS: bool, const EXPAND: bool>(
pub(crate) fn parse_bytes<
const OVERRIDE: bool,
const IS_PROCESS: bool,
const EXPAND: bool,
const CONDITIONAL: bool,
>(
src: &[u8],
map: &mut Map,
value_buffer: &mut Vec<u8>,
Expand All @@ -1272,7 +1299,7 @@ impl<'a> Parser<'a> {
src: strings::without_utf8_bom(src),
value_buffer,
};
parser._parse::<OVERRIDE, IS_PROCESS, EXPAND>(map)
parser._parse::<OVERRIDE, IS_PROCESS, EXPAND, CONDITIONAL>(map)
}
}

Expand All @@ -1281,6 +1308,13 @@ pub struct HashTableValue {
// `Box<[u8]>` is owned-by-default, trading some copies for uniform
// ownership.
pub value: Box<[u8]>,
/// Set for keys that exist only because Bun auto-discovered a `.env*` file,
/// not because the OS environment, `--env-file`, or an explicit `put()`
/// supplied them. `createEnvironmentVariablesMap` adds these with
/// `DontEnum` so tooling that re-reads `.env.{mode}` itself (Vite's
/// `loadEnv`, dotenv-flow, etc.) does not mistake them for process-level
/// overrides when enumerating `process.env`.
Comment thread
robobun marked this conversation as resolved.
pub conditional: bool,
}

// On Windows, environment variables are case-insensitive. So we use a case-insensitive hash map.
Expand Down Expand Up @@ -1411,6 +1445,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
)
}
Expand All @@ -1428,6 +1463,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
);
}
Expand All @@ -1437,6 +1473,7 @@ impl Map {
let gop = self.map.get_or_put(key)?;
*gop.value_ptr = HashTableValue {
value: Box::from(value),
conditional: false,
};
if !gop.found_existing {
*gop.key_ptr = Box::from(key);
Expand Down Expand Up @@ -1471,6 +1508,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
)?;
Ok(())
Expand Down
1 change: 1 addition & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ fn configure_env_for_scripts_run(
value: Box::<[u8]>::from(strings::without_trailing_slash(
FileSystem::instance().top_level_dir(),
)),
conditional: false,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/install_jsc/ini_jsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ impl IniTestingAPIs {
&keyslice,
dotenv::map::Entry {
value: slice.into_boxed_slice(),
conditional: false,
},
)?;
}
Expand Down
35 changes: 20 additions & 15 deletions src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,24 +453,24 @@ export function windowsEnv(
//
// it throws "Cannot convert a Symbol value to a string"

(internalEnv as any)[Bun.inspect.custom] = () => {
// envMapList now includes auto-loaded .env keys (DontEnum on internalEnv) so
// getOwnPropertyNames can see them; inspection and toJSON mirror Object.keys
// by skipping keys whose storage property is non-enumerable.
Comment thread
robobun marked this conversation as resolved.
Outdated
const enumerableView = () => {
let o = {};
for (let k of envMapList) {
o[k] = internalEnv[k.toUpperCase()];
}
return o;
};

(internalEnv as any).toJSON = () => {
// Mirror enumeration: original-case key names, case-insensitive values.
// Spreading internalEnv directly would leak the canonical UPPERCASE
// storage keys into JSON.stringify(process.env) and IPC env echoes.
let o = {};
for (let k of envMapList) {
o[k] = internalEnv[k.toUpperCase()];
const up = k.toUpperCase();
if ($Object.getOwnPropertyDescriptor(internalEnv, up)?.enumerable) {
o[k] = internalEnv[up];
}
}
return o;
};
(internalEnv as any)[Bun.inspect.custom] = enumerableView;
// Mirror enumeration: original-case key names, case-insensitive values.
// Spreading internalEnv directly would leak the canonical UPPERCASE
// storage keys into JSON.stringify(process.env) and IPC env echoes.
Comment thread
robobun marked this conversation as resolved.
(internalEnv as any).toJSON = enumerableView;

return new Proxy(internalEnv, {
get(_, p) {
Expand Down Expand Up @@ -505,8 +505,10 @@ export function windowsEnv(
}
if (internalEnv[k] !== value) {
editWindowsEnvVar(k, value);
internalEnv[k] = value;
}
// Unconditional so a same-value write to a DontEnum auto-loaded .env key
// still promotes it to an enumerable data property via the custom setter.
Comment thread
robobun marked this conversation as resolved.
internalEnv[k] = value;
return true;
},
has(_, p) {
Expand All @@ -530,7 +532,10 @@ export function windowsEnv(
defineProperty(_, p, attributes) {
const k = String(p).toUpperCase();
$assert(typeof p === "string"); // proxy is only string and symbol. the symbol would have thrown by now
if (!(k in internalEnv) && !envMapList.includes(p)) {
// Gate on envMapList membership, not `k in internalEnv`: the
// always-present TZ/proxy accessors are own properties of internalEnv
// while correctly absent from envMapList.
Comment thread
robobun marked this conversation as resolved.
if (!envMapList.includes(p) && !envMapList.some(x => x.toUpperCase() === k)) {
envMapList.push(p);
}
editWindowsEnvVar(k, internalEnv[k]);
Expand Down
16 changes: 14 additions & 2 deletions src/js/builtins/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,18 @@
const originalDefaultEnv = defaultEnv;
var defaultCwd: string | undefined = undefined;

// Auto-loaded .env values are DontEnum on process.env; setEnv iterates
// enumerable-only. Snapshot via getOwnPropertyNames so $`cmd` keeps
// inheriting both .env values and runtime process.env mutations.
Comment thread
robobun marked this conversation as resolved.
Outdated
function snapshotProcessEnv(env) {
const out = {};
for (const key of $Object.getOwnPropertyNames(env)) {
const v = env[key];
if (v !== undefined && typeof v !== "function") out[key] = v;
}
return out;
}

Check warning on line 266 in src/js/builtins/shell.ts

View check run for this annotation

Claude / Claude Code Review

ShellPromise.prototype.env() bypasses snapshotProcessEnv, dropping auto-loaded .env values

`snapshotProcessEnv` is applied at the two template-tag `setEnv` sites (lines 324 and 344) but not at the third sibling in this file — `ShellPromise.prototype.env()` still calls `this.#args!.setEnv(newEnv)` directly. So ``await Bun.$`cmd`.env(undefined)`` (or `.env(process.env)`) passes the raw `process.env` to `ParsedShellScript::set_env`, which iterates enumerable-only and drops the now-DontEnum auto-loaded `.env` keys, overwriting the correct snapshot the template tag already installed. Apply
Comment thread
robobun marked this conversation as resolved.

const cwdSymbol = Symbol("cwd");
const envSymbol = Symbol("env");
const throwsSymbol = Symbol("throws");
Expand Down Expand Up @@ -309,7 +321,7 @@

// cwd must be set before env or else it will be injected into env as "PWD=/"
if (cwd) parsed_shell_script.setCwd(cwd);
if (env) parsed_shell_script.setEnv(env);
if (env) parsed_shell_script.setEnv(env === originalDefaultEnv ? snapshotProcessEnv(env) : env);

return new ShellPromise(parsed_shell_script, throws);
};
Expand All @@ -329,7 +341,7 @@

// cwd must be set before env or else it will be injected into env as "PWD=/"
if (cwd) parsed_shell_script.setCwd(cwd);
if (env) parsed_shell_script.setEnv(env);
if (env) parsed_shell_script.setEnv(env === originalDefaultEnv ? snapshotProcessEnv(env) : env);

return new ShellPromise(parsed_shell_script, throws);
};
Expand Down
12 changes: 10 additions & 2 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,8 +1020,16 @@
// copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env);

let envKeys: string[] = [];
for (const key in env) {
ArrayPrototypePush.$call(envKeys, key);
if (env === process.env) {
// Auto-loaded .env values are DontEnum on process.env; getOwnPropertyNames
// includes them (and runtime mutations) so children keep inheriting both.
Comment thread
robobun marked this conversation as resolved.
Outdated
for (const key of $Object.getOwnPropertyNames(env)) {
ArrayPrototypePush.$call(envKeys, key);
}
} else {
for (const key in env) {
ArrayPrototypePush.$call(envKeys, key);
}

Check failure on line 1032 in src/js/node/child_process.ts

View check run for this annotation

Claude / Claude Code Review

cluster.fork() and the WASI runner drop auto-loaded .env values from child environments

Two more default-env-inheritance siblings were missed by the `getOwnPropertyNames` compensation applied here (and its analogues in shell.ts / JSWorker.cpp / `ensureSharedEnvStoreForWorker`): **`cluster.fork()`** (src/js/internal/cluster/primary.ts:81 builds `{ ...process.env, ...env, NODE_UNIQUE_ID }` and passes it as `options.env` to `child_process.fork`, so the spread drops DontEnum `.env` keys and this branch's `env === process.env` check does not fire) and the built-in **WASI runner** (src/j
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}

if (process.platform === "win32") {
Expand Down
Loading
Loading