From 5465fcd63d5b5de89b475a68aa850586c9ed0618 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Wed, 14 Oct 2026 15:05:20 -0400 Subject: [PATCH] feat(apps): load .env files for local backend function credentials Local backend functions read the dev server's process.env, so the dev server now loads the project's .env files (via Vite's loadEnv) into process.env, with shell variables taking precedence. - Refuse keys that change Node or process behavior (NODE_*, LD_*, DYLD_*, proxy, CA and OpenSSL settings, PATH, home and temp directories, shell and Windows system paths, libuv and resolver settings), with a warning. - Load after every plugin's configureServer, track values per project root, and drop a root's values when its last dev server closes or its config is replaced on restart; a failed restart keeps the old values. - Hand a shared key to another running root that defines it, and warn when one root's value is shadowed by another's. - Skip loading under Vitest and in test mode, and honor envFile: false. - Backend builds keep envFile: false and envPrefix: [], so .env values are never inlined into bundles. Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/README.md | 26 + .../apps/src/vite/build-config.test.ts | 69 +- .../src/vite/dev-server.integration.test.ts | 10 +- .../apps/src/vite/dotenv-credentials.test.ts | 522 +++++++++++++ .../apps/src/vite/dotenv-credentials.ts | 235 ++++++ packages/plugins/apps/src/vite/index.test.ts | 687 +++++++++++++++++- packages/plugins/apps/src/vite/index.ts | 71 +- 7 files changed, 1602 insertions(+), 18 deletions(-) create mode 100644 packages/plugins/apps/src/vite/dotenv-credentials.test.ts create mode 100644 packages/plugins/apps/src/vite/dotenv-credentials.ts diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 399ee3f09..d8e9e6d41 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -10,6 +10,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned - [Configuration](#configuration) - [Development server authentication](#development-server-authentication) +- [Custom Credentials for local execution](#custom-credentials-for-local-execution) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -45,6 +46,31 @@ passes it to the dev server via `DD_OAUTH_ACCESS_TOKEN`. When no credentials are configured, backend function execution is unavailable and the dev server tells you to start it with `datadog-apps dev`. +## Custom Credentials for local execution + +During `vite dev`, backend functions read Custom Credentials from `process.env`, under the same +names the deployed app uses. Set them in the shell that starts the dev server, or in Vite's +`.env`, `.env.local`, `.env.[mode]` and `.env.[mode].local` files. A shell variable takes +precedence over a file. + +- Don't give a credential Vite's `envPrefix` (`VITE_` by default): Vite exposes those keys to + the frontend, so they are not loaded for backend functions. +- `DD_*` and `DATADOG_*` keys in these files are ignored. Set the authentication variables above + in the shell, or start the dev server with `datadog-apps dev`. +- Keys that change Node or process behavior are ignored too, and left to the shell: `NODE_*`, + `LD_*`, `DYLD_*`, `OPENSSL_*`, proxy, DNS resolver and CA certificate settings, `TZ`, `PATH`, + `PATHEXT`, `COMSPEC`, `SystemRoot`, `windir`, `ESBUILD_BINARY_PATH`, `UV_THREADPOOL_SIZE`, + `UV_USE_IO_URING`, home and temp directories. +- `dev:verify`, which runs backend functions in the cloud, doesn't load these files, and neither + does a Vitest run or a dev server in `test` mode. +- After a dev server restart, `vite.config`, other plugins' `config` and `configureServer` hooks + and `${VAR}` references in `envPrefix` keys still see the values loaded before the restart. +- Don't pass `process.env` to Vite's `define`, as in `define: { 'process.env': process.env }`: + it would inline the loaded values, backend-only credentials included, into the frontend + bundle, notably after a restart, while the previous load is still in `process.env`. +- Keep files that hold real secrets, such as `.env.local`, out of version control. +- Values from these files are never bundled into the deployed backend functions. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/src/vite/build-config.test.ts b/packages/plugins/apps/src/vite/build-config.test.ts index 947584f98..c7702007d 100644 --- a/packages/plugins/apps/src/vite/build-config.test.ts +++ b/packages/plugins/apps/src/vite/build-config.test.ts @@ -7,9 +7,12 @@ import { outputFileSync, rmSync } from '@dd/core/helpers/fs'; import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env'; -import { build } from 'vite'; +import { build, loadEnv } from 'vite'; import { getBaseBackendBuildConfig } from './build-config'; +import { dropEnvFileCredentials, loadEnvFileCredentials } from './dotenv-credentials'; + +const isAlwaysActive = () => true; describe('getBaseBackendBuildConfig', () => { test('bundles a backend function that imports a real Node builtin module with a working import, not a browser-external stub', async () => { @@ -242,4 +245,68 @@ describe('getBaseBackendBuildConfig', () => { rmSync(workingDir); } }); + + test('Should keep a .env credential the dev server copied into process.env as a runtime read', async () => { + const seed = `build-config-dotenv-loaded-${Date.now()}`; + const workingDir = getTempWorkingDir(seed); + const unprefixedValue = 'sk_unprefixed_should_stay_a_runtime_read'; + + try { + outputFileSync(`${workingDir}/.env`, `QA_TEST_LOADED_SECRET=${unprefixedValue}\n`); + loadEnvFileCredentials( + loadEnv, + { + root: workingDir, + mode: 'development', + envDir: workingDir, + }, + isAlwaysActive, + ); + expect(process.env.QA_TEST_LOADED_SECRET).toBe(unprefixedValue); + + const absolutePath = `${workingDir}/src/readsLoadedEnv.backend.ts`; + outputFileSync( + absolutePath, + ` + export async function readsLoadedEnv() { + return process.env.QA_TEST_LOADED_SECRET; + } + `, + ); + + const virtualId = 'virtual:dd-backend-test:readsLoadedEnv'; + const virtualContent = `import { readsLoadedEnv } from ${JSON.stringify(absolutePath)};\nexport async function main($) { return await readsLoadedEnv(); }`; + const baseConfig = getBaseBackendBuildConfig( + workingDir, + { [virtualId]: virtualContent }, + [], + ); + + const result = await build({ + ...baseConfig, + build: { + ...baseConfig.build, + write: false, + rollupOptions: { + ...baseConfig.build.rollupOptions, + input: virtualId, + output: baseConfig.build.rollupOptions.output, + }, + }, + }); + + const output = Array.isArray(result) ? result[0] : result; + if (!('output' in output)) { + throw new Error('Unexpected vite.build result'); + } + const chunk = output.output[0]; + const code = chunk.type === 'chunk' ? chunk.code : ''; + + expect(code).toContain('process.env.QA_TEST_LOADED_SECRET'); + expect(code).not.toContain(unprefixedValue); + } finally { + dropEnvFileCredentials(); + rmSync(workingDir); + } + }); }); diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index fc3ba987a..6617558af 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -28,7 +28,7 @@ import nock from 'nock'; import os from 'os'; import path from 'path'; import { parseAst } from 'rollup/parseAst'; -import { build, createServer, type Plugin, type ViteDevServer } from 'vite'; +import { build, createServer, loadEnv, type Plugin, type ViteDevServer } from 'vite'; import { extractConnectionIdsFromModuleGraph } from '../backend/ast-parsing/extract-connection-ids-from-module-graph'; import { encodeQueryName } from '../backend/encodeQueryName'; @@ -150,7 +150,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const appsPlugin: Plugin = { name: 'dd-apps-test', ...getVitePlugin({ - bundler: { build }, + bundler: { build, loadEnv }, context: getContextMock({ buildRoot: FIXTURE_ROOT }), options: { include: [], @@ -510,7 +510,7 @@ describe('Dev Server Middleware — editing files between local executions', () const appsPlugin: Plugin = { name: 'dd-apps-test', ...getVitePlugin({ - bundler: { build }, + bundler: { build, loadEnv }, context, options: { include: [], longPolling: mockLongPolling }, }), @@ -794,7 +794,7 @@ describe('Dev Server Middleware — SSR import warmup warnings', () => { const appsPlugin: Plugin = { name: 'dd-apps-test', ...getVitePlugin({ - bundler: { build }, + bundler: { build, loadEnv }, context, options: { include: [], longPolling: mockLongPolling }, }), @@ -892,7 +892,7 @@ describe('Dev Server Middleware — dynamic imports inside a backend function', const appsPlugin: Plugin = { name: 'dd-apps-test', ...getVitePlugin({ - bundler: { build }, + bundler: { build, loadEnv }, context: getContextMock({ buildRoot: root }), options: { include: [], longPolling: mockLongPolling }, }), diff --git a/packages/plugins/apps/src/vite/dotenv-credentials.test.ts b/packages/plugins/apps/src/vite/dotenv-credentials.test.ts new file mode 100644 index 000000000..e712a2727 --- /dev/null +++ b/packages/plugins/apps/src/vite/dotenv-credentials.test.ts @@ -0,0 +1,522 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { + dropEnvFileCredentials, + loadEnvFileCredentials, + PROCESS_SETTING_KEYS, +} from '@dd/apps-plugin/vite/dotenv-credentials'; +import { outputFileSync, rmSync } from '@dd/core/helpers/fs'; +import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env'; +import os from 'os'; +import path from 'path'; +import { loadEnv } from 'vite'; + +const KEYS = [ + 'QA_DOTENV_STRIPE_KEY', + 'QA_DOTENV_OTHER_KEY', + 'VITE_QA_DOTENV_PUBLIC', + 'PUBLIC_QA_DOTENV', + 'B_QA_DOTENV', + 'DD_QA_DOTENV_SITE', + 'DATADOG_QA_DOTENV_KEY', + 'dd_qa_dotenv_lower', +]; + +// One sample per rule, skipping any the shell running the tests already sets. +const PROCESS_SETTING_SAMPLES = [ + ...PROCESS_SETTING_KEYS.prefixes.flatMap((prefix) => [ + `${prefix}QA_DOTENV`, + `${prefix.toLowerCase()}qa_dotenv`, + ]), + ...PROCESS_SETTING_KEYS.names.flatMap((name) => [name, name.toLowerCase()]), + ...PROCESS_SETTING_KEYS.infixes.map((infix) => `QA${infix}DOTENV`), + ...PROCESS_SETTING_KEYS.suffixes.map((suffix) => `qa_dotenv${suffix.toLowerCase()}`), +].filter((key) => !(key in process.env)); +const LOOKALIKE_KEYS = [ + 'QA_DOTENV_CACHE_DIR', + 'QA_DOTENV_PATHS', + 'QA_DOTENV_TZ_NAME', + 'QA_DOTENV_NODE', +]; + +const isAlwaysActive = () => true; + +describe('Apps Plugin - loadEnvFileCredentials', () => { + let envDir: string; + let otherRoots: string[] = []; + let seedCount = 0; + + const writeEnvFile = (name: string, contents: string) => { + const envFilePath = path.join(envDir, name); + outputFileSync(envFilePath, contents); + }; + const load = (envPrefix?: string | string[]) => + loadEnvFileCredentials( + loadEnv, + { root: envDir, mode: 'development', envDir, envPrefix }, + isAlwaysActive, + ); + const createOtherRoot = (name: string, envContents: string) => { + const startedAt = Date.now(); + const root = getTempWorkingDir(`dd-apps-dotenv-${name}-${startedAt}-${seedCount}`); + otherRoots.push(root); + const envFilePath = path.join(root, '.env'); + outputFileSync(envFilePath, envContents); + const loadOtherRoot = () => + loadEnvFileCredentials( + loadEnv, + { root, mode: 'development', envDir: root }, + isAlwaysActive, + ); + return { envFilePath, load: loadOtherRoot }; + }; + + beforeEach(() => { + seedCount += 1; + const startedAt = Date.now(); + envDir = getTempWorkingDir(`dd-apps-dotenv-${startedAt}-${seedCount}`); + }); + + afterEach(() => { + dropEnvFileCredentials(); + for (const key of [...KEYS, ...PROCESS_SETTING_SAMPLES, ...LOOKALIKE_KEYS]) { + delete process.env[key]; + } + rmSync(envDir); + for (const root of otherRoots) { + rmSync(root); + } + otherRoots = []; + }); + + test('Should copy an unprefixed .env value into process.env and return its name', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + + const result = load(); + + expect(result).toEqual({ + loaded: ['QA_DOTENV_STRIPE_KEY'], + ignoredDatadogKeys: [], + ignoredProcessKeys: [], + shadowedKeys: [], + drop: expect.any(Function), + }); + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('from-dotenv'); + }); + + test('Should let a shell variable take precedence over the .env value', () => { + process.env.QA_DOTENV_STRIPE_KEY = 'from-shell'; + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + + const result = load(); + + expect(result.loaded).toEqual([]); + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('from-shell'); + }); + + test.each([ + { files: { '.env.local': 'QA_DOTENV_STRIPE_KEY=local' }, expected: 'local' }, + { files: { '.env.development': 'QA_DOTENV_STRIPE_KEY=mode' }, expected: 'mode' }, + { + files: { + '.env.development': 'QA_DOTENV_STRIPE_KEY=mode', + '.env.development.local': 'QA_DOTENV_STRIPE_KEY=mode-local', + }, + expected: 'mode-local', + }, + ])('Should follow Vite env file precedence ($expected)', ({ files, expected }) => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=base\n'); + for (const [name, contents] of Object.entries(files)) { + writeEnvFile(name, `${contents}\n`); + } + + load(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe(expected); + }); + + // Vite's own loadEnv prefers process.env over the file, so a copied public key would go stale. + test.each([ + { envPrefix: undefined, key: 'VITE_QA_DOTENV_PUBLIC' }, + { envPrefix: 'PUBLIC_', key: 'PUBLIC_QA_DOTENV' }, + { envPrefix: ['A_', 'B_'], key: 'B_QA_DOTENV' }, + ])('Should leave a key with Vite envPrefix $envPrefix to Vite', ({ envPrefix, key }) => { + writeEnvFile('.env', `${key}=public\nQA_DOTENV_STRIPE_KEY=from-dotenv\n`); + + const result = load(envPrefix); + + expect(result.loaded).toEqual(['QA_DOTENV_STRIPE_KEY']); + expect(process.env).not.toHaveProperty(key); + }); + + test('Should ignore Datadog settings in .env files and report their names', () => { + writeEnvFile( + '.env', + 'DD_QA_DOTENV_SITE=datadoghq.eu\nDATADOG_QA_DOTENV_KEY=from-dotenv\nQA_DOTENV_STRIPE_KEY=from-dotenv\n', + ); + + const result = load(); + + expect(result).toEqual({ + loaded: ['QA_DOTENV_STRIPE_KEY'], + ignoredDatadogKeys: ['DD_QA_DOTENV_SITE', 'DATADOG_QA_DOTENV_KEY'], + ignoredProcessKeys: [], + shadowedKeys: [], + drop: expect.any(Function), + }); + expect(process.env).not.toHaveProperty('DD_QA_DOTENV_SITE'); + expect(process.env).not.toHaveProperty('DATADOG_QA_DOTENV_KEY'); + }); + + // process.env is case-insensitive on Windows, so dd_api_key would be read back as DD_API_KEY. + test('Should ignore Datadog settings whatever their case', () => { + writeEnvFile('.env', 'dd_qa_dotenv_lower=from-dotenv\n'); + + const result = load(); + + expect(result).toEqual({ + loaded: [], + ignoredDatadogKeys: ['dd_qa_dotenv_lower'], + ignoredProcessKeys: [], + shadowedKeys: [], + drop: expect.any(Function), + }); + expect(process.env).not.toHaveProperty('dd_qa_dotenv_lower'); + }); + + test('Should ignore NODE_TLS_REJECT_UNAUTHORIZED, which would turn off TLS checks for the whole process', () => { + const shellValue = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + writeEnvFile('.env', 'NODE_TLS_REJECT_UNAUTHORIZED=0\nQA_DOTENV_STRIPE_KEY=from-dotenv\n'); + + try { + const result = load(); + + expect(result).toEqual({ + loaded: ['QA_DOTENV_STRIPE_KEY'], + ignoredDatadogKeys: [], + ignoredProcessKeys: ['NODE_TLS_REJECT_UNAUTHORIZED'], + shadowedKeys: [], + drop: expect.any(Function), + }); + expect(process.env).not.toHaveProperty('NODE_TLS_REJECT_UNAUTHORIZED'); + } finally { + if (shellValue !== undefined) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = shellValue; + } + } + }); + + test('Should ignore every key that changes Node or process behavior, whatever its case', () => { + const contents = PROCESS_SETTING_SAMPLES.map((key) => `${key}=from-dotenv`).join('\n'); + writeEnvFile('.env', `${contents}\nQA_DOTENV_STRIPE_KEY=from-dotenv\n`); + + const result = load(); + const leakedKeys = PROCESS_SETTING_SAMPLES.filter((key) => key in process.env); + + expect(result.loaded).toEqual(['QA_DOTENV_STRIPE_KEY']); + expect(result.ignoredProcessKeys).toEqual(PROCESS_SETTING_SAMPLES); + expect(leakedKeys).toEqual([]); + }); + + // os.tmpdir() falls back to TMP and TEMP when TMPDIR is unset, as it usually is on Linux. + test('Should leave os.tmpdir() to the shell when TMPDIR is unset', () => { + const shellTmpDir = process.env.TMPDIR; + const shellTmp = process.env.TMP; + const shellTemp = process.env.TEMP; + delete process.env.TMPDIR; + delete process.env.TMP; + delete process.env.TEMP; + const defaultTmpDir = os.tmpdir(); + writeEnvFile('.env', 'TMP=/qa-dotenv-tmp\nTEMP=/qa-dotenv-temp\n'); + + try { + const result = load(); + const tmpDirAfterLoad = os.tmpdir(); + + expect(result.ignoredProcessKeys).toEqual(['TMP', 'TEMP']); + expect(tmpDirAfterLoad).toBe(defaultTmpDir); + } finally { + const shellValues = { TMPDIR: shellTmpDir, TMP: shellTmp, TEMP: shellTemp }; + for (const [key, value] of Object.entries(shellValues)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + }); + + test.each(LOOKALIKE_KEYS)('Should load %s, which only resembles a process setting', (key) => { + writeEnvFile('.env', `${key}=from-dotenv\n`); + + const result = load(); + + expect(result.loaded).toEqual([key]); + expect(process.env[key]).toBe('from-dotenv'); + }); + + test('Should pick up edited and removed .env values when it runs again after a server restart', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=first\nQA_DOTENV_OTHER_KEY=removed-later\n'); + load(); + + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=second\n'); + const result = load(); + + expect(result.loaded).toEqual(['QA_DOTENV_STRIPE_KEY']); + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('second'); + expect(process.env).not.toHaveProperty('QA_DOTENV_OTHER_KEY'); + }); + + // Vite bundles a config's non-node_modules imports, so a restart can load a second copy. + test('Should pick up an edited .env value when a restart loads a separate copy of this module', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=first\n'); + load(); + + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=second\n'); + jest.isolateModules(() => { + const copy: typeof import('./dotenv-credentials') = require('./dotenv-credentials'); + copy.loadEnvFileCredentials( + loadEnv, + { root: envDir, mode: 'development', envDir }, + isAlwaysActive, + ); + }); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('second'); + }); + + test('Should keep a value that something else set after the .env file was loaded', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + load(); + process.env.QA_DOTENV_STRIPE_KEY = 'set-later'; + + load(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('set-later'); + }); + + test('Should drop values from the previous load and keep shell variables', () => { + process.env.QA_DOTENV_OTHER_KEY = 'from-shell'; + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\nQA_DOTENV_OTHER_KEY=from-dotenv\n'); + load(); + + dropEnvFileCredentials(); + + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + expect(process.env.QA_DOTENV_OTHER_KEY).toBe('from-shell'); + }); + + test('Should load nothing and drop earlier values when env files are disabled', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + load(); + + const result = loadEnvFileCredentials( + loadEnv, + { + root: envDir, + mode: 'development', + envDir: false, + }, + isAlwaysActive, + ); + + expect(result).toEqual({ + loaded: [], + ignoredDatadogKeys: [], + ignoredProcessKeys: [], + shadowedKeys: [], + drop: expect.any(Function), + }); + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + }); + + test('Should drop only the values loaded for the same project root', () => { + const otherRoot = createOtherRoot('other', 'QA_DOTENV_OTHER_KEY=other-root\n'); + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=first\n'); + + load(); + otherRoot.load(); + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=second\n'); + load(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('second'); + expect(process.env.QA_DOTENV_OTHER_KEY).toBe('other-root'); + + outputFileSync(otherRoot.envFilePath, 'QA_DOTENV_OTHER_KEY=other-root-edited\n'); + otherRoot.load(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('second'); + expect(process.env.QA_DOTENV_OTHER_KEY).toBe('other-root-edited'); + }); + + test("Should keep another root's values when one load is dropped", () => { + const otherRoot = createOtherRoot('drop-other', 'QA_DOTENV_OTHER_KEY=other-root\n'); + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + + const result = load(); + otherRoot.load(); + result.drop(); + + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + expect(process.env.QA_DOTENV_OTHER_KEY).toBe('other-root'); + }); + + const sharedKeyCases = [ + { + description: 'its own value', + otherValue: 'second', + shadowedKeys: ['QA_DOTENV_STRIPE_KEY'], + }, + { description: 'the same value', otherValue: 'first', shadowedKeys: [] }, + ]; + test.each(sharedKeyCases)( + 'Should hand a key to another open root that defines it with $description once the owning root ends', + ({ otherValue, shadowedKeys }) => { + const otherRoot = createOtherRoot('shared', `QA_DOTENV_STRIPE_KEY=${otherValue}\n`); + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=first\n'); + + const ownerResult = load(); + const otherResult = otherRoot.load(); + const whileOwnerOpen = process.env.QA_DOTENV_STRIPE_KEY; + ownerResult.drop(); + const afterOwnerDropped = process.env.QA_DOTENV_STRIPE_KEY; + otherResult.drop(); + + expect(otherResult.loaded).toEqual([]); + expect(otherResult.shadowedKeys).toEqual(shadowedKeys); + expect(whileOwnerOpen).toBe('first'); + expect(afterOwnerDropped).toBe(otherValue); + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + }, + ); + + test("Should hand a key to another open root when the owning root's .env no longer defines it", () => { + const otherRoot = createOtherRoot('shared-removed', 'QA_DOTENV_STRIPE_KEY=second\n'); + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=first\n'); + load(); + otherRoot.load(); + + writeEnvFile('.env', 'QA_DOTENV_OTHER_KEY=unrelated\n'); + load(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('second'); + }); + + test('Should keep a value that something else set instead of handing it to another root', () => { + const otherRoot = createOtherRoot('shared-set-later', 'QA_DOTENV_STRIPE_KEY=second\n'); + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=first\n'); + const ownerResult = load(); + const otherResult = otherRoot.load(); + process.env.QA_DOTENV_STRIPE_KEY = 'set-later'; + + ownerResult.drop(); + const afterOwnerDropped = process.env.QA_DOTENV_STRIPE_KEY; + otherResult.drop(); + + expect(afterOwnerDropped).toBe('set-later'); + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('set-later'); + }); + + test("Should drop a root's values once its other loads report they ended", () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + let isEarlierLoadActive = true; + const config = { root: envDir, mode: 'development', envDir }; + loadEnvFileCredentials(loadEnv, config, () => isEarlierLoadActive); + const laterResult = load(); + + isEarlierLoadActive = false; + laterResult.drop(); + + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + }); + + test('Should keep a later load for the same root when an earlier load is dropped', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=unchanged\n'); + const earlierResult = load(); + load(); + + earlierResult.drop(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('unchanged'); + }); + + test("Should keep a root's values until every load for it is dropped", () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + const earlierResult = load(); + const laterResult = load(); + + laterResult.drop(); + const keptWhileEarlierLoadOpen = process.env.QA_DOTENV_STRIPE_KEY; + earlierResult.drop(); + + expect(keptWhileEarlierLoadOpen).toBe('from-dotenv'); + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + }); + + test('Should drop the next load for the root when an earlier load failed to read the files', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + const failingLoadEnv: typeof loadEnv = () => { + throw new Error('unreadable .env file'); + }; + const failingLoad = () => + loadEnvFileCredentials( + failingLoadEnv, + { root: envDir, mode: 'development', envDir }, + isAlwaysActive, + ); + + expect(failingLoad).toThrow('unreadable .env file'); + const result = load(); + result.drop(); + + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + }); + + test("Should keep the previous load's values when reading the files fails", () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + const result = load(); + const failingLoadEnv: typeof loadEnv = () => { + throw new Error('unreadable .env file'); + }; + const failingLoad = () => + loadEnvFileCredentials( + failingLoadEnv, + { root: envDir, mode: 'development', envDir }, + isAlwaysActive, + ); + + expect(failingLoad).toThrow('unreadable .env file'); + const keptAfterFailure = process.env.QA_DOTENV_STRIPE_KEY; + result.drop(); + + expect(keptAfterFailure).toBe('from-dotenv'); + expect(process.env).not.toHaveProperty('QA_DOTENV_STRIPE_KEY'); + }); + + test("Should expand a root's variable references with its own values, not another root's", () => { + const otherRoot = createOtherRoot('expand', 'QA_DOTENV_OTHER_KEY=other-root\n'); + writeEnvFile( + '.env', + `QA_DOTENV_OTHER_KEY=own\nQA_DOTENV_STRIPE_KEY=https://\${QA_DOTENV_OTHER_KEY}/api\n`, + ); + + otherRoot.load(); + load(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('https://own/api'); + }); + + test('Should keep a value that something else set when its load is dropped', () => { + writeEnvFile('.env', 'QA_DOTENV_STRIPE_KEY=from-dotenv\n'); + const result = load(); + process.env.QA_DOTENV_STRIPE_KEY = 'set-later'; + + result.drop(); + + expect(process.env.QA_DOTENV_STRIPE_KEY).toBe('set-later'); + }); +}); diff --git a/packages/plugins/apps/src/vite/dotenv-credentials.ts b/packages/plugins/apps/src/vite/dotenv-credentials.ts new file mode 100644 index 000000000..e34be76ab --- /dev/null +++ b/packages/plugins/apps/src/vite/dotenv-credentials.ts @@ -0,0 +1,235 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +import type { loadEnv, ResolvedConfig } from 'vite'; + +// On globalThis so it survives dev server restarts, which can load a fresh copy of this module, +// and a value removed from or edited in a .env file is not mistaken for a shell variable. Versioned, +// as another copy of the plugin in the process can store a different shape. +const LOADS_BY_ROOT_KEY = Symbol.for('@datadog/vite-plugin/apps/env-file-loads-by-root/v1'); + +// The plugin reads its own settings before configureServer and under alias names, so a file +// value would apply inconsistently and could override the shell. +const DATADOG_PREFIXES = ['DD_', 'DATADOG_']; + +// Node, TLS, proxy, DNS, shell, binary-path and dynamic-loader settings apply to the whole dev +// server process, including the plugin's own requests to Datadog, so they are left to the shell. +// Matched case-insensitively, as Windows env names are. +export const PROCESS_SETTING_KEYS = { + prefixes: ['NODE_', 'LD_', 'DYLD_', 'OPENSSL_'], + names: [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'RES_OPTIONS', + 'LOCALDOMAIN', + 'HOSTALIASES', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'TZ', + 'PATH', + 'PATHEXT', + 'HOME', + 'USERPROFILE', + 'TMPDIR', + 'TMP', + 'TEMP', + 'SYSTEMROOT', + 'WINDIR', + 'COMSPEC', + 'ESBUILD_BINARY_PATH', + 'UV_THREADPOOL_SIZE', + 'UV_USE_IO_URING', + ], + infixes: ['_CA_'], + suffixes: ['_CA'], +}; + +export type EnvFileConfig = Pick; + +export type EnvFileCredentials = { + loaded: string[]; + ignoredDatadogKeys: string[]; + ignoredProcessKeys: string[]; + /** Keys another project root loaded first with a different value. */ + shadowedKeys: string[]; + /** Ends this load. The root's values are removed once every load for that root has ended. */ + drop: () => void; +}; + +type RootLoad = { + values: Map; + // This root's values for keys another root had already set, applied if that root ends first. + shadowedValues: Map; + openLoads: Map boolean>; +}; + +function getLoadsByRoot(): Map { + const existing: unknown = Reflect.get(globalThis, LOADS_BY_ROOT_KEY); + if (existing instanceof Map) { + return existing; + } + const created = new Map(); + Reflect.set(globalThis, LOADS_BY_ROOT_KEY, created); + return created; +} + +/** Unsets the given roots' values that are still in place and returns them. */ +function takeLoadedValues(rootLoads: Iterable): Map { + const takenValues = new Map(); + for (const { values } of rootLoads) { + for (const [key, value] of values) { + if (process.env[key] === value) { + takenValues.set(key, value); + delete process.env[key]; + } + } + } + return takenValues; +} + +function restoreValues(values: Map) { + for (const [key, value] of values) { + process.env[key] = value; + } +} + +/** Gives each unset key to the first open root that shadowed it. */ +function handOffKeys(loadsByRoot: Map, keys: Iterable) { + for (const key of keys) { + if (key in process.env) { + continue; + } + for (const rootLoad of loadsByRoot.values()) { + const shadowedValue = rootLoad.shadowedValues.get(key); + if (shadowedValue !== undefined) { + rootLoad.shadowedValues.delete(key); + rootLoad.values.set(key, shadowedValue); + process.env[key] = shadowedValue; + break; + } + } + } +} + +const isProcessSettingKey = (upperCaseKey: string): boolean => + PROCESS_SETTING_KEYS.prefixes.some((prefix) => upperCaseKey.startsWith(prefix)) || + PROCESS_SETTING_KEYS.names.includes(upperCaseKey) || + PROCESS_SETTING_KEYS.infixes.some((infix) => upperCaseKey.includes(infix)) || + PROCESS_SETTING_KEYS.suffixes.some((suffix) => upperCaseKey.endsWith(suffix)); + +function releaseRoot(loadsByRoot: Map, root: string, rootLoad: RootLoad) { + loadsByRoot.delete(root); + const releasedValues = takeLoadedValues([rootLoad]); + const releasedKeys = releasedValues.keys(); + handOffKeys(loadsByRoot, releasedKeys); +} + +/** Ends every load whose `isActive` returns false, releasing roots left without an open load. */ +function endInactiveLoads(loadsByRoot: Map) { + for (const [root, rootLoad] of loadsByRoot) { + for (const [openLoad, isActive] of rootLoad.openLoads) { + if (!isActive()) { + rootLoad.openLoads.delete(openLoad); + } + } + if (rootLoad.openLoads.size === 0) { + releaseRoot(loadsByRoot, root, rootLoad); + } + } +} + +/** Removes every root's loaded values, keeping any that something else changed since. For tests. */ +export function dropEnvFileCredentials(): void { + const loadsByRoot = getLoadsByRoot(); + const rootLoads = loadsByRoot.values(); + takeLoadedValues(rootLoads); + loadsByRoot.clear(); +} + +/** + * Copies keys from Vite's .env files (.env, .env.local, .env.[mode], .env.[mode].local) into + * process.env for local execution, after dropping the previous load for the same `root`. Skips + * variables the shell already set, keys Vite exposes to the frontend (`envPrefix`), Datadog + * settings, and keys that change Node or process behavior. A key another root loaded first keeps + * that root's value until it ends. A load whose `isActive` returns false ends at the next load or drop. + */ +export function loadEnvFileCredentials( + loadEnvFromFiles: typeof loadEnv, + { root, mode, envDir, envPrefix = 'VITE_' }: EnvFileConfig, + isActive: () => boolean, +): EnvFileCredentials { + const loadsByRoot = getLoadsByRoot(); + endInactiveLoads(loadsByRoot); + const rootLoad: RootLoad = loadsByRoot.get(root) ?? { + values: new Map(), + shadowedValues: new Map(), + openLoads: new Map(), + }; + const publicPrefixes = Array.isArray(envPrefix) ? envPrefix : [envPrefix]; + // Unset while Vite reads the files, which would otherwise return them in place of the files' + // values. This root's own are restored if the read fails. + const otherRootLoads = [...loadsByRoot.values()].filter((other) => other !== rootLoad); + const otherRootValues = takeLoadedValues(otherRootLoads); + const previousValues = takeLoadedValues([rootLoad]); + let envFileValues: Record; + try { + // A '' prefix returns unprefixed keys too, merged with process.env, which the check below skips. + envFileValues = envDir === false ? {} : loadEnvFromFiles(mode, envDir, ''); + } catch (error) { + restoreValues(previousValues); + throw error; + } finally { + restoreValues(otherRootValues); + } + loadsByRoot.set(root, rootLoad); + rootLoad.values.clear(); + rootLoad.shadowedValues.clear(); + // Another dev server for this root, or the one a restart replaces, can still be running. + const openLoad = {}; + rootLoad.openLoads.set(openLoad, isActive); + const result: EnvFileCredentials = { + loaded: [], + ignoredDatadogKeys: [], + ignoredProcessKeys: [], + shadowedKeys: [], + drop: () => { + rootLoad.openLoads.delete(openLoad); + endInactiveLoads(loadsByRoot); + }, + }; + for (const [key, value] of Object.entries(envFileValues)) { + const otherRootValue = otherRootValues.get(key); + const isShellValue = key in process.env && otherRootValue === undefined; + if (isShellValue || publicPrefixes.some((prefix) => key.startsWith(prefix))) { + continue; + } + // Upper-cased because process.env is case-insensitive on Windows. + const upperCaseKey = key.toUpperCase(); + if (DATADOG_PREFIXES.some((prefix) => upperCaseKey.startsWith(prefix))) { + result.ignoredDatadogKeys.push(key); + continue; + } + if (isProcessSettingKey(upperCaseKey)) { + result.ignoredProcessKeys.push(key); + continue; + } + if (otherRootValue !== undefined) { + rootLoad.shadowedValues.set(key, value); + if (value !== otherRootValue) { + result.shadowedKeys.push(key); + } + continue; + } + process.env[key] = value; + rootLoad.values.set(key, value); + result.loaded.push(key); + } + const previousKeys = previousValues.keys(); + handOffKeys(loadsByRoot, previousKeys); + return result; +} diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 6d65b5c5c..993308ec4 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -5,9 +5,10 @@ import { getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; -import { rmSync } from '@dd/core/helpers/fs'; +import { outputFileSync, rmSync } from '@dd/core/helpers/fs'; import { InjectPosition } from '@dd/core/types'; import { clearDatadogEnv } from '@dd/tests/_jest/helpers/datadogEnv'; +import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env'; import { createMockRequest, createMockResponse, @@ -25,6 +26,7 @@ import type { PluginContext } from 'rollup'; import { createUnplugin } from 'unplugin'; import { createServer, + loadEnv, type ConfigEnv, type EnvironmentOptions, type Plugin as VitePlugin, @@ -42,6 +44,7 @@ import { } from '../constants'; import * as buildPackage from './build-package'; +import { dropEnvFileCredentials, loadEnvFileCredentials } from './dotenv-credentials'; import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; import * as networkGuard from './network-guard'; @@ -91,8 +94,14 @@ type DevServerMiddleware = (req: IncomingMessage, res: ServerResponse, next: () type FakeViteDevServer = { middlewares: { use: (fn: DevServerMiddleware) => void }; ssrLoadModule: (id: string) => Promise; - config: { mode: string }; + config: { + root: string; + mode: string; + envDir: string | false; + inlineConfig: { envFile?: false }; + }; pluginContainer?: object; + close: () => Promise; }; // Narrows `plugin.configureServer` to its plain-function hook form via a runtime check, then wraps @@ -105,8 +114,12 @@ function getConfigureServer( if (typeof configureServer !== 'function') { throw new Error('Expected plugin.configureServer to be the plain function-hook form'); } + // Runs the returned post hook too, as Vite does once every plugin's configureServer has run. return function callConfigureServer(server: FakeViteDevServer): void { - Reflect.apply(configureServer, undefined, [server]); + const postHook: unknown = Reflect.apply(configureServer, undefined, [server]); + if (typeof postHook === 'function') { + postHook(); + } }; } @@ -195,6 +208,7 @@ function getResolveIdHandler(plugin: ReturnType): Function const mockViteBuild = jest.fn(); const mockVite = { build: mockViteBuild, + loadEnv: jest.fn(() => ({})), transformWithEsbuild: jest.fn(), } as unknown as ViteBundler; const mockInject = jest.fn(); @@ -223,6 +237,7 @@ function mockBuildWithParsedBackend() { } const DD_API_ORIGIN = 'https://api.datadoghq.com'; +const isAlwaysActive = () => true; const defaultOptions = { bundler: mockVite, @@ -316,7 +331,8 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + close: jest.fn(), + config: { mode: 'development', envDir: false, root: '/build', inlineConfig: {} }, } as unknown as ViteDevServer; // The hooks are typed with Rollup's `this: PluginContext`, but the // plugin closures never read `this`, so a stand-in satisfies the call. @@ -345,7 +361,8 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + close: jest.fn(), + config: { mode: 'development', envDir: false, root: '/build', inlineConfig: {} }, } as unknown as ViteDevServer; plugin.configureServer(server); @@ -373,7 +390,8 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + close: jest.fn(), + config: { mode: 'development', envDir: false, root: '/build', inlineConfig: {} }, } as unknown as ViteDevServer; plugin.configureServer(server); @@ -1204,7 +1222,8 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode }, + close: jest.fn(), + config: { mode, envDir: false, root: '/build', inlineConfig: {} }, }); expect(installGuards).toHaveBeenCalledTimes(installs); @@ -1237,7 +1256,13 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + close: jest.fn(), + config: { + mode: 'development', + envDir: false, + root: '/build', + inlineConfig: {}, + }, }); const warned = mockLogFn.mock.calls.some( ([text, level]) => @@ -1270,7 +1295,13 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + close: jest.fn(), + config: { + mode: 'development', + envDir: false, + root: '/build', + inlineConfig: {}, + }, }), ).not.toThrow(); expect(installGuards).toHaveBeenCalled(); @@ -1314,7 +1345,8 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + close: jest.fn(), + config: { mode: 'development', envDir: false, root: '/build', inlineConfig: {} }, pluginContainer, }); const inRunPath = path.join(cacheDir, 'in-run.txt'); @@ -1384,7 +1416,8 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use }, ssrLoadModule, - config: { mode: DEV_VERIFY_MODE }, + close: jest.fn(), + config: { mode: DEV_VERIFY_MODE, envDir: false, root: '/build', inlineConfig: {} }, }); restoreEnv(); @@ -1425,4 +1458,636 @@ describe('Backend Functions - getVitePlugin', () => { expect(apiScope.isDone()).toBe(true); expect(ssrLoadModule).not.toHaveBeenCalled(); }); + + test('Should load unprefixed .env values into process.env when the dev server starts', () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'QA_CONFIGURE_SECRET=from-dotenv\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + + try { + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: { root: envDir, mode: 'development', envDir, inlineConfig: {} }, + }); + const loggedValue = mockLogFn.mock.calls.some(([text]) => + String(text).includes('from-dotenv'), + ); + // No auth is configured here, so the message must not promise backend execution. + const claimedReadable = mockLogFn.mock.calls.some(([text]) => + String(text).includes('can read'), + ); + + const loadedMessage = expect.stringContaining( + 'Loaded QA_CONFIGURE_SECRET from .env files', + ); + + expect(process.env.QA_CONFIGURE_SECRET).toBe('from-dotenv'); + expect(mockLogFn).toHaveBeenCalledWith(loadedMessage, 'info'); + expect(loggedValue).toBe(false); + expect(claimedReadable).toBe(false); + } finally { + dropEnvFileCredentials(); + delete process.env.QA_CONFIGURE_SECRET; + restoreEnv(); + rmSync(envDir); + } + }); + + test('Should drop the .env values a dev server loaded once its close resolves', async () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-close-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'QA_CONFIGURE_CLOSE_SECRET=from-dotenv\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + const closeServer = jest.fn(async () => { + expect(process.env.QA_CONFIGURE_CLOSE_SECRET).toBe('from-dotenv'); + }); + const server: FakeViteDevServer = { + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + config: { root: envDir, mode: 'development', envDir, inlineConfig: {} }, + close: closeServer, + }; + + try { + configureServer(server); + const loadedAtStart = process.env.QA_CONFIGURE_CLOSE_SECRET; + await server.close(); + + expect(loadedAtStart).toBe('from-dotenv'); + expect(closeServer).toHaveBeenCalledTimes(1); + expect(process.env).not.toHaveProperty('QA_CONFIGURE_CLOSE_SECRET'); + } finally { + dropEnvFileCredentials(); + delete process.env.QA_CONFIGURE_CLOSE_SECRET; + restoreEnv(); + rmSync(envDir); + } + }); + + test('Should keep Datadog auth from the shell and warn about Datadog keys in a .env file', () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-auth-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'DD_API_KEY=dotenv-api-key\nDD_APP_KEY=dotenv-app-key\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + process.env.DD_OAUTH_ACCESS_TOKEN = 'shell-oauth-token'; + const getAuthenticatedRequest = jest.spyOn(auth, 'getAuthenticatedRequest'); + + try { + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: { root: envDir, mode: 'development', envDir, inlineConfig: {} }, + }); + const ignoredMessage = expect.stringContaining('DD_API_KEY, DD_APP_KEY'); + const missingAuthMessage = expect.stringContaining('No authentication configured'); + const loggedValue = mockLogFn.mock.calls.some(([text]) => + String(text).includes('dotenv-api-key'), + ); + + expect(process.env).not.toHaveProperty('DD_API_KEY'); + expect(process.env.DD_OAUTH_ACCESS_TOKEN).toBe('shell-oauth-token'); + expect(getAuthenticatedRequest).toHaveReturned(); + expect(mockLogFn).toHaveBeenCalledWith(ignoredMessage, 'warn'); + expect(mockLogFn).not.toHaveBeenCalledWith(missingAuthMessage, 'warn'); + expect(loggedValue).toBe(false); + } finally { + dropEnvFileCredentials(); + getAuthenticatedRequest.mockRestore(); + restoreEnv(); + rmSync(envDir); + } + }); + + test('Should warn about and ignore .env keys that change Node or process behavior', () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-process-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'NODE_TLS_REJECT_UNAUTHORIZED=0\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + const shellValue = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + + try { + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: { root: envDir, mode: 'development', envDir, inlineConfig: {} }, + }); + const ignoredMessage = expect.stringContaining( + 'Ignoring NODE_TLS_REJECT_UNAUTHORIZED from .env files', + ); + + expect(process.env).not.toHaveProperty('NODE_TLS_REJECT_UNAUTHORIZED'); + expect(mockLogFn).toHaveBeenCalledWith(ignoredMessage, 'warn'); + } finally { + dropEnvFileCredentials(); + if (shellValue !== undefined) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = shellValue; + } + restoreEnv(); + rmSync(envDir); + } + }); + + test('Should keep the previous .env load when configureServer throws before it finishes', () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-throws-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'QA_CONFIGURE_EARLIER=from-dotenv\n'); + loadEnvFileCredentials( + loadEnv, + { root: envDir, mode: 'development', envDir }, + isAlwaysActive, + ); + outputFileSync(envFilePath, 'QA_CONFIGURE_LATER=from-dotenv\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + jest.spyOn(auth, 'getAuthenticatedRequest').mockImplementation(() => { + throw new Error('unexpected auth failure'); + }); + + try { + const startServer = () => + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: { root: envDir, mode: 'development', envDir, inlineConfig: {} }, + }); + + expect(startServer).toThrow('unexpected auth failure'); + expect(process.env.QA_CONFIGURE_EARLIER).toBe('from-dotenv'); + expect(process.env).not.toHaveProperty('QA_CONFIGURE_LATER'); + } finally { + dropEnvFileCredentials(); + delete process.env.QA_CONFIGURE_LATER; + rmSync(envDir); + } + }); + + // dev-verify sends every execution to the cloud, which never sees local .env values. + test('Should not load .env files in dev-verify mode', () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-verify-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'QA_CONFIGURE_VERIFY_SECRET=from-dotenv\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + + try { + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: { root: envDir, mode: DEV_VERIFY_MODE, envDir, inlineConfig: {} }, + }); + const loadedMessage = expect.stringContaining('QA_CONFIGURE_VERIFY_SECRET'); + + expect(process.env).not.toHaveProperty('QA_CONFIGURE_VERIFY_SECRET'); + expect(mockLogFn).not.toHaveBeenCalledWith(loadedMessage, 'info'); + } finally { + dropEnvFileCredentials(); + delete process.env.QA_CONFIGURE_VERIFY_SECRET; + restoreEnv(); + rmSync(envDir); + } + }); + + // Vite 5 keeps envDir set when the inline envFile option is false. + test("Should not load .env files when Vite's inline envFile option is false", () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-envfile-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'QA_CONFIGURE_ENVFILE_SECRET=from-dotenv\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + + try { + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: { + root: envDir, + mode: 'development', + envDir, + inlineConfig: { envFile: false }, + }, + }); + + expect(process.env).not.toHaveProperty('QA_CONFIGURE_ENVFILE_SECRET'); + } finally { + dropEnvFileCredentials(); + delete process.env.QA_CONFIGURE_ENVFILE_SECRET; + restoreEnv(); + rmSync(envDir); + } + }); + + // Vite 8 restarts with an internal close, so the replaced server's close never runs. + test('Should drop the .env values when a restarted server closes, even if its predecessor never closed', async () => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-replaced-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'QA_CONFIGURE_REPLACED_SECRET=from-dotenv\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + const createServerObject = (): FakeViteDevServer => ({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(async () => {}), + config: { root: envDir, mode: 'development', envDir, inlineConfig: {} }, + }); + const server = createServerObject(); + const restartedServer = createServerObject(); + + try { + configureServer(server); + configureServer(restartedServer); + Object.assign(server, restartedServer); + await server.close(); + + expect(process.env).not.toHaveProperty('QA_CONFIGURE_REPLACED_SECRET'); + } finally { + dropEnvFileCredentials(); + delete process.env.QA_CONFIGURE_REPLACED_SECRET; + restoreEnv(); + rmSync(envDir); + } + }); + + test("Should drop a replaced server's values when Vite swaps it for a server on another root", async () => { + const startedAt = Date.now(); + const firstRoot = getTempWorkingDir(`dd-apps-configure-dotenv-first-root-${startedAt}`); + const secondRoot = getTempWorkingDir(`dd-apps-configure-dotenv-second-root-${startedAt}`); + const firstEnvFilePath = path.join(firstRoot, '.env'); + const secondEnvFilePath = path.join(secondRoot, '.env'); + outputFileSync(firstEnvFilePath, 'QA_CONFIGURE_FIRST_ROOT_SECRET=first\n'); + outputFileSync(secondEnvFilePath, 'QA_CONFIGURE_SECOND_ROOT_SECRET=second\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + const createServerObject = (root: string): FakeViteDevServer => ({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(async () => {}), + config: { root, mode: 'development', envDir: root, inlineConfig: {} }, + }); + const server = createServerObject(firstRoot); + const restartedServer = createServerObject(secondRoot); + + try { + configureServer(server); + configureServer(restartedServer); + Object.assign(server, restartedServer); + await server.close(); + + expect(process.env).not.toHaveProperty('QA_CONFIGURE_FIRST_ROOT_SECRET'); + expect(process.env).not.toHaveProperty('QA_CONFIGURE_SECOND_ROOT_SECRET'); + } finally { + dropEnvFileCredentials(); + delete process.env.QA_CONFIGURE_FIRST_ROOT_SECRET; + delete process.env.QA_CONFIGURE_SECOND_ROOT_SECRET; + restoreEnv(); + rmSync(firstRoot); + rmSync(secondRoot); + } + }); + + const testRunCases = [ + { description: "mode 'test'", mode: 'test', vitest: undefined }, + { description: 'VITEST set', mode: 'development', vitest: 'true' }, + ]; + test.each(testRunCases)( + 'Should neither load nor drop .env values in a test run ($description)', + ({ mode, vitest }) => { + const startedAt = Date.now(); + const envDir = getTempWorkingDir(`dd-apps-configure-dotenv-test-run-${startedAt}`); + const envFilePath = path.join(envDir, '.env'); + outputFileSync(envFilePath, 'QA_TEST_RUN_EARLIER=from-dotenv\n'); + loadEnvFileCredentials( + loadEnv, + { root: envDir, mode: 'development', envDir }, + isAlwaysActive, + ); + outputFileSync(envFilePath, 'QA_TEST_RUN_SECRET=from-dotenv\n'); + const plugin = getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv } }); + const configureServer = getConfigureServer(plugin); + const restoreEnv = clearDatadogEnv(); + const previousVitest = process.env.VITEST; + if (vitest) { + process.env.VITEST = vitest; + } + + try { + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: { root: envDir, mode, envDir, inlineConfig: {} }, + }); + const skippedMessage = expect.stringContaining('Skipping .env file loading'); + + expect(process.env).not.toHaveProperty('QA_TEST_RUN_SECRET'); + expect(process.env.QA_TEST_RUN_EARLIER).toBe('from-dotenv'); + expect(mockLogFn).toHaveBeenCalledWith(skippedMessage, 'debug'); + } finally { + if (previousVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = previousVitest; + } + dropEnvFileCredentials(); + delete process.env.QA_TEST_RUN_SECRET; + restoreEnv(); + rmSync(envDir); + } + }, + ); + + describe('.env files on a real Vite dev server', () => { + let restoreEnv: () => void; + + beforeEach(() => { + restoreEnv = clearDatadogEnv(); + }); + + afterEach(() => { + dropEnvFileCredentials(); + restoreEnv(); + }); + + const createAppsDevServer = (root: string, extraPlugins: VitePlugin[] = []) => { + const context = getContextMock({ buildRoot: root }); + const appsPlugin: VitePlugin = { + name: 'dd-apps-test', + ...getVitePlugin({ ...defaultOptions, bundler: { ...mockVite, loadEnv }, context }), + }; + return createServer({ + configFile: false, + root, + logLevel: 'silent', + appType: 'custom', + server: { middlewareMode: true, hmr: false, ws: false }, + optimizeDeps: { noDiscovery: true, include: [] }, + plugins: [appsPlugin, ...extraPlugins], + }); + }; + + test('Should keep the loaded values when a restart fails after the config hooks ran', async () => { + const startedAt = Date.now(); + const root = getTempWorkingDir(`dd-apps-dotenv-failed-restart-${startedAt}`); + const envFilePath = path.join(root, '.env'); + outputFileSync(envFilePath, 'QA_RESTART_SECRET=from-dotenv\n'); + let shouldFailConfigResolved = false; + const failingPlugin: VitePlugin = { + name: 'fails-on-restart', + configResolved() { + if (shouldFailConfigResolved) { + throw new Error('configResolved failed'); + } + }, + }; + let server: ViteDevServer | undefined; + + try { + server = await createAppsDevServer(root, [failingPlugin]); + const loadedAtStart = process.env.QA_RESTART_SECRET; + outputFileSync(envFilePath, 'QA_RESTART_SECRET=edited\n'); + shouldFailConfigResolved = true; + await server.restart(); + + expect(loadedAtStart).toBe('from-dotenv'); + expect(process.env.QA_RESTART_SECRET).toBe('from-dotenv'); + } finally { + await server?.close(); + rmSync(root); + } + }); + + test('Should let the next dev server load its own .env value after one closes', async () => { + const startedAt = Date.now(); + const firstRoot = getTempWorkingDir(`dd-apps-dotenv-closed-root-${startedAt}`); + const secondRoot = getTempWorkingDir(`dd-apps-dotenv-next-root-${startedAt}`); + const firstEnvFilePath = path.join(firstRoot, '.env'); + const secondEnvFilePath = path.join(secondRoot, '.env'); + outputFileSync(firstEnvFilePath, 'QA_CLOSED_SERVER_SECRET=first\n'); + outputFileSync(secondEnvFilePath, 'QA_CLOSED_SERVER_SECRET=second\n'); + let secondServer: ViteDevServer | undefined; + + try { + const firstServer = await createAppsDevServer(firstRoot); + const loadedByFirstServer = process.env.QA_CLOSED_SERVER_SECRET; + await firstServer.close(); + const keptAfterClose = process.env.QA_CLOSED_SERVER_SECRET; + secondServer = await createAppsDevServer(secondRoot); + + expect(loadedByFirstServer).toBe('first'); + expect(keptAfterClose).toBeUndefined(); + expect(process.env.QA_CLOSED_SERVER_SECRET).toBe('second'); + } finally { + await secondServer?.close(); + delete process.env.QA_CLOSED_SERVER_SECRET; + rmSync(firstRoot); + rmSync(secondRoot); + } + }); + + test('Should keep unchanged .env values after a restart until the restarted server closes', async () => { + const startedAt = Date.now(); + const root = getTempWorkingDir(`dd-apps-dotenv-restart-unchanged-${startedAt}`); + const envFilePath = path.join(root, '.env'); + outputFileSync(envFilePath, 'QA_RESTART_UNCHANGED_SECRET=unchanged\n'); + let server: ViteDevServer | undefined; + + try { + server = await createAppsDevServer(root); + await server.restart(); + const keptAfterRestart = process.env.QA_RESTART_UNCHANGED_SECRET; + await server.close(); + + expect(keptAfterRestart).toBe('unchanged'); + expect(process.env).not.toHaveProperty('QA_RESTART_UNCHANGED_SECRET'); + } finally { + await server?.close(); + delete process.env.QA_RESTART_UNCHANGED_SECRET; + rmSync(root); + } + }); + + test('Should keep the .env values while another dev server for the same root runs', async () => { + const startedAt = Date.now(); + const root = getTempWorkingDir(`dd-apps-dotenv-same-root-${startedAt}`); + const envFilePath = path.join(root, '.env'); + outputFileSync(envFilePath, 'QA_SAME_ROOT_SECRET=from-dotenv\n'); + const servers: ViteDevServer[] = []; + + try { + const mainServer = await createAppsDevServer(root); + servers.push(mainServer); + const helperServer = await createAppsDevServer(root); + servers.push(helperServer); + await helperServer.close(); + const keptAfterHelperClosed = process.env.QA_SAME_ROOT_SECRET; + await mainServer.close(); + + expect(keptAfterHelperClosed).toBe('from-dotenv'); + expect(process.env).not.toHaveProperty('QA_SAME_ROOT_SECRET'); + } finally { + const closingServers = servers.map((server) => server.close()); + await Promise.all(closingServers); + delete process.env.QA_SAME_ROOT_SECRET; + rmSync(root); + } + }); + + test('Should drop the .env values once every dev server for the root closed, including one that loaded nothing', async () => { + const startedAt = Date.now(); + const root = getTempWorkingDir(`dd-apps-dotenv-empty-load-${startedAt}`); + const servers: ViteDevServer[] = []; + + try { + const emptyLoadServer = await createAppsDevServer(root); + servers.push(emptyLoadServer); + const envFilePath = path.join(root, '.env'); + outputFileSync(envFilePath, 'QA_EMPTY_LOAD_SECRET=from-dotenv\n'); + const loadingServer = await createAppsDevServer(root); + servers.push(loadingServer); + await emptyLoadServer.close(); + await loadingServer.close(); + + expect(process.env).not.toHaveProperty('QA_EMPTY_LOAD_SECRET'); + } finally { + const closingServers = servers.map((server) => server.close()); + await Promise.all(closingServers); + delete process.env.QA_EMPTY_LOAD_SECRET; + rmSync(root); + } + }); + + test('Should end the load of a restart that a later plugin fails, keeping the old values until the old server closes', async () => { + const startedAt = Date.now(); + const root = getTempWorkingDir(`dd-apps-dotenv-failed-configure-${startedAt}`); + const envFilePath = path.join(root, '.env'); + outputFileSync(envFilePath, 'QA_FAILED_CONFIGURE_SECRET=from-dotenv\n'); + let shouldFailConfigureServer = false; + // Vite never closes the server a failed restart was building. + let abandonedServer: ViteDevServer | undefined; + const failingPlugin: VitePlugin = { + name: 'fails-in-configure-server', + configureServer(newServer) { + if (shouldFailConfigureServer) { + abandonedServer = newServer; + throw new Error('configureServer failed'); + } + }, + }; + let server: ViteDevServer | undefined; + + try { + server = await createAppsDevServer(root, [failingPlugin]); + outputFileSync(envFilePath, 'QA_FAILED_CONFIGURE_SECRET=edited\n'); + shouldFailConfigureServer = true; + await server.restart(); + const keptAfterFailedRestart = process.env.QA_FAILED_CONFIGURE_SECRET; + await server.close(); + + expect(keptAfterFailedRestart).toBe('from-dotenv'); + expect(process.env).not.toHaveProperty('QA_FAILED_CONFIGURE_SECRET'); + } finally { + await server?.close(); + await abandonedServer?.close(); + delete process.env.QA_FAILED_CONFIGURE_SECRET; + rmSync(root); + } + }); + + test("Should hand a shared .env key to the other root's value once the first root's server closes", async () => { + const startedAt = Date.now(); + const firstRoot = getTempWorkingDir(`dd-apps-dotenv-shared-first-${startedAt}`); + const secondRoot = getTempWorkingDir(`dd-apps-dotenv-shared-second-${startedAt}`); + const firstEnvFilePath = path.join(firstRoot, '.env'); + const secondEnvFilePath = path.join(secondRoot, '.env'); + outputFileSync(firstEnvFilePath, 'QA_SHARED_ROOT_SECRET=first\n'); + outputFileSync(secondEnvFilePath, 'QA_SHARED_ROOT_SECRET=second\n'); + const servers: ViteDevServer[] = []; + + try { + const firstServer = await createAppsDevServer(firstRoot); + servers.push(firstServer); + const secondServer = await createAppsDevServer(secondRoot); + servers.push(secondServer); + const whileBothRun = process.env.QA_SHARED_ROOT_SECRET; + await firstServer.close(); + const afterFirstClosed = process.env.QA_SHARED_ROOT_SECRET; + await secondServer.close(); + const shadowedMessage = expect.stringContaining( + 'Ignoring QA_SHARED_ROOT_SECRET from .env files. Another dev server', + ); + const shadowWarnings = mockLogFn.mock.calls.filter( + ([text, level]) => + level === 'warn' && String(text).includes('QA_SHARED_ROOT_SECRET'), + ); + + expect(whileBothRun).toBe('first'); + expect(afterFirstClosed).toBe('second'); + expect(process.env).not.toHaveProperty('QA_SHARED_ROOT_SECRET'); + expect(mockLogFn).toHaveBeenCalledWith(shadowedMessage, 'warn'); + expect(shadowWarnings).toHaveLength(1); + } finally { + const closingServers = servers.map((server) => server.close()); + await Promise.all(closingServers); + delete process.env.QA_SHARED_ROOT_SECRET; + rmSync(firstRoot); + rmSync(secondRoot); + } + }); + + test("Should keep another dev server's values when one restarts", async () => { + const startedAt = Date.now(); + const firstRoot = getTempWorkingDir(`dd-apps-dotenv-first-root-${startedAt}`); + const secondRoot = getTempWorkingDir(`dd-apps-dotenv-second-root-${startedAt}`); + const firstEnvFilePath = path.join(firstRoot, '.env'); + const secondEnvFilePath = path.join(secondRoot, '.env'); + outputFileSync(firstEnvFilePath, 'QA_FIRST_ROOT_SECRET=first\n'); + outputFileSync(secondEnvFilePath, 'QA_SECOND_ROOT_SECRET=second\n'); + const servers: ViteDevServer[] = []; + + try { + const firstServer = await createAppsDevServer(firstRoot); + servers.push(firstServer); + const secondServer = await createAppsDevServer(secondRoot); + servers.push(secondServer); + outputFileSync(firstEnvFilePath, 'QA_FIRST_ROOT_SECRET=first-edited\n'); + await firstServer.restart(); + + expect(process.env.QA_FIRST_ROOT_SECRET).toBe('first-edited'); + expect(process.env.QA_SECOND_ROOT_SECRET).toBe('second'); + } finally { + const closingServers = servers.map((server) => server.close()); + await Promise.all(closingServers); + rmSync(firstRoot); + rmSync(secondRoot); + } + }); + }); }); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index fd726e257..5aca260eb 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -6,7 +6,7 @@ import { rm } from '@dd/core/helpers/fs'; import type { GlobalContext, PluginOptions } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import path from 'path'; -import type { build } from 'vite'; +import type { build, loadEnv, ViteDevServer } from 'vite'; import { AUTH_GUIDANCE, @@ -41,6 +41,7 @@ import { buildBackendFunctions } from './build-backend-functions'; import { buildAppPackage } from './build-package'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; import { createDevServerMiddleware } from './dev-server'; +import { loadEnvFileCredentials } from './dotenv-credentials'; import { localExecutionResolutionContext } from './local-execution'; import { exemptFetchFromBlockedScope, @@ -53,6 +54,7 @@ import { loadViteParseAst } from './vite-parse-ast'; export type ViteBundler = { build: typeof build; + loadEnv: typeof loadEnv; }; export interface VitePluginOptions { @@ -198,6 +200,72 @@ export const getVitePlugin = ({ // once per build rather than also at plugin init. let buildTagSources: TagSource[] = []; + // Runs in configureServer's post hook, which Vite only reaches once every plugin's + // configureServer has run, so a restart that fails there keeps the previous load. + const loadEnvFiles = (server: ViteDevServer) => { + const serverConfig = server.config; + const { root, mode, envDir, envPrefix, inlineConfig } = serverConfig; + // Vitest also calls configureServer, and its test workers would inherit the values. + if (process.env.VITEST || mode === 'test') { + log.debug('Skipping .env file loading: test run.'); + return; + } + // dev-verify runs every execution in the cloud, which never sees local .env values. Vite 5 + // keeps envDir set when the inline envFile option is false. + const isEnvFileDisabled = mode === DEV_VERIFY_MODE || inlineConfig.envFile === false; + // Vite 8 restarts without calling the replaced server's close, but swaps its config. + const isServerActive = () => server.config === serverConfig; + const envFileCredentials = loadEnvFileCredentials( + bundler.loadEnv, + { root, mode, envDir: isEnvFileDisabled ? false : envDir, envPrefix }, + isServerActive, + ); + const logKeys = ( + keys: string[], + level: 'info' | 'warn', + describe: (names: string) => string, + ) => { + if (keys.length > 0) { + const names = keys.join(', '); + const message = describe(names); + log[level](message); + } + }; + logKeys( + envFileCredentials.loaded, + 'info', + (names) => + `Loaded ${names} from .env files into process.env. Shell variables take precedence.`, + ); + logKeys( + envFileCredentials.ignoredDatadogKeys, + 'warn', + (names) => + `Ignoring ${names} from .env files. Set Datadog settings in the shell or start the dev server with \`datadog-apps dev\`.`, + ); + logKeys( + envFileCredentials.ignoredProcessKeys, + 'warn', + (names) => + `Ignoring ${names} from .env files. Set settings that change Node or process behavior in the shell.`, + ); + logKeys( + envFileCredentials.shadowedKeys, + 'warn', + (names) => + `Ignoring ${names} from .env files. Another dev server in this process, or the one being restarted, loaded them first; this server's values apply once no other server holds them.`, + ); + // Per server, as closeBundle can't tell which server closed on Vite 5. + const closeServer = server.close.bind(server); + server.close = async () => { + try { + await closeServer(); + } finally { + envFileCredentials.drop(); + } + }; + }; + return { config: { // After other plugins' config hooks, so it sees a server.preTransformRequests they set. @@ -477,6 +545,7 @@ export const getVitePlugin = ({ server.config.mode, ); server.middlewares.use(middleware); + return () => loadEnvFiles(server); }, }; };