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); }, }; };