diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index 0ebddfd988bb04..6aa217330ff5b2 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -39,12 +39,14 @@ Define global constant replacements. Entries will be defined as globals during d - Starting from `2.0.0-beta.70`, string values will be used as raw expressions, so if defining a string constant, it needs to be explicitly quoted (e.g. with `JSON.stringify`). +- Starting from `3.2.0`, defined constants within string literals and comments are ignored. For example, `process.env.NODE_ENV` is not replaced in `console.log('mode is process.env.NODE_ENV') /* process.env.NODE_ENV */`. + - To be consistent with [esbuild behavior](https://esbuild.github.io/api/#define), expressions must either be a JSON object (null, boolean, number, string, array, or object) or a single identifier. - Replacements are performed only when the match isn't surrounded by other letters, numbers, `_` or `$`. ::: warning -Because it's implemented as straightforward text replacements without any syntax analysis, we recommend using `define` for CONSTANTS only. +Because it's implemented as straightforward text replacements without any syntax analysis (pre Vite 3.2.0), we recommend using `define` for CONSTANTS only. For example, `process.env.FOO` and `__APP_VERSION__` are good fits. But `process` or `global` should not be put into this option. Variables can be shimmed or polyfilled instead. ::: diff --git a/docs/guide/env-and-mode.md b/docs/guide/env-and-mode.md index 8c14e9d476fdcb..d62410e71d4652 100644 --- a/docs/guide/env-and-mode.md +++ b/docs/guide/env-and-mode.md @@ -18,9 +18,11 @@ Vite exposes env variables on the special **`import.meta.env`** object. Some bui During production, these env variables are **statically replaced**. It is therefore necessary to always reference them using the full static string. For example, dynamic key access like `import.meta.env[key]` will not work. -It will also replace these strings appearing in JavaScript strings and Vue templates. This should be a rare case, but it can be unintended. You may see errors like `Missing Semicolon` or `Unexpected token` in this case, for example when `"process.env.``NODE_ENV"` is transformed to `""development": "`. There are ways to work around this behavior: +Before version 3.2.0, Vite also replaces environment variables (and other [defined constants](/config/shared-options.html#define)) within string literals and comments. This could result in unintentional changes to your string literals that result in invalid JavaScript syntax, causing errors like `Missing ) after argument list`, `Missing semicolon`, or `Unexpected token`. For example, you'd see an error when `console.log("This is process.env.``NODE_ENV")` is transformed to `console.log("This is "development"")`. -- For JavaScript strings, you can break the string up with a Unicode zero-width space, e.g. `'import.meta\u200b.env.MODE'`. +There are ways to work around this string replacement behavior: + +- For JavaScript strings, you can break the string up with a Unicode zero-width space, e.g. `'import.meta\0.env.MODE'` or use string concatenation, e.g. `'import' + '.meta.env.MODE'`. - For Vue templates or other HTML that gets compiled into JavaScript strings, you can use the [`` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/wbr), e.g. `import.meta.env.MODE`. diff --git a/packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts b/packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts new file mode 100644 index 00000000000000..1f989ddec07f09 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'vitest' +import { + clientInjectionsPlugin, + normalizedClientEntry, + normalizedEnvEntry, + resolveInjections +} from '../../plugins/clientInjections' +import { resolveConfig } from '../../config' + +async function createClientInjectionPluginTransform({ + build = true, + ssr = false, + id = 'foo.ts' +} = {}) { + const config = await resolveConfig({}, build ? 'build' : 'serve') + const instance = clientInjectionsPlugin(config) + const transform = async (code: string) => { + const transform = + instance.transform && 'handler' in instance.transform + ? instance.transform.handler + : instance.transform + const result = await transform?.call({}, code, id, { ssr }) + return result?.code || result + } + return { + transform, + injections: resolveInjections(config) + } +} + +describe('clientInjectionsPlugin', () => { + test('replaces process.env.NODE_ENV for non-SSR', async () => { + const { transform } = await createClientInjectionPluginTransform() + expect(await transform('let x = process.env.NODE_ENV;')).toBe( + 'let x = "test";' + ) + }) + + test('replaces process.env.NODE_ENV inside template literal expressions for non-SSR', async () => { + const { transform } = await createClientInjectionPluginTransform() + expect(await transform('let x = `${process.env.NODE_ENV}`;')).toBe( + 'let x = `${"test"}`;' + ) + }) + + test('ignores process.env.NODE_ENV for SSR', async () => { + const { transform: ssrTransform } = + await createClientInjectionPluginTransform({ + ssr: true + }) + expect(await ssrTransform('let x = process.env.NODE_ENV;')).toBe(null) + }) + + test('ignores process.env.NODE_ENV inside string literal for non-SSR', async () => { + const { transform } = await createClientInjectionPluginTransform() + expect(await transform(`let x = 'process.env.NODE_ENV';`)).toBe(null) + expect(await transform('let x = "process.env.NODE_ENV";')).toBe(null) + expect(await transform('let x = `process.env.NODE_ENV`;')).toBe(null) + }) + + test('ignores process.env.NODE_ENV inside string literal for SSR', async () => { + const { transform: ssrTransform } = + await createClientInjectionPluginTransform({ + ssr: true + }) + expect(await ssrTransform(`let x = 'process.env.NODE_ENV';`)).toBe(null) + expect(await ssrTransform('let x = "process.env.NODE_ENV";')).toBe(null) + expect(await ssrTransform('let x = `process.env.NODE_ENV`;')).toBe(null) + }) + + test('replaces code injections for client entry', async () => { + const { injections, transform } = + await createClientInjectionPluginTransform({ + id: normalizedClientEntry + }) + test.each(Object.keys(injections))('replaces %s', async (key) => { + expect(await transform(key)).toBe(injections[key]) + }) + }) + + test('replaces code injections for env entry', async () => { + const { injections, transform } = + await createClientInjectionPluginTransform({ + id: normalizedEnvEntry + }) + test.each(Object.keys(injections))('replaces %s', async (key) => { + expect(await transform(key)).toBe(injections[key]) + }) + }) + + test('ignores code injections for non entry files', async () => { + const { injections, transform } = + await createClientInjectionPluginTransform() + test.each(Object.keys(injections))('replaces %s', async (key) => { + expect(await transform(key)).toBe(null) + }) + }) +}) diff --git a/packages/vite/src/node/__tests__/plugins/define.spec.ts b/packages/vite/src/node/__tests__/plugins/define.spec.ts index 932560a749f24d..f51f6ddeb65d6a 100644 --- a/packages/vite/src/node/__tests__/plugins/define.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/define.spec.ts @@ -10,7 +10,11 @@ async function createDefinePluginTransform( const config = await resolveConfig({ define }, build ? 'build' : 'serve') const instance = definePlugin(config) return async (code: string) => { - const result = await instance.transform.call({}, code, 'foo.ts', { ssr }) + const transform = + instance.transform && 'handler' in instance.transform + ? instance.transform.handler + : instance.transform + const result = await transform?.call({}, code, 'foo.ts', { ssr }) return result?.code || result } } @@ -37,4 +41,114 @@ describe('definePlugin', () => { 'const isSSR = false;' ) }) + + // Specially defined constants found in packages/vite/src/node/plugins/define.ts + const specialDefines = { + 'process.env.': '({}).', + 'global.process.env.': '({}).', + 'globalThis.process.env.': '({}).', + 'process.env.NODE_ENV': '"test"', + 'global.process.env.NODE_ENV': '"test"', + 'globalThis.process.env.NODE_ENV': '"test"', + __vite_process_env_NODE_ENV: '"test"', + 'import.meta.env.': '({}).', + 'import.meta.env': + '{"BASE_URL":"/","MODE":"development","DEV":true,"PROD":false}', + 'import.meta.hot': 'false' + } + const specialDefineKeys = Object.keys(specialDefines) + + const specialDefinesSSR = { + ...specialDefines, + // process.env is not replaced in SSR + 'process.env.': null, + 'global.process.env.': null, + 'globalThis.process.env.': null, + 'process.env.NODE_ENV': null, + 'global.process.env.NODE_ENV': null, + 'globalThis.process.env.NODE_ENV': null, + // __vite_process_env_NODE_ENV is a special variable that resolves to process.env.NODE_ENV, which is not replaced in SSR + __vite_process_env_NODE_ENV: 'process.env.NODE_ENV' + } + + describe('ignores defined constants in string literals', async () => { + const singleQuotedDefines = specialDefineKeys + .map((define) => `let x = '${define}'`) + .join(';\n') + const doubleQuotedDefines = specialDefineKeys + .map((define) => `let x = "${define}"`) + .join(';\n') + const backtickedDefines = specialDefineKeys + .map((define) => `let x = \`${define}\``) + .join(';\n') + const singleQuotedDefinesMultilineNested = specialDefineKeys + .map((define) => `let x = \`\n'${define}'\n\``) + .join(';\n') + const doubleQuotedDefinesMultilineNested = specialDefineKeys + .map((define) => `let x = \`\n"${define}"\n\``) + .join(';\n') + + const inputs = [ + ['double-quoted', doubleQuotedDefines], + ['single-quoted', singleQuotedDefines], + ['backticked', backtickedDefines], + ['multiline nested double-quoted', doubleQuotedDefinesMultilineNested], + ['multiline nested single-quoted', singleQuotedDefinesMultilineNested] + ] + + describe('non-SSR', async () => { + const transform = await createDefinePluginTransform() + test.each(inputs)('%s', async (label, input) => { + // transform() returns null when no replacement is made + expect(await transform(input)).toBe(null) + }) + }) + + describe('SSR', async () => { + const ssrTransform = await createDefinePluginTransform({}, true, true) + test.each(inputs)('%s', async (label, input) => { + // transform() returns null when no replacement is made + expect(await ssrTransform(input)).toBe(null) + }) + }) + }) + + describe('replaces defined constants in template literal expressions', async () => { + describe('non-SSR', async () => { + const transform = await createDefinePluginTransform() + + test.each(specialDefineKeys)('%s', async (key) => { + const result = await transform('let x = `${' + key + '}`') + expect(result).toBe('let x = `${' + specialDefines[key] + '}`') + }) + + // multiline tests + test.each(specialDefineKeys)('%s', async (key) => { + const result = await transform('let x = `\n${' + key + '}\n`') + expect(result).toBe('let x = `\n${' + specialDefines[key] + '}\n`') + }) + }) + describe('SSR', async () => { + const ssrTransform = await createDefinePluginTransform({}, true, true) + + test.each(specialDefineKeys)('%s', async (key) => { + const result = await ssrTransform('let x = `${' + key + '}`') + expect(result).toBe( + specialDefinesSSR[key] + ? 'let x = `${' + specialDefinesSSR[key] + '}`' + : null + ) + }) + + // multiline tests + test.each(specialDefineKeys)('%s', async (key) => { + const result = await ssrTransform('let x = `\n${' + key + '}\n`') + expect(result).toBe( + specialDefinesSSR[key] + ? 'let x = `\n${' + specialDefinesSSR[key] + '}\n`' + : null + ) + }) + }) + }) }) diff --git a/packages/vite/src/node/__tests__/plugins/importGlob/__snapshots__/fixture.test.ts.snap b/packages/vite/src/node/__tests__/plugins/importGlob/__snapshots__/fixture.test.ts.snap index e638850c4ce0b0..0de5dfe44083a7 100644 --- a/packages/vite/src/node/__tests__/plugins/importGlob/__snapshots__/fixture.test.ts.snap +++ b/packages/vite/src/node/__tests__/plugins/importGlob/__snapshots__/fixture.test.ts.snap @@ -13,7 +13,7 @@ export const excludeSelf = /* #__PURE__ */ Object.assign({\\"./sibling.ts\\": () export const customQueryString = /* #__PURE__ */ Object.assign({\\"./sibling.ts\\": () => import(\\"./sibling.ts?custom\\")}); export const customQueryObject = /* #__PURE__ */ Object.assign({\\"./sibling.ts\\": () => import(\\"./sibling.ts?foo=bar&raw=true\\")}); export const parent = /* #__PURE__ */ Object.assign({}); -export const rootMixedRelative = /* #__PURE__ */ Object.assign({\\"/css.spec.ts\\": () => import(\\"../../css.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/define.spec.ts\\": () => import(\\"../../define.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/esbuild.spec.ts\\": () => import(\\"../../esbuild.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/import.spec.ts\\": () => import(\\"../../import.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/a.ts\\": () => import(\\"../fixture-b/a.ts?url\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/b.ts\\": () => import(\\"../fixture-b/b.ts?url\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/index.ts\\": () => import(\\"../fixture-b/index.ts?url\\").then(m => m[\\"default\\"])}); +export const rootMixedRelative = /* #__PURE__ */ Object.assign({\\"/clientInjections.spec.ts\\": () => import(\\"../../clientInjections.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/css.spec.ts\\": () => import(\\"../../css.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/define.spec.ts\\": () => import(\\"../../define.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/esbuild.spec.ts\\": () => import(\\"../../esbuild.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/import.spec.ts\\": () => import(\\"../../import.spec.ts?url\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/a.ts\\": () => import(\\"../fixture-b/a.ts?url\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/b.ts\\": () => import(\\"../fixture-b/b.ts?url\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/index.ts\\": () => import(\\"../fixture-b/index.ts?url\\").then(m => m[\\"default\\"])}); export const cleverCwd1 = /* #__PURE__ */ Object.assign({\\"./node_modules/framework/pages/hello.page.js\\": () => import(\\"./node_modules/framework/pages/hello.page.js\\")}); export const cleverCwd2 = /* #__PURE__ */ Object.assign({\\"./modules/a.ts\\": () => import(\\"./modules/a.ts\\"),\\"./modules/b.ts\\": () => import(\\"./modules/b.ts\\"),\\"../fixture-b/a.ts\\": () => import(\\"../fixture-b/a.ts\\"),\\"../fixture-b/b.ts\\": () => import(\\"../fixture-b/b.ts\\")}); " @@ -32,7 +32,7 @@ export const excludeSelf = /* #__PURE__ */ Object.assign({\\"./sibling.ts\\": () export const customQueryString = /* #__PURE__ */ Object.assign({\\"./sibling.ts\\": () => import(\\"./sibling.ts?custom&lang.ts\\")}); export const customQueryObject = /* #__PURE__ */ Object.assign({\\"./sibling.ts\\": () => import(\\"./sibling.ts?foo=bar&raw=true&lang.ts\\")}); export const parent = /* #__PURE__ */ Object.assign({}); -export const rootMixedRelative = /* #__PURE__ */ Object.assign({\\"/css.spec.ts\\": () => import(\\"../../css.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/define.spec.ts\\": () => import(\\"../../define.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/esbuild.spec.ts\\": () => import(\\"../../esbuild.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/import.spec.ts\\": () => import(\\"../../import.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/a.ts\\": () => import(\\"../fixture-b/a.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/b.ts\\": () => import(\\"../fixture-b/b.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/index.ts\\": () => import(\\"../fixture-b/index.ts?url&lang.ts\\").then(m => m[\\"default\\"])}); +export const rootMixedRelative = /* #__PURE__ */ Object.assign({\\"/clientInjections.spec.ts\\": () => import(\\"../../clientInjections.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/css.spec.ts\\": () => import(\\"../../css.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/define.spec.ts\\": () => import(\\"../../define.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/esbuild.spec.ts\\": () => import(\\"../../esbuild.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/import.spec.ts\\": () => import(\\"../../import.spec.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/a.ts\\": () => import(\\"../fixture-b/a.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/b.ts\\": () => import(\\"../fixture-b/b.ts?url&lang.ts\\").then(m => m[\\"default\\"]),\\"/importGlob/fixture-b/index.ts\\": () => import(\\"../fixture-b/index.ts?url&lang.ts\\").then(m => m[\\"default\\"])}); export const cleverCwd1 = /* #__PURE__ */ Object.assign({\\"./node_modules/framework/pages/hello.page.js\\": () => import(\\"./node_modules/framework/pages/hello.page.js\\")}); export const cleverCwd2 = /* #__PURE__ */ Object.assign({\\"./modules/a.ts\\": () => import(\\"./modules/a.ts\\"),\\"./modules/b.ts\\": () => import(\\"./modules/b.ts\\"),\\"../fixture-b/a.ts\\": () => import(\\"../fixture-b/a.ts\\"),\\"../fixture-b/b.ts\\": () => import(\\"../fixture-b/b.ts\\")}); " diff --git a/packages/vite/src/node/__tests__/utils.spec.ts b/packages/vite/src/node/__tests__/utils.spec.ts index 34ede1f5be2303..bb88b59dd914c2 100644 --- a/packages/vite/src/node/__tests__/utils.spec.ts +++ b/packages/vite/src/node/__tests__/utils.spec.ts @@ -7,6 +7,7 @@ import { injectQuery, isWindows, posToNumber, + replaceInCode, resolveHostname } from '../utils' @@ -236,3 +237,36 @@ describe('asyncFlatten', () => { expect(arr).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) }) }) + +describe('replaceInCode', () => { + test('replaces pattern with string', () => { + expect(replaceInCode('let a1 = 1;', /\d/g, 'b')?.toString()).toBe( + 'let ab = b;' + ) + }) + + test('replaces pattern with replacement object', () => { + expect( + replaceInCode('let a1 = 2;', /\d/g, { + '1': 'b', + '2': 'c' + })?.toString() + ).toBe('let ab = c;') + }) + + test('returns null if no replacement', () => { + expect(replaceInCode('let a = b;', /\d/g, {})).toBe(null) + }) + + test('ignores string literals', () => { + expect(replaceInCode('let a1 = "1";', /\d/g, 'b')?.toString()).toBe( + 'let ab = "1";' + ) + }) + + test('ignores comments', () => { + expect( + replaceInCode('let a /* 1 */ = 1; // 1', /\d/g, 'b')?.toString() + ).toBe('let a /* 1 */ = b; // 1') + }) +}) diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index c4472cb8f14b62..958088b7d033b2 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -2,11 +2,66 @@ import path from 'node:path' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' import { CLIENT_ENTRY, ENV_ENTRY } from '../constants' -import { isObject, normalizePath, resolveHostname } from '../utils' +import { + isObject, + normalizePath, + replaceInCode, + resolveHostname +} from '../utils' // ids in transform are normalized to unix style -const normalizedClientEntry = normalizePath(CLIENT_ENTRY) -const normalizedEnvEntry = normalizePath(ENV_ENTRY) +export const normalizedClientEntry = normalizePath(CLIENT_ENTRY) +export const normalizedEnvEntry = normalizePath(ENV_ENTRY) + +export async function resolveInjections( + config: ResolvedConfig +): Promise> { + const resolvedServerHostname = (await resolveHostname(config.server.host)) + .name + const resolvedServerPort = config.server.port! + const devBase = config.base + + const serverHost = `${resolvedServerHostname}:${resolvedServerPort}${devBase}` + + let hmrConfig = config.server.hmr + hmrConfig = isObject(hmrConfig) ? hmrConfig : undefined + const host = hmrConfig?.host || null + const protocol = hmrConfig?.protocol || null + const timeout = hmrConfig?.timeout || 30000 + const overlay = hmrConfig?.overlay !== false + + // hmr.clientPort -> hmr.port + // -> (24678 if middleware mode) -> new URL(import.meta.url).port + let port = hmrConfig?.clientPort || hmrConfig?.port || null + if (config.server.middlewareMode) { + port ||= 24678 + } + + let directTarget = hmrConfig?.host || resolvedServerHostname + directTarget += `:${hmrConfig?.port || resolvedServerPort}` + directTarget += devBase + + let hmrBase = devBase + if (hmrConfig?.path) { + hmrBase = path.posix.join(hmrBase, hmrConfig.path) + } + + const replacements: Record = { + __MODE__: JSON.stringify(config.mode), + __BASE__: JSON.stringify(devBase), + __DEFINES__: serializeDefine(config.define || {}), + __SERVER_HOST__: JSON.stringify(serverHost), + __HMR_PROTOCOL__: JSON.stringify(protocol), + __HMR_HOSTNAME__: JSON.stringify(host), + __HMR_PORT__: JSON.stringify(port), + __HMR_DIRECT_TARGET__: JSON.stringify(directTarget), + __HMR_BASE__: JSON.stringify(hmrBase), + __HMR_TIMEOUT__: JSON.stringify(timeout), + __HMR_ENABLE_OVERLAY__: JSON.stringify(overlay) + } + + return replacements +} /** * some values used by the client needs to be dynamically injected by the server @@ -17,59 +72,23 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { name: 'vite:client-inject', async transform(code, id, options) { if (id === normalizedClientEntry || id === normalizedEnvEntry) { - const resolvedServerHostname = ( - await resolveHostname(config.server.host) - ).name - const resolvedServerPort = config.server.port! - const devBase = config.base - - const serverHost = `${resolvedServerHostname}:${resolvedServerPort}${devBase}` - - let hmrConfig = config.server.hmr - hmrConfig = isObject(hmrConfig) ? hmrConfig : undefined - const host = hmrConfig?.host || null - const protocol = hmrConfig?.protocol || null - const timeout = hmrConfig?.timeout || 30000 - const overlay = hmrConfig?.overlay !== false - - // hmr.clientPort -> hmr.port - // -> (24678 if middleware mode) -> new URL(import.meta.url).port - let port = hmrConfig?.clientPort || hmrConfig?.port || null - if (config.server.middlewareMode) { - port ||= 24678 - } - - let directTarget = hmrConfig?.host || resolvedServerHostname - directTarget += `:${hmrConfig?.port || resolvedServerPort}` - directTarget += devBase - - let hmrBase = devBase - if (hmrConfig?.path) { - hmrBase = path.posix.join(hmrBase, hmrConfig.path) - } - - return code - .replace(`__MODE__`, JSON.stringify(config.mode)) - .replace(`__BASE__`, JSON.stringify(devBase)) - .replace(`__DEFINES__`, serializeDefine(config.define || {})) - .replace(`__SERVER_HOST__`, JSON.stringify(serverHost)) - .replace(`__HMR_PROTOCOL__`, JSON.stringify(protocol)) - .replace(`__HMR_HOSTNAME__`, JSON.stringify(host)) - .replace(`__HMR_PORT__`, JSON.stringify(port)) - .replace(`__HMR_DIRECT_TARGET__`, JSON.stringify(directTarget)) - .replace(`__HMR_BASE__`, JSON.stringify(hmrBase)) - .replace(`__HMR_TIMEOUT__`, JSON.stringify(timeout)) - .replace(`__HMR_ENABLE_OVERLAY__`, JSON.stringify(overlay)) - } else if (!options?.ssr && code.includes('process.env.NODE_ENV')) { + const replacements = await resolveInjections(config) + const pattern = new RegExp(Object.keys(replacements).join('|'), 'gu') + return replaceInCode(code, pattern, replacements)?.toString() ?? null + } else if (!options?.ssr) { // replace process.env.NODE_ENV instead of defining a global // for it to avoid shimming a `process` object during dev, // avoiding inconsistencies between dev and build - return code.replace( - /\bprocess\.env\.NODE_ENV\b/g, - config.define?.['process.env.NODE_ENV'] || - JSON.stringify(process.env.NODE_ENV || config.mode) + return ( + replaceInCode( + code, + /\bprocess\.env\.NODE_ENV\b/g, + config.define?.['process.env.NODE_ENV'] || + JSON.stringify(process.env.NODE_ENV || config.mode) + )?.toString() ?? null ) } + return null } } } diff --git a/packages/vite/src/node/plugins/define.ts b/packages/vite/src/node/plugins/define.ts index e683ba4d0175db..668419fb779272 100644 --- a/packages/vite/src/node/plugins/define.ts +++ b/packages/vite/src/node/plugins/define.ts @@ -1,7 +1,6 @@ -import MagicString from 'magic-string' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' -import { transformStableResult } from '../utils' +import { replaceInCode, transformStableResult } from '../utils' import { isCSSRequest } from './css' import { isHTMLRequest } from './html' @@ -45,6 +44,7 @@ export function definePlugin(config: ResolvedConfig): Plugin { ...config.env, SSR: !!config.build.ssr } + for (const key in env) { importMetaKeys[`import.meta.env.${key}`] = JSON.stringify(env[key]) } @@ -107,6 +107,11 @@ export function definePlugin(config: ResolvedConfig): Plugin { return } + const [replacements, pattern] = ssr ? ssrPattern : defaultPattern + if (!pattern) { + return null + } + if ( // exclude html, css and static assets for performance isHTMLRequest(id) || @@ -117,36 +122,8 @@ export function definePlugin(config: ResolvedConfig): Plugin { return } - const [replacements, pattern] = ssr ? ssrPattern : defaultPattern - - if (!pattern) { - return null - } - - if (ssr && !isBuild) { - // ssr + dev, simple replace - return code.replace(pattern, (_, match) => { - return '' + replacements[match] - }) - } - - const s = new MagicString(code) - let hasReplaced = false - let match: RegExpExecArray | null - - while ((match = pattern.exec(code))) { - hasReplaced = true - const start = match.index - const end = start + match[0].length - const replacement = '' + replacements[match[1]] - s.update(start, end, replacement) - } - - if (!hasReplaced) { - return null - } - - return transformStableResult(s, id, config) + const s = replaceInCode(code, pattern, replacements) + return s ? transformStableResult(s, id, config) : null } } } diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 23c7597bb77195..5de6c2a6d36339 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -8,6 +8,7 @@ import { builtinModules, createRequire } from 'node:module' import { promises as dns } from 'node:dns' import { performance } from 'node:perf_hooks' import type { AddressInfo, Server } from 'node:net' +import { stripLiteral } from 'strip-literal' import resolve from 'resolve' import type { FSWatcher } from 'chokidar' import remapping from '@ampproject/remapping' @@ -15,7 +16,7 @@ import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping' import colors from 'picocolors' import debug from 'debug' import type { Alias, AliasOptions } from 'dep-types/alias' -import type MagicString from 'magic-string' +import MagicString from 'magic-string' import type { TransformResult } from 'rollup' import { createFilter as _createFilter } from '@rollup/pluginutils' @@ -1198,6 +1199,48 @@ export const isNonDriveRelativeAbsolutePath = (p: string): boolean => { return windowsDrivePathPrefixRE.test(p) } +/** + * Replaces strings within JS code without touching string literals or comments + * + * @param code JavaScript to analyze + * @param pattern regular expression to match in code + * @param replacements a map of strings to replace matches with; or a string + * @returns MagicString of transformed code; or null if no replacements were made + */ +export function replaceInCode( + code: string, + pattern: RegExp, + replacements: Record | string +): MagicString | null { + const maybeNeedsReplacement = new RegExp(pattern).test(code) + if (!maybeNeedsReplacement) { + return null + } + + const s = new MagicString(code) + let hasReplaced = false + let match: RegExpExecArray | null + + code = stripLiteral(code) + + while ((match = pattern.exec(code))) { + hasReplaced = true + const start = match.index + const end = start + match[0].length + const replacement = + typeof replacements === 'string' + ? replacements + : replacements[match[0]] ?? '' + s.update(start, end, replacement) + } + + if (!hasReplaced) { + return null + } + + return s +} + export function joinUrlSegments(a: string, b: string): string { if (!a || !b) { return a || b || '' diff --git a/playground/define/__tests__/define.spec.ts b/playground/define/__tests__/define.spec.ts index 43787ef0adb112..43e260715b5192 100644 --- a/playground/define/__tests__/define.spec.ts +++ b/playground/define/__tests__/define.spec.ts @@ -2,9 +2,9 @@ import { expect, test } from 'vitest' import viteConfig from '../vite.config' import { page } from '~utils' -test('string', async () => { - const defines = viteConfig.define +const defines = viteConfig.define +test('string', async () => { expect(await page.textContent('.exp')).toBe( String(typeof eval(defines.__EXP__)) ) @@ -45,3 +45,51 @@ test('string', async () => { defines.__STRINGIFIED_OBJ__ ) }) + +test('ignores constants in string literals', async () => { + expect( + await page.textContent('.ignores-string-literals .process-env-dot') + ).toBe('process.env.') + expect( + await page.textContent('.ignores-string-literals .global-process-env-dot') + ).toBe('global.process.env.') + expect( + await page.textContent( + '.ignores-string-literals .globalThis-process-env-dot' + ) + ).toBe('globalThis.process.env.') + expect( + await page.textContent('.ignores-string-literals .process-env-NODE_ENV') + ).toBe('process.env.NODE_ENV') + expect( + await page.textContent( + '.ignores-string-literals .global-process-env-NODE_ENV' + ) + ).toBe('global.process.env.NODE_ENV') + expect( + await page.textContent( + '.ignores-string-literals .globalThis-process-env-NODE_ENV' + ) + ).toBe('globalThis.process.env.NODE_ENV') + expect( + await page.textContent( + '.ignores-string-literals .__vite_process_env_NODE_ENV' + ) + ).toBe('__vite_process_env_NODE_ENV') + expect( + await page.textContent('.ignores-string-literals .import-meta-hot') + ).toBe('import' + '.meta.hot') +}) + +test('replaces constants in template literal expressions', async () => { + expect( + await page.textContent( + '.replaces-constants-in-template-literal-expressions .process-env-dot' + ) + ).toBe(JSON.parse(defines['process.env.SOMEVAR'])) + expect( + await page.textContent( + '.replaces-constants-in-template-literal-expressions .process-env-NODE_ENV' + ) + ).toBe('dev') +}) diff --git a/playground/define/index.html b/playground/define/index.html index c4f4c598aba563..ef46d284c0823d 100644 --- a/playground/define/index.html +++ b/playground/define/index.html @@ -18,6 +18,52 @@

Define

import json:

define in dep:

+

Define ignores string literals

+
+

process.env.

+

global.process.env.

+

+ globalThis.process.env. +

+

process.env.NODE_ENV

+

+ global.process.env.NODE_ENV + +

+

+ globalThis.process.env.NODE_ENV + +

+

+ __vite_process_env_NODE_ENV + +

+

import.meta.hot

+
+ +

Define replaces constants in template literal expressions

+
+

process.env.

+

global.process.env.

+

+ globalThis.process.env. +

+

process.env.NODE_ENV

+

+ global.process.env.NODE_ENV + +

+

+ globalThis.process.env.NODE_ENV + +

+

+ __vite_process_env_NODE_ENV + +

+

import.meta.hot

+
+