Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/runtime/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ env = false

Files passed with `--env-file` still load even when default loading is disabled.

The setting applies to the whole process. [`Bun.build()`](/bundler) and its [macros](/bundler/macros), [HTML routes](/bundler/fullstack), and [`Worker`](/runtime/workers) threads also get no value from a default `.env` file.

When invoked as `node` (for example via `bun --bun`, `bunx --bun`, or a `node` symlink pointing at Bun), Bun disables automatic `.env` loading to match Node.js. This lets tools with their own mode-aware `.env` resolution, such as Vite's `loadEnv`, pick the correct `.env.{mode}` file instead of seeing Bun's pre-populated values as shell-set overrides. Bun still honors explicit `--env-file` arguments.

---
Expand Down
12 changes: 12 additions & 0 deletions src/bundler/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1850,6 +1850,7 @@ impl<'a> BundleOptions<'a> {
}

opts.env.disable_default_env_files = transform.disable_default_env_files;
opts.env.loaded_by_parent = transform.env_files_loaded_by_parent;

if let Some(origin) = &transform.origin {
// ownership — `URL<'_>` borrows its input; `OwnedURL` owns the href and
Expand Down Expand Up @@ -2108,6 +2109,9 @@ pub struct Env {

/// If true, disable loading of default .env files (from --no-env-file flag or bunfig)
pub disable_default_env_files: bool,

/// A `bun test --parallel` worker: the coordinator read the env files. Not a switch, a later build reads them as usual.
pub loaded_by_parent: bool,
}

impl Default for Env {
Expand All @@ -2117,10 +2121,18 @@ impl Default for Env {
prefix: Box::default(),
files: Box::default(),
disable_default_env_files: false,
loaded_by_parent: false,
}
}
}

impl Env {
/// No `--no-env-file` or bunfig `env = false`, and no `--env-file`, which replaces the default files.
pub fn loads_default_files(&self) -> bool {
!self.disable_default_env_files && self.files.is_empty()
}
}

#[derive(Debug, Clone, Default)]
pub struct PathTemplate {
pub data: Box<[u8]>,
Expand Down
9 changes: 8 additions & 1 deletion src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,9 @@ impl<'a> Transpiler<'a> {
self.options.env.prefix = Box::from(b"BUN_".as_slice());
}

self.run_env_loader(self.options.env.disable_default_env_files)?;
self.run_env_loader(
self.options.env.disable_default_env_files || self.options.env.loaded_by_parent,
)?;

let env_loader = self.env_mut();
let mut is_production = env_loader.is_production();
Expand Down Expand Up @@ -642,6 +644,11 @@ impl<'a> Transpiler<'a> {
// for the duration of this call.
let env: &mut dot_env::Loader = self.env_mut();

// A `Bun.build`, a macro VM and the dev server run this with default options: the options of the process decide.
if !self.options.env.loads_default_files() {
dot_env::exclude_default_files();
}
Comment thread
robobun marked this conversation as resolved.

match self.options.env.behavior {
DotEnvBehavior::prefix
| DotEnvBehavior::load_all
Expand Down
13 changes: 12 additions & 1 deletion src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ static NODE_PATH_TO_USE_SET_ONCE: bun_core::RwLock<Option<Box<[u8]>>> = bun_core
// PORTING.md §Concurrency: OnceLock — set once from CLI flag, read many.
pub static HAS_NO_CLEAR_SCREEN_CLI_FLAG: OnceLock<bool> = OnceLock::new();

static DEFAULT_FILES_EXCLUDED: AtomicBool = AtomicBool::new(false);

/// From here on no `Loader` of this process reads a default `.env` file, whatever its caller asks.
pub fn exclude_default_files() {
DEFAULT_FILES_EXCLUDED.store(true, Ordering::SeqCst);
}

fn default_files_excluded() -> bool {
DEFAULT_FILES_EXCLUDED.load(Ordering::SeqCst)
}

impl Loader {
/// Shared "empty-ish" predicate for proxy env vars: an unset/empty value,
/// or a literal empty-quote pair left over from shell `export FOO=""` /
Expand Down Expand Up @@ -614,7 +625,7 @@ impl Loader {
//
// See https://github.com/oven-sh/bun/issues/9635#issuecomment-2021350123
// for more details on how this edge case works.
if !skip_default_env {
if !skip_default_env && !default_files_excluded() {
self.load_default_files(suffix, dir, &mut value_buffer)?;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/dotenv/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ pub use error::{Error, Result};

pub use env_loader::{
DirEntryKeys, DotEnvBehavior, DotEnvFileSuffix, HAS_NO_CLEAR_SCREEN_CLI_FLAG, HashTable,
HashTableValue, INSTANCE, Loader, Map, NullDelimitedEnvMap, S3Credentials, instance,
set_instance,
HashTableValue, INSTANCE, Loader, Map, NullDelimitedEnvMap, S3Credentials,
exclude_default_files, instance, set_instance,
};

/// `dotenv::map::{HashTable, Entry}` namespace expected by `install_jsc::ini_jsc` et al.
Expand Down
2 changes: 2 additions & 0 deletions src/options_types/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ pub mod api {
pub env_files: Vec<Box<[u8]>>,
/// disable_default_env_files
pub disable_default_env_files: bool,
/// The parent process read the env files into this environment: a VM reads none when it starts.
pub env_files_loaded_by_parent: bool,
/// extension_order
pub extension_order: Vec<Box<[u8]>>,
/// no_summary
Expand Down
8 changes: 7 additions & 1 deletion src/runtime/bake/production/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,14 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
// this is probably wrong
// Note: process-lifetime dotenv singleton owned via `OnceLock`
// (PORTING.md §Forbidden: no leaking).
// With the default files excluded, nothing fills this loader: it starts as what the command read, `--env-file` included.
let app_env = if vm.transpiler.options.env.loads_default_files() {
dotenv::Loader::init()
} else {
vm.transpiler.env().clone_for_worker()?
};
let backing = DOTENV_SINGLETON.get_or_init(|| DotenvSingleton {
loader: UnsafeCell::new(dotenv::Loader::init()),
loader: UnsafeCell::new(app_env),
});
// SAFETY: single-threaded CLI init; `get_or_init` guarantees one-time
// setup and `backing` is never moved (static storage).
Expand Down
13 changes: 11 additions & 2 deletions src/runtime/cli/test/parallel/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ pub(crate) fn run_as_coordinator(
let _ = env.map.put(b"BUN_TEST_WORKER_ID", &id);
envps.push(env.map.create_null_delimited_env_map()?);
}
let argv = build_worker_argv(ctx)?;
// SAFETY: see vm_ptr note above.
let loads_default_env_files = unsafe { (*vm_ptr).transpiler.options.env.loads_default_files() };
let argv = build_worker_argv(ctx, loads_default_env_files)?;

// Sort lexicographically so adjacent indices share parent directories.
// Each worker owns a contiguous chunk; co-located files share imports, so
Expand Down Expand Up @@ -238,7 +240,10 @@ pub(crate) fn run_as_coordinator(
/// (`--path-ignore-patterns`, `--changed`), `--reporter-outfile`,
/// `--pass-with-no-tests`, `--parallel` itself — are intentionally not
/// forwarded.
fn build_worker_argv(ctx: &Command::ContextData) -> crate::Result<Box<[bun_spawn::CStrPtr]>> {
fn build_worker_argv(
ctx: &Command::ContextData,
loads_default_env_files: bool,
) -> crate::Result<Box<[bun_spawn::CStrPtr]>> {
// Null-sentinel slice of C-string pointers. String storage routes through
// the process-lifetime CLI arena (bulk-freed on exit).
let mut argv: Vec<bun_spawn::CStrPtr> = Vec::new();
Expand Down Expand Up @@ -380,6 +385,10 @@ fn build_worker_argv(ctx: &Command::ContextData) -> crate::Result<Box<[bun_spawn
if ctx.args.allow_ffi_cc == Some(false) {
argv.push(lit(b"--no-ffi-cc\0"));
}
// The `.env` switch of the command, for what a worker builds. The worker itself reads no env file.
if !loads_default_env_files {
argv.push(lit(b"--no-env-file\0"));
}
match bun_core::code_generation_from_strings() {
bun_core::CodeGenerationFromStrings::Allowed => {}
bun_core::CodeGenerationFromStrings::DisallowedLikeNode => {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1918,7 +1918,7 @@ impl TestCommand {
// The worker's environment already holds the coordinator's env file values, and `BUN_OPTIONS` in it can carry `--env-file`.
if ctx.test_options.test_worker {
ctx.args.env_files.clear();
ctx.args.disable_default_env_files = true;
ctx.args.env_files_loaded_by_parent = true;
}

bun_ast::initialize_store();
Expand Down
41 changes: 41 additions & 0 deletions test/bake/dev/production.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,4 +780,45 @@ export default function IndexPage() {
expect(indexHtml).toContain("readyState=<!-- -->" + WebSocket.CONNECTING);
expect(buildProc.exitCode).toBe(0);
});

// The transpilers of the app, and the VMs of their macros, get an env loader of their own. The `.env` switches of
// the command apply to it.
test.each([
["no switch", [], "seen:key-from-dot-env,only-from-dot-env"],
["--no-env-file", ["--no-env-file"], "seen:undefined,undefined"],
["--env-file", ["--env-file=custom.env"], "seen:key-from-custom-env,undefined"],
])("%s: what a macro of a page has of .env", async (_, flags, seen) => {
const dir = await tempDirWithBakeDeps("bake-production-env", {
".env": "API_KEY=key-from-dot-env\nONLY_IN_DOT_ENV=only-from-dot-env\n",
"custom.env": "API_KEY=key-from-custom-env\n",
"values.ts":
"export const values = () => [process.env.API_KEY, process.env.ONLY_IN_DOT_ENV].map(String).join();\n",
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/index.tsx": `import { values } from "../values.ts" with { type: "macro" };

export default function IndexPage() {
return <div>{"seen:" + values()}</div>;
}`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
const env = { ...bunEnv };
delete env.API_KEY;
delete env.ONLY_IN_DOT_ENV;

const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ${flags} ./src/index.tsx`
.cwd(dir)
.env(env)
.throws(false);

const page = path.join(dir, "dist", "index.html");
const html = existsSync(page) ? await Bun.file(page).text() : stderr.toString();
expect({ seen: /seen:[\w,-]+/.exec(html)?.[0] ?? html, exitCode }).toEqual({ seen, exitCode: 0 });
});
});
23 changes: 23 additions & 0 deletions test/bundler/bundler_compile_autoload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ describe("bundler", () => {
},
});

// An executable that does not load .env files does not load them for its own Bun.build() either
itBundled("compile/AutoloadDotenvDisabledBunBuild", {
compile: {
autoloadDotenv: false,
},
files: {
"/entry.ts": /* js */ `
const result = await Bun.build({ entrypoints: ["./client.js"], env: "inline" });
const text = await result.outputs[0].text();
console.log(text.includes("from_dotenv") ? "inlined from .env" : "not inlined");
console.log(process.env.TEST_VAR || "not found");
`,
},
runtimeFiles: {
"/.env": `TEST_VAR=from_dotenv`,
"/client.js": `console.log(process.env.TEST_VAR);`,
},
run: {
stdout: "not inlined\nnot found",
setCwd: true,
},
});

// Test that .env files can be explicitly enabled with autoloadDotenv: true
itBundled("compile/AutoloadDotenvEnabledExplicitly", {
compile: {
Expand Down
30 changes: 30 additions & 0 deletions test/cli/lint/js-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,36 @@ test.concurrent(
timeout,
);

test.concurrent(
"the .env of the project is not in a Worker or a build that a plugin starts",
async () => {
const files = {
".env": "FROM_FILE=1\n",
"client.js": "console.log(process.env.FROM_FILE);\n",
"worker.mjs": "postMessage(String(process.env.FROM_FILE));\n",
"plugin.mjs": `const worker = new Worker(new URL("./worker.mjs", import.meta.url).href);
const fromWorker = await new Promise((resolve, reject) => {
worker.onmessage = event => resolve(event.data);
worker.onerror = event => reject(new Error(event.message));
});
await worker.terminate();
const build = await Bun.build({ entrypoints: [import.meta.dir + "/client.js"], env: "inline" });
const inlined = /console\\.log\\((.*)\\);/.exec(await build.outputs[0].text())[1];
const message = () => [fromWorker, inlined, process.env.FROM_FILE, process.env.INHERITED].map(String).join(" ");
export default { rules: { show: { create: context => ({ Program: node => context.report({ node, message: message() }) }) } } };`,
"eslint.config.mjs": `import demo from "./plugin.mjs";
export default [{ plugins: { demo }, rules: { "demo/show": "error" } }];`,
"a.js": "a;\n",
};
const { raw, exitCode } = await lint(files, ["-f", "json", "a.js"], [], { INHERITED: "1" });
expect(JSON.parse(raw)[0].messages.map((it: { message: string }) => it.message)).toEqual([
"undefined process.env.FROM_FILE undefined 1",
]);
expect(exitCode).toBe(1);
},
timeout,
);

// Reports every identifier called `foo`, with what the API has to say about it.
const noFoo = `
export default {
Expand Down
Loading
Loading