Skip to content
Created: August 18, 2024

Scope: a publishing example for Vite 5/6, TypeScript 5 and Node.js 20.19+ / 22. API scanning supports named functions, classes, simple variables and explicit export aliases, excluding export *, destructured exports and type exports. Plugin-package types and application virtual-module types need separate declarations.

Resolve virtual modules in dev and build; the bundler performs tree-shaking at build time. This article includes plugin declarations (.d.ts), scripts, Rollup configuration and publishing steps.

target output

  1. vite-plugin-auto-all (TypeScript implementation)
  2. Support: virtual:auto-api + virtual:env (dev & build)
  3. Provide ES modules with named exports to participate in build-time tree-shaking
  4. Generate .d.ts type declaration file (rollup-plugin-dts)
  5. Package.json that can be directly npm publish + release process description

1. Design points ​

  • Tree-shaking principle: named exports aid static analysis. Removing unused exports also depends on source-module side effects, bundler settings and access patterns. Default-exporting an object does not guarantee property-level removal.
  • Dev mode: provide virtual modules through resolveId/load; invalidate caches and reload on file changes.
  • Build mode: the plugin also runs build hooks and emits named re-exports. Referenced API source files may still have side effects.
  • TypeScript support: The code is written in TypeScript, packaged output dist/ (ESM & CJS), and generates type declaration dist/index.d.ts.
  • No side effects: package.json sideEffects: false and let the output be ES modules.

2. Project structure ​

vite-plugin-auto-all/
├─ src/
│  ├─ index.ts             // 插件入口
│  ├─ virtualApi.ts        // 生成 virtual:auto-api 内容逻辑
│  ├─ virtualEnv.ts        // 生成 virtual:env 内容逻辑
│  └─ (Options is exported from index.ts)
├─ rollup.config.mjs
├─ tsconfig.json
├─ package.json
├─ README.md
└─ .npmignore

3. Core source code (TypeScript) ​

Place the following files into src/.

src/virtualEnv.ts ​

ts
import { loadEnv } from 'vite'

export function loadClientEnv(envDir: string, mode: string, prefix = 'VITE_') {
  if (!prefix) throw new Error('envPrefix must not be empty')
  return loadEnv(mode, envDir, prefix)
}

export function generateEnvModuleCode(
  env: Record<string, string | boolean>,
  extra: Record<string, string | boolean> = {}
) {
  const merged = { ...env, ...extra }
  const named = Object.entries(merged)
    .filter(([key]) => /^[A-Z_][A-Z0-9_]*$/.test(key))
    .map(([key, value]) => 'export const ' + key + ' = ' + JSON.stringify(value) + ';')
  return named.join('\n') + '\nexport default ' + JSON.stringify(merged) + ';\n'
}

src/virtualApi.ts ​

ts
import fs from 'node:fs/promises'
import path from 'node:path'
import { parse } from '@babel/parser'

export type ExportsMap = Record<string, { id: string; names: string[] }>

export async function scanApiDir(root: string, apiDir: string) {
  const files: string[] = []
  async function walk(dir: string) {
    for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
      const file = path.join(dir, entry.name)
      if (entry.isDirectory()) await walk(file)
      else if (/\.[jt]sx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) files.push(file)
    }
  }
  try {
    await walk(path.resolve(root, apiDir))
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
    return { files: [], exports: {} as ExportsMap }
  }
  const exportsMap: ExportsMap = {}
  for (const file of files.sort()) {
    const ast = parse(await fs.readFile(file, 'utf8'), {
      sourceType: 'module', plugins: ['typescript', 'jsx']
    })
    const names = new Set<string>()
    for (const node of ast.program.body) {
      if (node.type === 'ExportAllDeclaration') throw new Error('Unsupported export *: ' + file)
      if (node.type !== 'ExportNamedDeclaration' || node.exportKind === 'type') continue
      const declaration = node.declaration
      if (declaration) {
        if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
          if (!declaration.declare && declaration.id) names.add(declaration.id.name)
        } else if (declaration.type === 'VariableDeclaration' && !declaration.declare) {
          for (const item of declaration.declarations) {
            if (item.id.type !== 'Identifier') throw new Error('Unsupported destructured export: ' + file)
            names.add(item.id.name)
          }
        }
      }
      for (const item of node.specifiers) {
        if (item.type === 'ExportSpecifier' && item.exportKind === 'type') continue
        const exported = item.exported
        const name = exported.type === 'Identifier' ? exported.name : exported.value
        if (name === 'default') continue
        if (!/^[$A-Z_a-z][\w$]*$/.test(name)) throw new Error('Unsupported export name: ' + name)
        names.add(name)
      }
    }
    if (names.size) {
      const id = '/@fs/' + file.replace(/\\/g, '/')
      exportsMap[id] = { id, names: [...names] }
    }
  }
  return { files, exports: exportsMap }
}

export function generateAutoApiModuleCode(exportsMap: ExportsMap) {
  const owners = new Set<string>()
  const lines: string[] = []
  for (const { id, names } of Object.values(exportsMap)) {
    for (const name of names) {
      if (owners.has(name)) throw new Error('Duplicate API export: ' + name)
      owners.add(name)
    }
    lines.push('export { ' + names.join(', ') + ' } from ' + JSON.stringify(id) + ';')
  }
  return lines.join('\n') || 'export {}'
}

src/index.ts (plug-in entry) ​

ts
import type { Plugin, ResolvedConfig } from 'vite'
import path from 'node:path'
import { loadClientEnv, generateEnvModuleCode } from './virtualEnv.js'
import { scanApiDir, generateAutoApiModuleCode } from './virtualApi.js'

export interface Options {
  apiDir?: string
  envPrefix?: string
}

const API_ID = '\0virtual:auto-api'
const ENV_ID = '\0virtual:env'

export default function autoAllPlugin(options: Options = {}): Plugin {
  const apiDir = options.apiDir || 'src/composables'
  const prefix = options.envPrefix ?? 'VITE_'
  let config: ResolvedConfig
  let env: Record<string, string | boolean> = {}
  const readEnv = () => ({
    ...loadClientEnv(config.envDir, config.mode, prefix),
    MODE: config.mode, DEV: !config.isProduction,
    PROD: config.isProduction, BASE_URL: config.base
  })
  return {
    name: 'vite-plugin-auto-all',
    configResolved(resolved) { config = resolved; env = readEnv() },
    resolveId(id) {
      if (id === 'virtual:auto-api') return API_ID
      if (id === 'virtual:env') return ENV_ID
    },
    async load(id) {
      if (id === ENV_ID) return generateEnvModuleCode(env)
      if (id === API_ID) {
        const result = await scanApiDir(config.root, apiDir)
        return generateAutoApiModuleCode(result.exports)
      }
    },
    async buildStart() {
      env = readEnv()
      const result = await scanApiDir(config.root, apiDir)
      generateAutoApiModuleCode(result.exports)
    },
    configureServer(server) {
      const apiRoot = path.resolve(config.root, apiDir)
      const envFiles = ['.env', '.env.local', '.env.' + config.mode, '.env.' + config.mode + '.local']
        .map(file => path.resolve(config.envDir, file))
      server.watcher.add([apiRoot, ...envFiles])
      const refresh = (file: string) => {
        const absolute = path.resolve(file)
        const relative = path.relative(apiRoot, absolute)
        const inApi = relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)
        let id: string | undefined
        if (envFiles.includes(absolute)) { env = readEnv(); id = ENV_ID }
        else if (inApi && /\.[jt]sx?$/.test(absolute)) id = API_ID
        if (!id) return
        const mod = server.moduleGraph.getModuleById(id)
        if (mod) server.moduleGraph.invalidateModule(mod)
        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)
      })
    }
  }
}

Note: scanning uses an AST and preserves explicit aliases; duplicate exports throw. Type exports are skipped and unsupported forms need extensions. Syntax and permission errors are not suppressed.

4. Packaging configuration (Rollup) ​

rollup.config.mjs (written in ESM):

mjs
import { readFileSync } from 'node:fs'
import dts from 'rollup-plugin-dts'

const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
const packages = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]
const external = id => id.startsWith('node:') || packages.some(name => id === name || id.startsWith(name + '/'))

export default [
  {
    input: 'dist/index.js',
    output: [
      { file: pkg.module, format: 'es', sourcemap: true },
      { file: pkg.main, format: 'cjs', sourcemap: true, exports: 'named' }
    ],
    external
  },
  {
    input: 'dist/types/index.d.ts',
    output: [{ file: pkg.types, format: 'es' }],
    external,
    plugins: [dts()]
  }
]

Note: run tsc first to emit JS and dist/types/index.d.ts, then bundle both with Rollup. The input path matches rootDir below.

5. tsconfig.json ​

json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "rootDir": "src",
    "declaration": true,
    "declarationDir": "dist/types",
    "outDir": "dist",
    "noEmitOnError": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}

6. package.json ​

json
{
  "name": "vite-plugin-auto-all",
  "version": "1.0.0",
  "description": "Vite virtual API and public env modules",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.esm.js",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
    "build:ts": "tsc -p tsconfig.json",
    "build:rollup": "rollup -c",
    "build": "npm run clean && npm run build:ts && npm run build:rollup",
    "prepare": "npm run build"
  },
  "keywords": ["vite", "plugin", "virtual", "env"],
  "author": "Your Name",
  "license": "MIT",
  "dependencies": {
    "@babel/parser": "^7.26.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "rollup": "^3.0.0",
    "rollup-plugin-dts": "^5.0.0",
    "typescript": "^5.9.0",
    "vite": "^5.4.21"
  },
  "peerDependencies": {
    "vite": "^5.4.0 || ^6.0.0"
  }
}

7. Key things to note before publishing (let tree-shaking take effect) ​

  1. Exports: prefer named exports. The generated layer only re-exports; source API top-level side effects still affect removal. Object-property removal is not guaranteed.
  2. package.json sideEffects: false: Tells the packager that the module has no side effects, allowing tree shaking.
  3. Generate ESM output: The module field points to the ES module dist/index.esm.js, Rollup/webpack can perform tree-shaking better.
  4. Build availability: the plugin is not limited to serve. resolveId/load run in dev and build; buildStart validates the scan in advance.

8. Usage examples ​

Install in target project:

bash
npm i -D vite-plugin-auto-all

vite.config.ts:

ts
import { defineConfig } from 'vite'
import autoAll from 'vite-plugin-auto-all'

export default defineConfig({
  plugins: [ autoAll({ apiDir: 'src/composables' }) ]
})

Use:

ts
// 可以直接从虚拟模块导入
import { useUser } from 'virtual:auto-api'
import { VITE_API_URL } from 'virtual:env'

// 也可 default import env
import env from 'virtual:env'

Unused named exports can usually be removed, subject to source-module side effects and the actual build output.

9. Suggestions for generating type (.d.ts) ​

  • Plugin option types are exported from src/index.ts; they do not automatically generate application virtual-module types.
  • Create src/virtual-modules.d.ts in the consuming project and declare actual business exports; do not edit generated dist/index.d.ts:
ts
// Consumer-side ambient declarations; this file has no top-level import/export.
declare module 'virtual:env' {
  const env: Record<string, string | boolean>
  export default env
  export const MODE: string
  export const DEV: boolean
  export const PROD: boolean
  export const BASE_URL: string
  export const VITE_API_URL: string
}
declare module 'virtual:auto-api' {
  export const useUser: typeof import('./composables/useUser').useUser
}
  • rollup-plugin-dts can help merge .d.ts into dist/index.d.ts.

10. Release process (checklist + command) ​

  1. Update package.json (name/version/author/license).Make sure package names are unique.
  2. npm run build (or CI automation)
  3. Login npm: npm login
  4. Publish: npm publish --access public (if it is a scoped package, use --access public)
  5. Tag and Release (optional): git tag v1.0.0 && git push --tags

CI example: publish on pushed tags matching v*; commit the lockfile for npm ci.

11. How to locally verify that tree-shaking is effective ​

  1. Install local packages (npm pack or yarn add ../path/to/your/plugin) in a test project.
  2. In the test project import { unusedFunc } from 'virtual:auto-api' Do not reference it in the code, build npm run build.
  3. Check the packaged product (such as dist/assets/*.js) and confirm that unusedFunc does not appear in the final bundle (or use source-map analysis).
  4. Add an API source file with a top-level log to verify that the test detects retained side effects; plugin-package sideEffects: false does not eliminate application source side effects.

12. Subsequent enhancement suggestions (you can choose to implement them) ​

  • Extend AST scanning for export *, destructured exports and fuller TypeScript syntax.
  • Support auto import to generate import statements based on usage location on demand (closer to unplugin-auto-import).
  • Generate env.d.ts for virtual:env and support import.meta.env replacement (AST + magic-string), making import.meta.env.FOO type-checked and auto-completed in TS.
  • Provides a list of commonly used APIs built into preset (such as Vue preset, React preset).
  • During the build phase, explicitly emit the virtual module into a chunk (this.emitFile({ type: 'chunk', id: virtualId })) to allow for finer rollup control (optional).

GitHub Actions automatically publish to npm ​

You just need to put it into:

.github/workflows/publish.yml

✅ GitHub Actions: Automatic build + Automatic release npm + Automatic creation of Release ​

yaml
name: Publish to npm

on:
  push:
    tags:
      - "v*" # 例如 v1.0.0 时触发

permissions:
  contents: write
jobs:
  build-and-publish:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test --if-present

      - name: Build plugin
        run: npm run build

      # ---- 发布到 npm ----
      - name: Publish to npm
        run: npm publish --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

      # ---- 自动创建 GitHub Release ----
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            dist/**

🔐 Requires Secrets you set in GitHub ​

Enter the repository → Settings → Secrets → Actions → New repository secret

Secret namevalue
NPM_TOKENnpm’s publish token (npmjs.com → Access Tokens → Generate Fine-grained Token)

📌 Workflow triggering method ​

Every time you push a tag:

git tag v1.0.0
git push origin v1.0.0

GitHub Actions automatically:

  1. Install Node environment
  2. npm ci install dependencies
  3. Run test
  4. npm run build
  5. Publish to npm using npm publish
  6. Create a GitHub Release with dist/** files

🔧 If you also want to support Beta/Alpha releases ​

For example:

v1.1.0-beta.1

Then just keep the same tag rules, and npm will publish according to versions.