From a6adbb06b69644b7bef270eff852992330c3c49b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:40:00 +0000 Subject: [PATCH 1/2] dotenv: the .env switches of a process cover its builds, macros, HTML routes and Workers A Bun.build(), a macro VM, a Bun.serve() HTML route, the dev server and a Bun.Transpiler with a macro run Transpiler::configure_defines with default options on the env loader of the process. So they read the default .env files when the process was told not to: --no-env-file, bunfig env = false, --env-file, bun as node, an executable without dotenv autoload, and the VM of a bun lint plugin. The switch is a fact about the process. run_env_loader now records it in bun_dotenv when the options of a transpiler exclude the default files, and Loader::load reads no default file after that, for any loader. bun build --app gives its transpilers a second loader that nothing fills when the default files are excluded. It now starts as a copy of the command's loader, so a page macro gets --env-file and not .env. Nothing changes when no switch is set. --- docs/runtime/environment-variables.mdx | 2 + src/bundler/options.rs | 7 + src/bundler/transpiler.rs | 5 + src/dotenv/env_loader.rs | 13 +- src/dotenv/lib.rs | 4 +- src/runtime/bake/production.rs | 8 +- test/bake/dev/production.test.ts | 45 ++++ test/bundler/bundler_compile_autoload.test.ts | 23 ++ test/cli/lint/js-plugins.test.ts | 30 +++ test/cli/run/no-envfile.test.ts | 225 +++++++++++++++++- 10 files changed, 357 insertions(+), 5 deletions(-) diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index 4a62fb4bea7d..62a290fd7a2e 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -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. --- diff --git a/src/bundler/options.rs b/src/bundler/options.rs index cdd1c7b8c25b..1be467187c72 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2133,6 +2133,13 @@ impl Default for Env { } } +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]>, diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 3821fc09304a..2cc307309f0b 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -642,6 +642,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(); + } + match self.options.env.behavior { DotEnvBehavior::prefix | DotEnvBehavior::load_all diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index dc1e5546d9b9..d2d74bbe2954 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -162,6 +162,17 @@ static NODE_PATH_TO_USE_SET_ONCE: bun_core::RwLock>> = bun_core // PORTING.md §Concurrency: OnceLock — set once from CLI flag, read many. pub static HAS_NO_CLEAR_SCREEN_CLI_FLAG: OnceLock = 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=""` / @@ -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)?; } } diff --git a/src/dotenv/lib.rs b/src/dotenv/lib.rs index b99816e76d72..5c0b9776720e 100644 --- a/src/dotenv/lib.rs +++ b/src/dotenv/lib.rs @@ -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. diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index 9fe7bf62d261..cdf03f09896b 100644 --- a/src/runtime/bake/production.rs +++ b/src/runtime/bake/production.rs @@ -409,8 +409,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). diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 76d04d2e876a..01783993029b 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -691,4 +691,49 @@ 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. The timeout is explicit: one React build takes more than the 5s default on a debug build. + 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
{"seen:" + values()}
; +}`, + "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 }); + }, + 120_000, + ); }); diff --git a/test/bundler/bundler_compile_autoload.test.ts b/test/bundler/bundler_compile_autoload.test.ts index 6b4338c26985..da69cdbbc46b 100644 --- a/test/bundler/bundler_compile_autoload.test.ts +++ b/test/bundler/bundler_compile_autoload.test.ts @@ -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: { diff --git a/test/cli/lint/js-plugins.test.ts b/test/cli/lint/js-plugins.test.ts index 30558f08cd04..faa02e5a8dbb 100644 --- a/test/cli/lint/js-plugins.test.ts +++ b/test/cli/lint/js-plugins.test.ts @@ -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 { diff --git a/test/cli/run/no-envfile.test.ts b/test/cli/run/no-envfile.test.ts index fd55d3926492..2e75d682e550 100644 --- a/test/cli/run/no-envfile.test.ts +++ b/test/cli/run/no-envfile.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; test("--no-env-file disables .env loading", async () => { @@ -263,3 +263,226 @@ test("--no-env-file in production mode", async () => { expect(stdout.trim()).toBe("undefined"); expect(exitCode).toBe(0); }); + +// A switch that keeps the default `.env` files out of a process keeps them out of what runs inside it: a +// `Bun.build()`, a macro, a `Bun.serve()` HTML route, a child process and a Worker. +describe("the switch covers a build, a macro, an HTML route and a Worker of the process", () => { + const markers = ["key-from-dot-env", "only-from-dot-env", "key-from-custom-env"]; + // The values from the .env files that are in the text. + const seenIn = (text: string) => markers.filter(marker => text.includes(marker)); + const seen = ` + const markers = ${JSON.stringify(markers)}; + export const seen = (text: string) => markers.filter(marker => text.includes(marker)); + `; + const files = { + ".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", + "client.ts": "console.log(process.env.API_KEY, process.env.ONLY_IN_DOT_ENV);\n", + "values.ts": "export const values = () => [process.env.API_KEY, process.env.ONLY_IN_DOT_ENV].join();\n", + "use-macro.ts": 'import { values } from "./values.ts" with { type: "macro" };\nconsole.log(values());\n', + "worker.ts": "postMessage([process.env.API_KEY, process.env.ONLY_IN_DOT_ENV].join());\n", + "seen.ts": seen, + "report.ts": String.raw` + import { seen } from "./seen.ts"; + + const bundle = async (entry: string, env: any) => { + const result = await Bun.build({ entrypoints: [entry], env }); + return seen(await result.outputs[0].text()); + }; + const macro = + "import { values } from " + + JSON.stringify(import.meta.dir.replaceAll("\\", "/") + "/values.ts") + + ' with { type: "macro" };\nconsole.log(values());\n'; + const print = "console.log([process.env.API_KEY, process.env.ONLY_IN_DOT_ENV].join())"; + + export async function report() { + const inline = await bundle("./client.ts", "inline"); + const prefix = await bundle("./client.ts", "API_*"); + const buildMacro = await bundle("./use-macro.ts", "disable"); + // The async transform runs the macro in a VM of a pool thread, as a build does. + const transpilerMacro = seen(await new Bun.Transpiler({ loader: "ts" }).transform(macro)); + const child = Bun.spawn({ cmd: [process.execPath, "--no-env-file", "-e", print], stdout: "pipe" }); + const [fromChild] = await Promise.all([child.stdout.text(), child.exited]); + const worker = new Worker(new URL("./worker.ts", 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(); + return { + inline, + prefix, + buildMacro, + transpilerMacro, + child: seen(fromChild), + worker: seen(fromWorker), + // Read last: process.env gets the variables that the process has when it is first read. + own: seen([process.env.API_KEY, process.env.ONLY_IN_DOT_ENV].join()), + }; + } + `, + "main.ts": 'import { report } from "./report.ts";\nconsole.log("REPORT " + JSON.stringify(await report()));\n', + "main.test.ts": String.raw` + import { test } from "bun:test"; + import { report } from "./report.ts"; + + test("report", async () => { + console.log("REPORT " + JSON.stringify(await report())); + }); + `, + }; + + // Each place of the report, with the values from the .env files that reached it. + const everyPlace = (values: string[], prefixed = values) => ({ + inline: values, + prefix: prefixed, + buildMacro: values, + transpilerMacro: values, + child: values, + worker: values, + own: values, + }); + + type Options = { env?: Record; argv0?: string }; + async function run(cwd: string, args: string[], { env, argv0 }: Options = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + argv0, + env: { ...bunEnv, NODE_ENV: undefined, BUN_OPTIONS: undefined, ...env }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const line = stdout.split(/\r?\n/).find(line => line.startsWith("REPORT ")); + return { report: line ? JSON.parse(line.slice("REPORT ".length)) : { stdout, stderr }, exitCode }; + } + + // The tests that make the whole report run one at a time: each starts three builds, a transform, a child process and + // a Worker, and that is too much at once for a debug build. + test("with no switch, each of them has the values of .env", async () => { + using dir = tempDir("env-opt-out", files); + expect(await run(String(dir), ["main.ts"])).toEqual({ + report: everyPlace(["key-from-dot-env", "only-from-dot-env"], ["key-from-dot-env"]), + exitCode: 0, + }); + }); + + const switches: [string, string[], Options & { bunfig?: string }][] = [ + ["bun --no-env-file", ["--no-env-file", "main.ts"], {}], + ["bun run --no-env-file", ["run", "--no-env-file", "main.ts"], {}], + ["BUN_OPTIONS=--no-env-file", ["main.ts"], { env: { BUN_OPTIONS: "--no-env-file" } }], + ["bunfig env = false", ["main.ts"], { bunfig: "env = false\n" }], + ["bunfig [env] file = false", ["main.ts"], { bunfig: "[env]\nfile = false\n" }], + ["bun invoked as node", ["main.ts"], { argv0: "node" }], + ["bun test --no-env-file", ["test", "--no-env-file", "./main.test.ts"], {}], + ]; + test.each(switches)("%s: none of them has a value of .env", async (_, args, { bunfig, ...options }) => { + using dir = tempDir("env-opt-out", bunfig ? { ...files, "bunfig.toml": bunfig } : files); + expect(await run(String(dir), args, options)).toEqual({ report: everyPlace([]), exitCode: 0 }); + }); + + test("--env-file: each of them has the values of that file only", async () => { + using dir = tempDir("env-opt-out", files); + expect(await run(String(dir), ["--env-file=custom.env", "main.ts"])).toEqual({ + report: everyPlace(["key-from-custom-env"]), + exitCode: 0, + }); + }); + + // `bun build` boots no VM. The VM of its macro gets the options of the command. What that macro starts does not: + // here a transform with a macro of its own, which runs in one more VM. + const buildFiles = { + ".env": files[".env"], + "custom.env": files["custom.env"], + "values.ts": files["values.ts"], + "nested.ts": String.raw` + export async function nested() { + const code = + "import { values } from " + + JSON.stringify(import.meta.dir.replaceAll("\\", "/") + "/values.ts") + + ' with { type: "macro" };\nconsole.log(values());\n'; + return await new Bun.Transpiler({ loader: "ts" }).transform(code); + } + `, + "entry.ts": String.raw` + import { nested } from "./nested.ts" with { type: "macro" }; + import { values } from "./values.ts" with { type: "macro" }; + + console.log("direct", values()); + console.log("nested", nested()); + `, + }; + const builds: [string, string[], string | undefined, string[]][] = [ + ["bun build", [], undefined, ["key-from-dot-env", "only-from-dot-env"]], + ["bun build --no-env-file", ["--no-env-file"], undefined, []], + ["bun build with bunfig env = false", [], "env = false\n", []], + ["bun build --env-file", ["--env-file=custom.env"], undefined, ["key-from-custom-env"]], + ]; + test.concurrent.each(builds)("%s: a macro and what it starts", async (_, flags, bunfig, values) => { + using dir = tempDir("env-opt-out", bunfig ? { ...buildFiles, "bunfig.toml": bunfig } : buildFiles); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", ...flags, "./entry.ts"], + env: { ...bunEnv, NODE_ENV: undefined, BUN_OPTIONS: undefined }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = stdout.split(/\r?\n/); + // The values that the call of the macro became in the bundle, or null if the bundle has no such line. + const macro = (name: string) => { + const line = lines.find(line => line.startsWith(`console.log("${name}"`)); + return line === undefined ? null : seenIn(line); + }; + expect({ direct: macro("direct"), nested: macro("nested"), stderr, exitCode }).toEqual({ + direct: values, + nested: values, + stderr: "", + exitCode: 0, + }); + }); + + const htmlRoutes: [string, string[], boolean, string[]][] = [ + ["no switch, development: false", [], false, ["key-from-dot-env", "only-from-dot-env"]], + ["no switch, development: true", [], true, ["key-from-dot-env", "only-from-dot-env"]], + ["--no-env-file, development: false", ["--no-env-file"], false, []], + ["--no-env-file, development: true", ["--no-env-file"], true, []], + ]; + test.concurrent.each(htmlRoutes)( + "%s: an HTML route with [serve.static] env = 'inline'", + async (_, flags, development, values) => { + using dir = tempDir("env-opt-out", { + ".env": files[".env"], + "bunfig.toml": '[serve.static]\nenv = "inline"\n', + "client.ts": files["client.ts"], + "index.html": '\n', + "seen.ts": seen, + "main.ts": String.raw` + import index from "./index.html"; + import { seen } from "./seen.ts"; + + const server = Bun.serve({ + port: 0, + development: process.argv.includes("--development"), + routes: { "/": index }, + fetch: () => new Response("not found", { status: 404 }), + }); + const html = await (await fetch(server.url)).text(); + const scripts = [...html.matchAll(/src="([^"]+)"/g)].map(match => match[1]); + const route: string[] = []; + for (const src of scripts) { + const response = await fetch(new URL(src, server.url)); + if (!response.ok) throw new Error(src + " is " + response.status); + route.push(...seen(await response.text())); + } + const own = seen([process.env.API_KEY, process.env.ONLY_IN_DOT_ENV].join()); + console.log("REPORT " + JSON.stringify({ scripts: scripts.length, route, own })); + await server.stop(true); + `, + }); + const args = [...flags, "main.ts", ...(development ? ["--development"] : [])]; + expect(await run(String(dir), args)).toEqual({ report: { scripts: 1, route: values, own: values }, exitCode: 0 }); + }, + ); +}); From cd69d75fa6b5c97df31311ba890f68ed2377aad4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:09:31 +0000 Subject: [PATCH 2/2] test --parallel: a worker's own skip of the env files is not a switch A `bun test --parallel` worker reads no env file when it starts, because the coordinator read them. It said so with `disable_default_env_files`, which `run_env_loader` now takes for the switch of the process. So with no switch, a `Bun.build()` in a worker stopped reading a default `.env`. The skip is now `env_files_loaded_by_parent`, which only `configure_defines` reads. The coordinator forwards `--no-env-file` to the workers when its own options exclude the default files. Tests: `bun test` and `bun test --parallel` rows with and without a switch. The fixtures no longer depend on `API_KEY` being unset in the environment of the test run, and the `bun build --app` rows have no timeout of their own. --- src/bundler/options.rs | 5 ++ src/bundler/transpiler.rs | 4 +- src/options_types/schema.rs | 2 + src/runtime/cli/test/parallel/runner.rs | 13 ++++- src/runtime/cli/test_command.rs | 2 +- test/bake/dev/production.test.ts | 66 ++++++++++++------------- test/cli/run/no-envfile.test.ts | 55 ++++++++++++++++++++- 7 files changed, 106 insertions(+), 41 deletions(-) diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 8e142a600110..0cdffaf3cfd7 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1848,6 +1848,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 @@ -2106,6 +2107,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 { @@ -2115,6 +2119,7 @@ impl Default for Env { prefix: Box::default(), files: Box::default(), disable_default_env_files: false, + loaded_by_parent: false, } } } diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 92bfd7a5a72c..cb72a4f19fe2 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -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(); diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index f07fc96205a4..9d9ae5cd521a 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -95,6 +95,8 @@ pub mod api { pub env_files: Vec>, /// 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>, /// no_summary diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 0272a3d8a68b..49f1d0793d31 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -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 @@ -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> { +fn build_worker_argv( + ctx: &Command::ContextData, + loads_default_env_files: bool, +) -> crate::Result> { // Null-sentinel slice of C-string pointers. String storage routes through // the process-lifetime CLI arena (bulk-freed on exit). let mut argv: Vec = Vec::new(); @@ -380,6 +385,10 @@ fn build_worker_argv(ctx: &Command::ContextData) -> crate::Result {} bun_core::CodeGenerationFromStrings::DisallowedLikeNode => { diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 17bbca36c5d1..65bcb2d3f71d 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -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(); diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 2cf1f1315908..864204dbb306 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -782,47 +782,43 @@ export default function IndexPage() { }); // 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. The timeout is explicit: one React build takes more than the 5s default on a debug build. + // 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" }; + ])("%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
{"seen:" + values()}
; }`, - "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 }); - }, - 120_000, - ); + "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 }); + }); }); diff --git a/test/cli/run/no-envfile.test.ts b/test/cli/run/no-envfile.test.ts index 2e75d682e550..b1cfbbf7ff27 100644 --- a/test/cli/run/no-envfile.test.ts +++ b/test/cli/run/no-envfile.test.ts @@ -343,12 +343,21 @@ describe("the switch covers a build, a macro, an HTML route and a Worker of the own: values, }); + // The variables of the fixtures come from a `.env` file only, whatever the environment of this test run has. + const cleanEnv = { + ...bunEnv, + NODE_ENV: undefined, + BUN_OPTIONS: undefined, + API_KEY: undefined, + ONLY_IN_DOT_ENV: undefined, + }; + type Options = { env?: Record; argv0?: string }; async function run(cwd: string, args: string[], { env, argv0 }: Options = {}) { await using proc = Bun.spawn({ cmd: [bunExe(), ...args], argv0, - env: { ...bunEnv, NODE_ENV: undefined, BUN_OPTIONS: undefined, ...env }, + env: { ...cleanEnv, ...env }, cwd, stdout: "pipe", stderr: "pipe", @@ -390,6 +399,48 @@ describe("the switch covers a build, a macro, an HTML route and a Worker of the }); }); + // A `bun test --parallel` worker reads no env file when it starts: it has the environment of the coordinator. That + // is not a switch. What a test builds there reads a default `.env` as under `bun test`, unless the command has one. + const testFile = String.raw` + import { test } from "bun:test"; + import { seen } from "./seen.ts"; + + test("a build in another directory", async () => { + process.chdir(import.meta.dir + "/pkg"); + const result = await Bun.build({ entrypoints: ["./client.ts"], env: "inline" }); + console.log("REPORT " + JSON.stringify(seen(await result.outputs[0].text()))); + }); + `; + const testRuns: [string, string[], string[]][] = [ + ["bun test", ["test"], ["key-from-dot-env", "only-from-dot-env"]], + ["bun test --parallel", ["test", "--parallel=2"], ["key-from-dot-env", "only-from-dot-env"]], + ["bun test --parallel --no-env-file", ["test", "--parallel=2", "--no-env-file"], []], + ["bun test --parallel --env-file", ["test", "--parallel=2", "--env-file=custom.env"], ["key-from-custom-env"]], + ]; + test.concurrent.each(testRuns)("%s: a build in a directory with a .env of its own", async (_, args, values) => { + using dir = tempDir("env-opt-out", { + "custom.env": files["custom.env"], + "seen.ts": seen, + "pkg/.env": files[".env"], + "pkg/client.ts": files["client.ts"], + "a.test.ts": testFile, + "b.test.ts": testFile, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { ...cleanEnv, BUN_TEST_PARALLEL_SCALE_MS: "0" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const reports = [...(stdout + stderr).matchAll(/^REPORT (.*)$/gm)].map(match => JSON.parse(match[1])); + expect({ reports: reports.length === 2 ? reports : { stdout, stderr }, exitCode }).toEqual({ + reports: [values, values], + exitCode: 0, + }); + }); + // `bun build` boots no VM. The VM of its macro gets the options of the command. What that macro starts does not: // here a transform with a macro of its own, which runs in one more VM. const buildFiles = { @@ -423,7 +474,7 @@ describe("the switch covers a build, a macro, an HTML route and a Worker of the using dir = tempDir("env-opt-out", bunfig ? { ...buildFiles, "bunfig.toml": bunfig } : buildFiles); await using proc = Bun.spawn({ cmd: [bunExe(), "build", ...flags, "./entry.ts"], - env: { ...bunEnv, NODE_ENV: undefined, BUN_OPTIONS: undefined }, + env: cleanEnv, cwd: String(dir), stdout: "pipe", stderr: "pipe",