Scope: demonstrate client env loading and static replacement with Vite 5/6 plugin APIs; this is not a full copy of Vite internals. Install dependencies:
npm i -D @babel/parser@7 @babel/traverse@7 magic-string@0.30. Only read-only env expressions in JS/TS/JSX/TSX are transformed.
This plugin demonstrates part of import.meta.env: loading public variables, distinguishing modes and replacing syntax nodes.
🚀Ultimate goal
With your own plugin, developers can:
console.log(import.meta.env.VITE_API_URL)The example covers these common cases:
- ✔ Automatically read
.env* - ✔ Distinguish between
mode(development / production) - ✔ Only expose
VITE_prefix variables - ✔ Automatically inject
BASE_URL,MODE,DEV,PROD - ✔ Replace
import.meta.envon client side - ✔Support HMR
SSR, HTML placeholders, third-party plugin ordering and full compatibility are outside this example.
🧩 Vite’s built-in import.meta.env principle
Vite's import.meta.env Not a virtual module , instead:
1. Parse env files by pattern
.env
.env.local
.env.development
.env.development.local
...2. Merge out "User-Accessible Environment Variables"
Expose only:
VITE_prefix variableMODEDEVPRODBASE_URL
This constitutes the final:
import.meta.env = {
MODE: 'development',
DEV: true,
PROD: false,
BASE_URL: '/',
VITE_API_URL: '...',
...
}3. Development transforms and build-time replacement
Vite processes the following expression during the relevant transform phase; implementation depends on phase and version:
import.meta.env.VITE_API_URLReplace with static literal string:
"some-url"4. Env changes and development server restart
🧨 Implement a teaching subset
The name is:
vite-plugin-env-core🏗️ 1. Large plug-in framework
import path from 'node:path'
import { loadEnv as viteLoadEnv } from 'vite'
import { parse } from '@babel/parser'
import traverseModule from '@babel/traverse'
import MagicString from 'magic-string'
const traverse = traverseModule.default || traverseModule
export default function envCorePlugin() {
let config
let env = {}
return {
name: 'vite-plugin-env-core',
enforce: 'pre',
configResolved(resolved) {
config = resolved
env = loadClientEnv(config)
},
transform(code, id) {
if (id.includes('node_modules') || !/\.[cm]?[jt]sx?$/.test(id.split('?')[0])) return
if (!code.includes('import.meta.env')) return
return transformImportMetaEnv(code, env, id)
},
configureServer(server) {
const files = ['.env', '.env.local', '.env.' + config.mode, '.env.' + config.mode + '.local']
.map(file => path.resolve(config.envDir, file))
server.watcher.add(files)
const refresh = file => {
if (!files.includes(path.resolve(file))) return
env = loadClientEnv(config)
server.moduleGraph.invalidateAll()
server.ws.send({ type: 'full-reload', path: '*' })
}
for (const event of ['add', 'change', 'unlink']) server.watcher.on(event, refresh)
server.watcher.once('close', () => {
for (const event of ['add', 'change', 'unlink']) server.watcher.off(event, refresh)
})
}
}
}Next we complete each part.
🧠 II. Reuse the Vite env loader
Vite original logic (after simplification):
const files = ['.env', '.env.local', '.env.' + mode, '.env.' + mode + '.local']Use Vite loadEnv to preserve expansion, process-variable precedence and file merging:
function loadClientEnv(config) {
return {
...viteLoadEnv(config.mode, config.envDir, 'VITE_'),
MODE: config.mode,
DEV: !config.isProduction,
PROD: config.isProduction,
BASE_URL: config.base
}
}MODE comes from mode; DEV / PROD come from config.isProduction. --mode staging does not imply a development build.
🎯 3. Implement transformImportMetaEnv (core)
Goal: Put all code in:
import.meta.env.XReplaced with static literals.
For example:
console.log(import.meta.env.VITE_API_URL)Replace with:
console.log("https://example.com")Implementation:
function transformImportMetaEnv(code, env, id = "module.js") {
const filename = id.split('?')[0]
const plugins = /\.[cm]?ts$/.test(filename) ? ['typescript'] : ['typescript', 'jsx']
const ast = parse(code, { sourceType: 'module', plugins })
const output = new MagicString(code)
const isEnv = node => node?.type === 'MemberExpression' && !node.computed
&& node.property.type === 'Identifier' && node.property.name === 'env'
&& node.object.type === 'MetaProperty' && node.object.meta.name === 'import'
&& node.object.property.name === 'meta'
traverse(ast, {
MemberExpression(p) {
const node = p.node
if (isEnv(node.object)) {
const key = !node.computed && node.property.type === 'Identifier'
? node.property.name
: node.computed && node.property.type === 'StringLiteral' ? node.property.value : null
if (key !== null) {
const value = Object.hasOwn(env, key) ? JSON.stringify(env[key]) : 'undefined'
output.overwrite(node.start, node.end, value)
p.skip()
return
}
}
if (isEnv(node)) {
output.overwrite(node.start, node.end, '(' + JSON.stringify(env) + ')')
p.skip()
}
}
})
return { code: output.toString(), map: output.generateMap({ hires: true }) }
}The AST walk changes expressions only, preserving matching text inside strings and comments, and returns a source map.
🔄 4. Implement HMR behavior (simulate Vite)
Vite normally requires a restart after env-file edits; this example adds its own watcher:
→ Reload public variables, clear transform caches and reload the page
We also simulate this:
In the plugin:
// Inside configureServer: handle add/change/unlink, including create and delete.
// Refresh env, invalidate cached transforms, then send:
env = loadClientEnv(config)
server.moduleGraph.invalidateAll()
server.ws.send({ type: 'full-reload', path: '*' })This is the plugin refresh strategy, not automatic HMR for built-in Vite env. The next section includes the complete watcher.
🧪 5. Complete plug-in code (can be run directly)
import path from 'node:path'
import { loadEnv as viteLoadEnv } from 'vite'
import { parse } from '@babel/parser'
import traverseModule from '@babel/traverse'
import MagicString from 'magic-string'
const traverse = traverseModule.default || traverseModule
function loadClientEnv(config) {
return {
...viteLoadEnv(config.mode, config.envDir, 'VITE_'),
MODE: config.mode,
DEV: !config.isProduction,
PROD: config.isProduction,
BASE_URL: config.base
}
}
function transformImportMetaEnv(code, env, id = "module.js") {
const filename = id.split('?')[0]
const plugins = /\.[cm]?ts$/.test(filename) ? ['typescript'] : ['typescript', 'jsx']
const ast = parse(code, { sourceType: 'module', plugins })
const output = new MagicString(code)
const isEnv = node => node?.type === 'MemberExpression' && !node.computed
&& node.property.type === 'Identifier' && node.property.name === 'env'
&& node.object.type === 'MetaProperty' && node.object.meta.name === 'import'
&& node.object.property.name === 'meta'
traverse(ast, {
MemberExpression(p) {
const node = p.node
if (isEnv(node.object)) {
const key = !node.computed && node.property.type === 'Identifier'
? node.property.name
: node.computed && node.property.type === 'StringLiteral' ? node.property.value : null
if (key !== null) {
const value = Object.hasOwn(env, key) ? JSON.stringify(env[key]) : 'undefined'
output.overwrite(node.start, node.end, value)
p.skip()
return
}
}
if (isEnv(node)) {
output.overwrite(node.start, node.end, '(' + JSON.stringify(env) + ')')
p.skip()
}
}
})
return { code: output.toString(), map: output.generateMap({ hires: true }) }
}
export default function envCorePlugin() {
let config
let env = {}
return {
name: 'vite-plugin-env-core',
enforce: 'pre',
configResolved(resolved) {
config = resolved
env = loadClientEnv(config)
},
transform(code, id) {
if (id.includes('node_modules') || !/\.[cm]?[jt]sx?$/.test(id.split('?')[0])) return
if (!code.includes('import.meta.env')) return
return transformImportMetaEnv(code, env, id)
},
configureServer(server) {
const files = ['.env', '.env.local', '.env.' + config.mode, '.env.' + config.mode + '.local']
.map(file => path.resolve(config.envDir, file))
server.watcher.add(files)
const refresh = file => {
if (!files.includes(path.resolve(file))) return
env = loadClientEnv(config)
server.moduleGraph.invalidateAll()
server.ws.send({ type: 'full-reload', path: '*' })
}
for (const event of ['add', 'change', 'unlink']) server.watcher.on(event, refresh)
server.watcher.once('close', () => {
for (const event of ['add', 'change', 'unlink']) server.watcher.off(event, refresh)
})
}
}
}The plugin demonstrates a limited client scenario; prefer built-in Vite env in application code.
🧠 6. Functions we have achieved
| Features | Implementation |
|---|---|
| Multiple environment loading (.env.*) | ✔ |
| mode analysis | ✔ |
| Expose only VITE_ prefix | ✔ |
| BASE_URL / MODE / DEV / PROD | ✔ |
| import.meta.env static replacement | ✔ |
| JS/TS/JSX/TSX scanning | ✔ |
| Env file HMR | ✔ (full page refresh) |
This is not a complete Vite env system; SSR and raw Vue SFCs are not covered.