Babel macro:`runCodeForEnvVar()` - run code conditionally on env var.

1. The need to run code conditionally based on env var

When we build a web app, we often feel the need to do something conditionally based on environment, for example:

  1. for local dev env : enable some hacky login
  2. for staging env: enable some debug menu, catch special errors
  3. for prod: don’t want any of above

Thus the goal is to run some code under certain condition but strip it totally otherwise.

There is already a convention to use NODE_ENV to determine a development build or production build, but since NODE_ENV targets the mode node.js is running, not exactly the product environment, we could try something like this.

js
if (process.env.TARGET === 'local') {
// do something only in local
}
if (process.env.TARGET === 'staging') {
// do something only on staging
}
js
if (process.env.TARGET === 'local') {
// do something only in local
}
if (process.env.TARGET === 'staging') {
// do something only on staging
}

But this doesn’t scale because it is hard to manage what are enabled under each product env. What if we want to enable something both for local and staging?

A better way might be explicit feature flags.

js
if (process.env.ENABLE_DEBUG_MENU) {
// enable debug
}
if (process.env.ENABLE_EXTRA_LOGGING) {
// enable extra logging
}
js
if (process.env.ENABLE_DEBUG_MENU) {
// enable debug
}
if (process.env.ENABLE_EXTRA_LOGGING) {
// enable extra logging
}

Then we can just set up the flags in build command.

shell
ENABLE_DEBUG_MENU=true ENABLE_EXTRA_LOGGING=true npm run build
shell
ENABLE_DEBUG_MENU=true ENABLE_EXTRA_LOGGING=true npm run build

2. Demo with Vite

For above code to work, we need to make sure that env vars are injected. We’ll run some code with Vite to demo the approaches.

2.1 default approach with process.env

First let’s just try process.env in an if.

console.log(process.env.VITE_ENABLE_DEBUG);
if (process.env.VITE_ENABLE_DEBUG === 'true') {
console.log('ENABLE_DEBUG');
}
console.log(process.env.VITE_ENABLE_DEBUG);
if (process.env.VITE_ENABLE_DEBUG === 'true') {
console.log('ENABLE_DEBUG');
}
{
"scripts": {
"build:staging": "VITE_ENABLE_DEBUG=true vite build",

only set up the flag under build:staging

"build:prod": "vite build"
}
}
{
"scripts": {
"build:staging": "VITE_ENABLE_DEBUG=true vite build",

only set up the flag under build:staging

"build:prod": "vite build"
}
}

Now let’s run npm run build:staging, here is what we get in the built assets.

js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");

We can see that process.env is replaced with {} and the value 'true' is actually not injected.

For npm run build:prod we actually get the same stuff.

js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");

The problem is that for the bundler, it actually doesn’t know what is process.env.VITE_ENABLE_DEBUG.

2.2 Global constant replacement by esbuild defines

In order to let Vite know the value of the env var, we need to define global constant replacement. We can change the vite config into below.

import { defineConfig } from 'vite';
export default defineConfig({
define: {
'process.env.VITE_ENABLE_DEBUG': JSON.stringify(
process.env.VITE_ENABLE_DEBUG
),
},
});
import { defineConfig } from 'vite';
export default defineConfig({
define: {
'process.env.VITE_ENABLE_DEBUG': JSON.stringify(
process.env.VITE_ENABLE_DEBUG
),
},
});

Now Vite knows the value, here is what we get from npm run build:staging.

js
console.log("true");console.log("ENABLE_DEBUG");
js
console.log("true");console.log("ENABLE_DEBUG");

For npm run build:prod, we can see that the if clause gets stripped.

console.log(void 0)

void 0 is undefined

console.log(void 0)

void 0 is undefined

So this result is what we want, it keeps the true branch and strips the false branch completely.

2.3 Global constant replacement by import.meta.env

It could be a bit verbose to manually define each env var in vite config, vite actually exposes env vars in import.meta.env if they are prefixed with VITE_. Let’s give it a try.

console.log(import.meta.env.VITE_ENABLE_DEBUG);
if (import.meta.env.VITE_ENABLE_DEBUG === 'true') {
console.log('ENABLE_DEBUG');
}
console.log(import.meta.env.VITE_ENABLE_DEBUG);
if (import.meta.env.VITE_ENABLE_DEBUG === 'true') {
console.log('ENABLE_DEBUG');
}

For npm run build:staging, we get what we want and it is the same as previous approach.

js
console.log("true");console.log("ENABLE_DEBUG");
js
console.log("true");console.log("ENABLE_DEBUG");

For npm run build:prod, it is quite different.

var s={BASE_URL:"/",MODE:"production",DEV:!1,PROD:!0,SSR:!1};

import.meta.env is inlined

console.log(s.VITE_ENABLE_DEBUG);s.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
var s={BASE_URL:"/",MODE:"production",DEV:!1,PROD:!0,SSR:!1};

import.meta.env is inlined

console.log(s.VITE_ENABLE_DEBUG);s.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");

We can see that it injects built-in env vars and also keep the runtime code. Though s.VITE_ENABLE_DEBUG is undefined so the false branch won’t get run, but it is not cool to have debug code sneak into production build. So this is not cool.

Actually we can improve this by explicitly set VITE_ENABLE_DEBUG to "false". Below is what we get from VITE_ENABLE_DEBUG=false vite build, we can see that the false branch is stripped.

js
console.log("false");
js
console.log("false");

This is not very straightforward, I’d expect it to work without explicitly setting the flag. To truly understand why it behaves like this, we’ll need to dive into the internals of Vite.

3. How Vite eliminates dead code and injects env vars.

What vite does is actually injecting the env vars by defining the replacement in the background for us. The replacement itself is provided by esbuild - a way to replace global identifiers with constant expressions.

3.1 Dead if branch is eliminated by esbuild’s minify

Built assets are optimized by esbuild’s minify, in which dead code are eliminated. In our case there is dead if branch if we set up the flag with values other than "true", if ("false" === "true"){...}.

The elimination code of if branches could be found here. It is written in go, I’ve never used it but we still could understand what is going on.

case *js_ast.SIf:

This means we try to optimize every if clause

s.Test = p.visitExpr(s.Test)
if p.options.minifySyntax {
s.Test = p.astHelpers.SimplifyBooleanExpr(s.Test)

This helper will simplify "false" === "true" into false

Source code could be found here

}
// Fold constants
boolean, _, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data)
// Mark the control flow as dead if the branch is never taken

The comment here says itself

if ok && !boolean {
old := p.isControlFlowDead
p.isControlFlowDead = true
s.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)
p.isControlFlowDead = old
} else {
s.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)
}
...
if p.options.minifySyntax {
return p.mangleIf(stmts, stmt.Loc, s)
}

If minify is enabled, then the actual stripping happens in mangleIf()

case *js_ast.SIf:

This means we try to optimize every if clause

s.Test = p.visitExpr(s.Test)
if p.options.minifySyntax {
s.Test = p.astHelpers.SimplifyBooleanExpr(s.Test)

This helper will simplify "false" === "true" into false

Source code could be found here

}
// Fold constants
boolean, _, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data)
// Mark the control flow as dead if the branch is never taken

The comment here says itself

if ok && !boolean {
old := p.isControlFlowDead
p.isControlFlowDead = true
s.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)
p.isControlFlowDead = old
} else {
s.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)
}
...
if p.options.minifySyntax {
return p.mangleIf(stmts, stmt.Loc, s)
}

If minify is enabled, then the actual stripping happens in mangleIf()

mangleIf() is the actual optimization.

func (p *parser) mangleIf(stmts []js_ast.Stmt, loc logger.Loc, s *js_ast.SIf) []js_ast.Stmt {
// Constant folding using the test expression
if boolean, sideEffects, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data); ok {
if boolean {
// The test is truthy
if s.NoOrNil.Data == nil || !shouldKeepStmtInDeadControlFlow(s.NoOrNil) {
// We can drop the "no" branch
if sideEffects == js_ast.CouldHaveSideEffects {
// Keep the condition if it could have side effects (but is still known to be truthy)
if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {
stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})
}
}
return appendIfOrLabelBodyPreservingScope(stmts, s.Yes)

If the condition is true, then we keep the true branch(Yes)

} else {
}
} else {
// The test is falsy
if !shouldKeepStmtInDeadControlFlow(s.Yes) {
// We can drop the "yes" branch
if sideEffects == js_ast.CouldHaveSideEffects {
// Keep the condition if it could have side effects (but is still known to be falsy)
if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {
stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})
}
}
if s.NoOrNil.Data == nil {
return stmts
}
return appendIfOrLabelBodyPreservingScope(stmts, s.NoOrNil)

If the condition is false, then we keep the false branch(NoOrNil)

} else {
// We have to keep the "yes" branch
}
}
}
...
}
func (p *parser) mangleIf(stmts []js_ast.Stmt, loc logger.Loc, s *js_ast.SIf) []js_ast.Stmt {
// Constant folding using the test expression
if boolean, sideEffects, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data); ok {
if boolean {
// The test is truthy
if s.NoOrNil.Data == nil || !shouldKeepStmtInDeadControlFlow(s.NoOrNil) {
// We can drop the "no" branch
if sideEffects == js_ast.CouldHaveSideEffects {
// Keep the condition if it could have side effects (but is still known to be truthy)
if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {
stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})
}
}
return appendIfOrLabelBodyPreservingScope(stmts, s.Yes)

If the condition is true, then we keep the true branch(Yes)

} else {
}
} else {
// The test is falsy
if !shouldKeepStmtInDeadControlFlow(s.Yes) {
// We can drop the "yes" branch
if sideEffects == js_ast.CouldHaveSideEffects {
// Keep the condition if it could have side effects (but is still known to be falsy)
if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {
stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})
}
}
if s.NoOrNil.Data == nil {
return stmts
}
return appendIfOrLabelBodyPreservingScope(stmts, s.NoOrNil)

If the condition is false, then we keep the false branch(NoOrNil)

} else {
// We have to keep the "yes" branch
}
}
}
...
}

Now we know how Vite(esbuild under the hood) eliminates the dead if branch.

3.2 How import.meta.env is injected in Vite.

First of all, loadEnv() collects all env vars from env files or commands.

export function loadEnv(
mode: string,
envDir: string,
prefixes: string | string[] = 'VITE_',
): Record<string, string> {
prefixes = arraify(prefixes)
const env: Record<string, string> = {}
const envFiles = getEnvFilesForMode(mode)
const parsed = Object.fromEntries(
envFiles.flatMap((file) => {
const filePath = path.join(envDir, file)
if (!tryStatSync(filePath)?.isFile()) return []
return Object.entries(parse(fs.readFileSync(filePath)))
}),
)
// test NODE_ENV override before expand as otherwise process.env.NODE_ENV
// would override this
if (parsed.NODE_ENV && process.env.VITE_USER_NODE_ENV === undefined) {
process.env.VITE_USER_NODE_ENV = parsed.NODE_ENV
}
// support BROWSER and BROWSER_ARGS env variables
if (parsed.BROWSER && process.env.BROWSER === undefined) {
process.env.BROWSER = parsed.BROWSER
}
if (parsed.BROWSER_ARGS && process.env.BROWSER_ARGS === undefined) {
process.env.BROWSER_ARGS = parsed.BROWSER_ARGS
}
// let environment variables use each other
// `expand` patched in patches/[email protected]
expand({ parsed })
// only keys that start with prefix are exposed to client
for (const [key, value] of Object.entries(parsed)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value
}
}

Filter out the env vars starting with "VITE_" from env files

// check if there are actual env variables starting with VITE_*
// these are typically provided inline and should be prioritized
for (const key in process.env) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = process.env[key] as string
}
}

Filter out the env vars starting with "VITE_" from commands

Which are what we used in previous demos

return env
}
export function loadEnv(
mode: string,
envDir: string,
prefixes: string | string[] = 'VITE_',
): Record<string, string> {
prefixes = arraify(prefixes)
const env: Record<string, string> = {}
const envFiles = getEnvFilesForMode(mode)
const parsed = Object.fromEntries(
envFiles.flatMap((file) => {
const filePath = path.join(envDir, file)
if (!tryStatSync(filePath)?.isFile()) return []
return Object.entries(parse(fs.readFileSync(filePath)))
}),
)
// test NODE_ENV override before expand as otherwise process.env.NODE_ENV
// would override this
if (parsed.NODE_ENV && process.env.VITE_USER_NODE_ENV === undefined) {
process.env.VITE_USER_NODE_ENV = parsed.NODE_ENV
}
// support BROWSER and BROWSER_ARGS env variables
if (parsed.BROWSER && process.env.BROWSER === undefined) {
process.env.BROWSER = parsed.BROWSER
}
if (parsed.BROWSER_ARGS && process.env.BROWSER_ARGS === undefined) {
process.env.BROWSER_ARGS = parsed.BROWSER_ARGS
}
// let environment variables use each other
// `expand` patched in patches/[email protected]
expand({ parsed })
// only keys that start with prefix are exposed to client
for (const [key, value] of Object.entries(parsed)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value
}
}

Filter out the env vars starting with "VITE_" from env files

// check if there are actual env variables starting with VITE_*
// these are typically provided inline and should be prioritized
for (const key in process.env) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = process.env[key] as string
}
}

Filter out the env vars starting with "VITE_" from commands

Which are what we used in previous demos

return env
}

Then the filtered env vars are returned as the resolved config(source).

const userEnv =
inlineConfig.envFile !== false &&
loadEnv(mode, envDir, resolveEnvPrefix(config))
resolved = {
configFile: configFile ? normalizePath(configFile) : undefined,
configFileDependencies: configFileDependencies.map((name) =>
normalizePath(path.resolve(name)),
),
inlineConfig,
root: resolvedRoot,
base: withTrailingSlash(resolvedBase),
rawBase: resolvedBase,
resolve: resolveOptions,
publicDir: resolvedPublicDir,
cacheDir,
command,
mode,
ssr,
isWorker: false,
mainConfig: null,
isProduction,
plugins: userPlugins,
css: resolveCSSOptions(config.css),
esbuild:
config.esbuild === false
? false
: {
jsxDev: !isProduction,
...config.esbuild,
},
server,
build: resolvedBuildOptions,
preview: resolvePreviewOptions(config.preview, server),
envDir,
env: {
...userEnv,
BASE_URL,
MODE: mode,
DEV: !isProduction,
PROD: isProduction,
},

BASE_URL, MODE,DEV,PROD are built-in env vars, they are injected no matter what

assetsInclude(file: string) {
return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)
},
logger,
packageCache,
createResolver,
optimizeDeps: {
disabled: 'build',
...optimizeDeps,
esbuildOptions: {
preserveSymlinks: resolveOptions.preserveSymlinks,
...optimizeDeps.esbuildOptions,
},
},
worker: resolvedWorkerOptions,
appType: config.appType ?? 'spa',
experimental: {
importGlobRestoreExtension: false,
hmrPartialAccept: false,
...config.experimental,
},
getSortedPlugins: undefined!,
getSortedPluginHooks: undefined!,
}
resolved = {
...config,
...resolved,
}
const userEnv =
inlineConfig.envFile !== false &&
loadEnv(mode, envDir, resolveEnvPrefix(config))
resolved = {
configFile: configFile ? normalizePath(configFile) : undefined,
configFileDependencies: configFileDependencies.map((name) =>
normalizePath(path.resolve(name)),
),
inlineConfig,
root: resolvedRoot,
base: withTrailingSlash(resolvedBase),
rawBase: resolvedBase,
resolve: resolveOptions,
publicDir: resolvedPublicDir,
cacheDir,
command,
mode,
ssr,
isWorker: false,
mainConfig: null,
isProduction,
plugins: userPlugins,
css: resolveCSSOptions(config.css),
esbuild:
config.esbuild === false
? false
: {
jsxDev: !isProduction,
...config.esbuild,
},
server,
build: resolvedBuildOptions,
preview: resolvePreviewOptions(config.preview, server),
envDir,
env: {
...userEnv,
BASE_URL,
MODE: mode,
DEV: !isProduction,
PROD: isProduction,
},

BASE_URL, MODE,DEV,PROD are built-in env vars, they are injected no matter what

assetsInclude(file: string) {
return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)
},
logger,
packageCache,
createResolver,
optimizeDeps: {
disabled: 'build',
...optimizeDeps,
esbuildOptions: {
preserveSymlinks: resolveOptions.preserveSymlinks,
...optimizeDeps.esbuildOptions,
},
},
worker: resolvedWorkerOptions,
appType: config.appType ?? 'spa',
experimental: {
importGlobRestoreExtension: false,
hmrPartialAccept: false,
...config.experimental,
},
getSortedPlugins: undefined!,
getSortedPluginHooks: undefined!,
}
resolved = {
...config,
...resolved,
}

The final step is to expose these env vars through esbuild defines, and this is done by the Vite’s internal define plugin.

import { transform } from 'esbuild'
import type { ResolvedConfig } from '../config'
import type { Plugin } from '../plugin'
import { escapeRegex, getHash } from '../utils'
import { isCSSRequest } from './css'
import { isHTMLRequest } from './html'
const nonJsRe = /\.json(?:$|\?)/
const isNonJsRequest = (request: string): boolean => nonJsRe.test(request)
export function definePlugin(config: ResolvedConfig): Plugin {
const isBuild = config.command === 'build'
const isBuildLib = isBuild && config.build.lib
// ignore replace process.env in lib build
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': `{}`,
'process.env.NODE_ENV': JSON.stringify(nodeEnv),
'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),
'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv),
})
}
// during dev, import.meta properties are handled by importAnalysis plugin.
const importMetaKeys: Record<string, string> = {}
const importMetaEnvKeys: Record<string, string> = {}
const importMetaFallbackKeys: Record<string, string> = {}
if (isBuild) {
importMetaKeys['undefined'] = `undefined`
for (const key in config.env) {
const val = JSON.stringify(config.env[key])
importMetaKeys[`import.meta.env.${key}`] = val
importMetaEnvKeys[key] = val
}

Notice that only the explicit defined env vars are collected

// these will be set to a proper value in `generatePattern`
importMetaKeys['import.meta.env.SSR'] = `undefined`
importMetaFallbackKeys['import.meta.env'] = `undefined`
}
const userDefine: Record<string, string> = {}
const userDefineEnv: Record<string, any> = {}
for (const key in config.define) {

This means the manual defines like what we did in 2.2

userDefine[key] = handleDefineValue(config.define[key])
// make sure `import.meta.env` object has user define properties
if (isBuild && key.startsWith('import.meta.env.')) {
userDefineEnv[key.slice(16)] = config.define[key]
}
}
function generatePattern(ssr: boolean) {
const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker'
const define: Record<string, string> = {
...(replaceProcessEnv ? processEnv : {}),
...importMetaKeys,
...userDefine,
...importMetaFallbackKeys,
}

This is the final define entries

// Additional define fixes based on `ssr` value
if ('import.meta.env.SSR' in define) {
define['import.meta.env.SSR'] = ssr + ''
}
if ('import.meta.env' in define) {
define['import.meta.env'] = serializeDefine({
...importMetaEnvKeys,
SSR: ssr + '',
...userDefineEnv,
})
}
// Create regex pattern as a fast check before running esbuild
const patternKeys = Object.keys(userDefine)
if (replaceProcessEnv && Object.keys(processEnv).length) {
patternKeys.push('process.env')
}
if (Object.keys(importMetaKeys).length) {
patternKeys.push('import.meta.env', 'undefined')
}
const pattern = patternKeys.length
? new RegExp(patternKeys.map(escapeRegex).join('|'))
: null
return [define, pattern] as const
}
const defaultPattern = generatePattern(false)
const ssrPattern = generatePattern(true)
return {
name: 'vite:define',
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. see the `clientInjection` and
// `importAnalysis` plugin.
return
}
if (
// exclude html, css and static assets for performance
isHTMLRequest(id) ||
isCSSRequest(id) ||
isNonJsRequest(id) ||
config.assetsInclude(id)
) {
return
}
const [define, pattern] = ssr ? ssrPattern : defaultPattern
if (!pattern) return
// Check if our code needs any replacements before running esbuild
pattern.lastIndex = 0
if (!pattern.test(code)) return
return await replaceDefine(code, id, define, config)
},
}
}
export async function replaceDefine(
code: string,
id: string,
define: Record<string, string>,
config: ResolvedConfig,
): Promise<{ code: string; map: string | null }> {
// Because esbuild only allows JSON-serializable values, and `import.meta.env`
// may contain values with raw identifiers, making it non-JSON-serializable,
// we replace it with a temporary marker and then replace it back after to
// workaround it. This means that esbuild is unable to optimize the `import.meta.env`
// access, but that's a tradeoff for now.
const replacementMarkers: Record<string, string> = {}
const env = define['import.meta.env']
if (env && !canJsonParse(env)) {
const marker = `_${getHash(env, env.length - 2)}_`
replacementMarkers[marker] = env
define = { ...define, 'import.meta.env': marker }
}
const esbuildOptions = config.esbuild || {}
const result = await transform(code, {
loader: 'js',
charset: esbuildOptions.charset ?? 'utf8',
platform: 'neutral',
define,

The define entries are used in esbuild transform

sourcefile: id,
sourcemap: config.command === 'build' ? !!config.build.sourcemap : true,
})
for (const marker in replacementMarkers) {
result.code = result.code.replaceAll(marker, replacementMarkers[marker])
}
return {
code: result.code,
-----------------

After code transform, import.meta.env.XXX is replaced with actual values/expressions

map: result.map || null,
}
}
import { transform } from 'esbuild'
import type { ResolvedConfig } from '../config'
import type { Plugin } from '../plugin'
import { escapeRegex, getHash } from '../utils'
import { isCSSRequest } from './css'
import { isHTMLRequest } from './html'
const nonJsRe = /\.json(?:$|\?)/
const isNonJsRequest = (request: string): boolean => nonJsRe.test(request)
export function definePlugin(config: ResolvedConfig): Plugin {
const isBuild = config.command === 'build'
const isBuildLib = isBuild && config.build.lib
// ignore replace process.env in lib build
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': `{}`,
'process.env.NODE_ENV': JSON.stringify(nodeEnv),
'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),
'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv),
})
}
// during dev, import.meta properties are handled by importAnalysis plugin.
const importMetaKeys: Record<string, string> = {}
const importMetaEnvKeys: Record<string, string> = {}
const importMetaFallbackKeys: Record<string, string> = {}
if (isBuild) {
importMetaKeys['undefined'] = `undefined`
for (const key in config.env) {
const val = JSON.stringify(config.env[key])
importMetaKeys[`import.meta.env.${key}`] = val
importMetaEnvKeys[key] = val
}

Notice that only the explicit defined env vars are collected

// these will be set to a proper value in `generatePattern`
importMetaKeys['import.meta.env.SSR'] = `undefined`
importMetaFallbackKeys['import.meta.env'] = `undefined`
}
const userDefine: Record<string, string> = {}
const userDefineEnv: Record<string, any> = {}
for (const key in config.define) {

This means the manual defines like what we did in 2.2

userDefine[key] = handleDefineValue(config.define[key])
// make sure `import.meta.env` object has user define properties
if (isBuild && key.startsWith('import.meta.env.')) {
userDefineEnv[key.slice(16)] = config.define[key]
}
}
function generatePattern(ssr: boolean) {
const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker'
const define: Record<string, string> = {
...(replaceProcessEnv ? processEnv : {}),
...importMetaKeys,
...userDefine,
...importMetaFallbackKeys,
}

This is the final define entries

// Additional define fixes based on `ssr` value
if ('import.meta.env.SSR' in define) {
define['import.meta.env.SSR'] = ssr + ''
}
if ('import.meta.env' in define) {
define['import.meta.env'] = serializeDefine({
...importMetaEnvKeys,
SSR: ssr + '',
...userDefineEnv,
})
}
// Create regex pattern as a fast check before running esbuild
const patternKeys = Object.keys(userDefine)
if (replaceProcessEnv && Object.keys(processEnv).length) {
patternKeys.push('process.env')
}
if (Object.keys(importMetaKeys).length) {
patternKeys.push('import.meta.env', 'undefined')
}
const pattern = patternKeys.length
? new RegExp(patternKeys.map(escapeRegex).join('|'))
: null
return [define, pattern] as const
}
const defaultPattern = generatePattern(false)
const ssrPattern = generatePattern(true)
return {
name: 'vite:define',
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. see the `clientInjection` and
// `importAnalysis` plugin.
return
}
if (
// exclude html, css and static assets for performance
isHTMLRequest(id) ||
isCSSRequest(id) ||
isNonJsRequest(id) ||
config.assetsInclude(id)
) {
return
}
const [define, pattern] = ssr ? ssrPattern : defaultPattern
if (!pattern) return
// Check if our code needs any replacements before running esbuild
pattern.lastIndex = 0
if (!pattern.test(code)) return
return await replaceDefine(code, id, define, config)
},
}
}
export async function replaceDefine(
code: string,
id: string,
define: Record<string, string>,
config: ResolvedConfig,
): Promise<{ code: string; map: string | null }> {
// Because esbuild only allows JSON-serializable values, and `import.meta.env`
// may contain values with raw identifiers, making it non-JSON-serializable,
// we replace it with a temporary marker and then replace it back after to
// workaround it. This means that esbuild is unable to optimize the `import.meta.env`
// access, but that's a tradeoff for now.
const replacementMarkers: Record<string, string> = {}
const env = define['import.meta.env']
if (env && !canJsonParse(env)) {
const marker = `_${getHash(env, env.length - 2)}_`
replacementMarkers[marker] = env
define = { ...define, 'import.meta.env': marker }
}
const esbuildOptions = config.esbuild || {}
const result = await transform(code, {
loader: 'js',
charset: esbuildOptions.charset ?? 'utf8',
platform: 'neutral',
define,

The define entries are used in esbuild transform

sourcefile: id,
sourcemap: config.command === 'build' ? !!config.build.sourcemap : true,
})
for (const marker in replacementMarkers) {
result.code = result.code.replaceAll(marker, replacementMarkers[marker])
}
return {
code: result.code,
-----------------

After code transform, import.meta.env.XXX is replaced with actual values/expressions

map: result.map || null,
}
}

Now we know why the dead code is still kept in prod build when the env var is not set - because only explicit defined env vars are injected through esbuild defines. And also this explains why we can be sure to get what we want by VITE_ENABLE_DEBUG=false vite build.

But still it doesn’t feel straightforward, because the dead code actually won’t cause runtime issues, people might just forget setting the env vars and think everything is fine but actually it is not.

4. A possibly better approach with babel macro

With all above explanations, what I want to demonstrate is that above approaches heavily rely on bundler settings and it is quite a lot to understand what really is going on and get it right. What if we can eliminate the code even before bundler kicks in? Then we could no longer worry about the bundler internals!

Ok here comes the true hero of this post - babel macro. We actually have another approach - eliminate dead code earlier by AST manipulating.

Since vite 4, both swc and babel are supported for React project. You should choose what suits you, in this post I’m introducing the power of macros so will use the babel plugin to demonstrate.

The first step is to enable macros in babel config.

js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['macros'],
},
}),
],
});
js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['macros'],
},
}),
],
});

Second step is to use the actual macro, I’ve published the macro to npm - jser.macro.

js
import runCodeForEnvVar from 'jser.macro/dist/runCodeForEnvVar.macro.cjs';
console.log('runCodeForEnvVar()');
runCodeForEnvVar('VITE_ENABLE_DEBUG', () => {
console.log('ENABLE_DEBUG');
});
js
import runCodeForEnvVar from 'jser.macro/dist/runCodeForEnvVar.macro.cjs';
console.log('runCodeForEnvVar()');
runCodeForEnvVar('VITE_ENABLE_DEBUG', () => {
console.log('ENABLE_DEBUG');
});

It might look a bit more complex, but actually it is straightforward because we have the freedom to change the function name, we can make it explicit with runCodeForEnvVarOrStripIt().

What it does is the same as the function name - run the code if process.env.VITE_ENABLE_DEBUG is "true" or just strip the code otherwise.

Now if we npm run build:staging, we see the if branch getting bundled.

js
console.log("runCodeForEnvVar()");console.log("ENABLE_DEBUG");
js
console.log("runCodeForEnvVar()");console.log("ENABLE_DEBUG");

For npm run build:prod, we see the dead branch getting eliminated even if we don’t set the env var explicitly.

js
console.log("runCodeForEnvVar()");
js
console.log("runCodeForEnvVar()");

Fantastic, right? We no longer need to worry about bundler setting since the code is stripped at a very early stage before bundlers handle it.

You can find the code of the macro on github, here let me just briefly explain it.

import { MacroParams, createMacro, MacroError } from "babel-plugin-macros";
import * as t from "@babel/types";
function macro({ references }: MacroParams) {
const { default: runCodeForEnvVar } = references;
runCodeForEnvVar.forEach((path) => {
const call = path.parent;
if (t.isCallExpression(call)) {

runCodeForEnvVar() must be used as a function call

const [firstArg, secondArg] = call.arguments;
if (!t.isStringLiteral(firstArg)) {
throw new MacroError(
"1st argument of runCodeForEnvVar() must be a string literal"
);
}
if (
!(
t.isArrowFunctionExpression(secondArg) ||
t.isFunctionExpression(secondArg)
)
) {
throw new MacroError(
"2nd argument of runCodeForEnvVar() must be a function expression."
);
}

Validations of the arguments

// now check if the env var matches
const isValid = process.env[firstArg.value] === "true";
if (isValid) {
path.parentPath?.replaceWith(t.callExpression(secondArg, []));
} else {
path.parentPath?.remove();
}

This is a bit similar to the esbuild minify logic

If truthy, keep the callback, strip everything otherwise

} else {
throw new MacroError(
"runCodeForEnvVar should be called as runCodeForEnvVar(envVarName, function)"
);
}
});
}
export default createMacro(macro);
import { MacroParams, createMacro, MacroError } from "babel-plugin-macros";
import * as t from "@babel/types";
function macro({ references }: MacroParams) {
const { default: runCodeForEnvVar } = references;
runCodeForEnvVar.forEach((path) => {
const call = path.parent;
if (t.isCallExpression(call)) {

runCodeForEnvVar() must be used as a function call

const [firstArg, secondArg] = call.arguments;
if (!t.isStringLiteral(firstArg)) {
throw new MacroError(
"1st argument of runCodeForEnvVar() must be a string literal"
);
}
if (
!(
t.isArrowFunctionExpression(secondArg) ||
t.isFunctionExpression(secondArg)
)
) {
throw new MacroError(
"2nd argument of runCodeForEnvVar() must be a function expression."
);
}

Validations of the arguments

// now check if the env var matches
const isValid = process.env[firstArg.value] === "true";
if (isValid) {
path.parentPath?.replaceWith(t.callExpression(secondArg, []));
} else {
path.parentPath?.remove();
}

This is a bit similar to the esbuild minify logic

If truthy, keep the callback, strip everything otherwise

} else {
throw new MacroError(
"runCodeForEnvVar should be called as runCodeForEnvVar(envVarName, function)"
);
}
});
}
export default createMacro(macro);

5. Summary

Phew, this is a lot but quite interesting journey, right?

We’ve not only learned how a babel macro helps with our need of conditionally logic based on environment, but also touched a bit on vite/esbuild’s internals.

This idea of manipulating AST tree on the fly is quite powerful, it could help simplify our code or build pipeline. Search keywords:babel-plugin-macros on npm to get some inspiration!

😳 Would you like to share my post to more people ?    

❮ Prev: The Internals of Styled Components