Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(define): handle replacement with esbuild #11151

Merged
merged 18 commits into from
Oct 26, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions docs/config/shared-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,7 @@ See [Env Variables and Modes](/guide/env-and-mode) for more details.

Define global constant replacements. Entries will be defined as globals during dev and statically replaced during build.

- 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`).

- 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.

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.
:::
Vite uses [esbuild defines](https://esbuild.github.io/api/#define) to perform replacements, so value expressions must either be a JSON object (null, boolean, number, string, array, or object) or a single identifier. For example, if defining a string constant, it needs to be explicitly quoted with `JSON.stringify`.

::: tip NOTE
For TypeScript users, make sure to add the type declarations in the `env.d.ts` or `vite-env.d.ts` file to get type checks and Intellisense.
Expand Down
11 changes: 3 additions & 8 deletions packages/vite/src/node/__tests__/plugins/define.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ async function createDefinePluginTransform(
const config = await resolveConfig({ define }, build ? 'build' : 'serve')
const instance = definePlugin(config)
return async (code: string) => {
// @ts-expect-error
const result = await instance.transform.call({}, code, 'foo.ts', { ssr })
return result?.code || result
}
Expand All @@ -20,21 +21,15 @@ describe('definePlugin', () => {
const transform = await createDefinePluginTransform({
__APP_VERSION__: JSON.stringify('1.0')
})
expect(await transform('const version = __APP_VERSION__ ;')).toBe(
'const version = "1.0" ;'
)
expect(await transform('const version = __APP_VERSION__;')).toBe(
'const version = "1.0";'
'const version = "1.0";\n'
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Esbuild transform tend to semi-format the entire code so this is changed here. That also means we can't do stable sourcemaps transform.

)
})

test('replaces import.meta.env.SSR with false', async () => {
const transform = await createDefinePluginTransform()
expect(await transform('const isSSR = import.meta.env.SSR ;')).toBe(
'const isSSR = false ;'
)
expect(await transform('const isSSR = import.meta.env.SSR;')).toBe(
'const isSSR = false;'
'const isSSR = false;\n'
)
})
})
11 changes: 8 additions & 3 deletions packages/vite/src/node/plugins/clientInjections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Plugin } from '../plugin'
import type { ResolvedConfig } from '../config'
import { CLIENT_ENTRY, ENV_ENTRY } from '../constants'
import { isObject, normalizePath, resolveHostname } from '../utils'
import { replaceDefine } from './define'

// ids in transform are normalized to unix style
const normalizedClientEntry = normalizePath(CLIENT_ENTRY)
Expand Down Expand Up @@ -64,10 +65,14 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
// 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,
const nodeEnv =
config.define?.['process.env.NODE_ENV'] ||
JSON.stringify(process.env.NODE_ENV || config.mode)
JSON.stringify(process.env.NODE_ENV || config.mode)
return await replaceDefine(
code,
id,
{ 'process.env.NODE_ENV': nodeEnv },
config
)
}
}
Expand Down
100 changes: 36 additions & 64 deletions packages/vite/src/node/plugins/define.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import MagicString from 'magic-string'
import { transform } from 'esbuild'
import type { ResolvedConfig } from '../config'
import type { Plugin } from '../plugin'
import { transformStableResult } from '../utils'
import { isCSSRequest } from './css'
import { isHTMLRequest } from './html'

Expand All @@ -13,21 +12,21 @@ export function definePlugin(config: ResolvedConfig): Plugin {
const isBuildLib = isBuild && config.build.lib

// ignore replace process.env in lib build
const processEnv: Record<string, string> = {}
const processNodeEnv: Record<string, string> = {}
const processEnv: Record<string, string> = {}
if (!isBuildLib) {
const nodeEnv = process.env.NODE_ENV || config.mode
Object.assign(processEnv, {
'process.env.': `({}).`,
'global.process.env.': `({}).`,
'globalThis.process.env.': `({}).`
})
Object.assign(processNodeEnv, {
'process.env.NODE_ENV': JSON.stringify(nodeEnv),
'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),
'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv),
__vite_process_env_NODE_ENV: JSON.stringify(nodeEnv)
})
Object.assign(processEnv, {
'process.env': `{}`,
'global.process.env': `{}`,
'globalThis.process.env': `{}`
})
}

const userDefine: Record<string, string> = {}
Expand All @@ -49,15 +48,12 @@ export function definePlugin(config: ResolvedConfig): Plugin {
importMetaKeys[`import.meta.env.${key}`] = JSON.stringify(env[key])
}
Object.assign(importMetaFallbackKeys, {
'import.meta.env.': `({}).`,
'import.meta.env': JSON.stringify(config.env),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is fine as esbuild will share a single reference to import.meta.env object as a variable, so it shouldn't increase the bundle size by much after bundling

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: not exactly fine, as in the bundle size will technically increase, but multiple usages of import.meta.env should be deduplicated now. There isn't a way to fix the former with esbuild define restriction

'import.meta.hot': `false`
})
}

function generatePattern(
ssr: boolean
): [Record<string, string | undefined>, RegExp | null] {
function generatePattern(ssr: boolean) {
const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker'

const replacements: Record<string, string> = {
Expand All @@ -72,38 +68,20 @@ export function definePlugin(config: ResolvedConfig): Plugin {
replacements['__vite_process_env_NODE_ENV'] = 'process.env.NODE_ENV'
}

const replacementsKeys = Object.keys(replacements)
const pattern = replacementsKeys.length
? new RegExp(
// Mustn't be preceded by a char that can be part of an identifier
// or a '.' that isn't part of a spread operator
'(?<![\\p{L}\\p{N}_$]|(?<!\\.\\.)\\.)(' +
replacementsKeys
.map((str) => {
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')
})
.join('|') +
// Mustn't be followed by a char that can be part of an identifier
// or an assignment (but allow equality operators)
')(?:(?<=\\.)|(?![\\p{L}\\p{N}_$]|\\s*?=[^=]))',
'gu'
)
: null

return [replacements, pattern]
return Object.keys(replacements).length ? replacements : null
}

const defaultPattern = generatePattern(false)
const ssrPattern = generatePattern(true)
const defaultReplacements = generatePattern(false)
const ssrReplacements = generatePattern(true)

return {
name: 'vite:define',

transform(code, id, options) {
async transform(code, id, options) {
const ssr = options?.ssr === true
if (!ssr && !isBuild) {
// for dev we inject actual global defines in the vite client to
// avoid the transform cost.
// avoid the transform cost. see the clientInjection plugin.
return
}

Expand All @@ -117,36 +95,30 @@ 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 replacements = ssr ? ssrReplacements : defaultReplacements
if (!replacements) return

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)
return await replaceDefine(code, id, replacements, config)
}
}
}

export async function replaceDefine(
code: string,
id: string,
replacements: Record<string, string>,
config: ResolvedConfig
): Promise<{ code: string; map: string | null }> {
const result = await transform(code, {
loader: 'js',
charset: 'utf8',
platform: 'neutral',
define: replacements,
sourcefile: id,
sourcemap: config.command === 'build' && !!config.build.sourcemap
})
return {
code: result.code,
map: result.map || null
}
}
60 changes: 51 additions & 9 deletions playground/define/__tests__/define.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { expect, test } from 'vitest'
import viteConfig from '../vite.config'
import { isBuild, page } from '~utils'
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__))
)
Expand Down Expand Up @@ -44,10 +44,52 @@ test('string', async () => {
expect(await page.textContent('.define-in-dep')).toBe(
defines.__STRINGIFIED_OBJ__
)
expect(await page.textContent('.import-meta-env-undefined')).toBe(
isBuild ? '({}).UNDEFINED' : 'import.meta.env.UNDEFINED'
)
expect(await page.textContent('.process-env-undefined')).toBe(
isBuild ? '({}).UNDEFINED' : 'process.env.UNDEFINED'
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test isn't relevant for esbuild transform anymore.

})

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')
})
4 changes: 1 addition & 3 deletions playground/define/commonjs-dep/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
module.exports = {
defined: __STRINGIFIED_OBJ__,
importMetaEnvUndefined: 'import.meta.env.UNDEFINED',
processEnvUndefined: 'process.env.UNDEFINED'
defined: __STRINGIFIED_OBJ__
}
Loading