Skip to content
Created: August 9, 2024

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:

js
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.env on 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 variable
  • MODE
  • DEV
  • PROD
  • BASE_URL

This constitutes the final:

ts
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_URL

Replace with static literal string:

js
"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 ​

js
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):

ts
const files = ['.env', '.env.local', '.env.' + mode, '.env.' + mode + '.local']

Use Vite loadEnv to preserve expansion, process-variable precedence and file merging:

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

Replaced with static literals.

For example:

js
console.log(import.meta.env.VITE_API_URL)

Replace with:

js
console.log("https://example.com")

Implementation:

js
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:

js
// 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) ​

js
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 ​

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