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
vite-plugin-auto-all(TypeScript implementation)- Support:
virtual:auto-api+virtual:env(dev & build)- Provide ES modules with named exports to participate in build-time tree-shaking
- Generate
.d.tstype declaration file (rollup-plugin-dts)- 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 declarationdist/index.d.ts. - No side effects: package.json
sideEffects: falseand 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
└─ .npmignore3. Core source code (TypeScript)
Place the following files into src/.
src/virtualEnv.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
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)
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):
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
tscfirst to emit JS anddist/types/index.d.ts, then bundle both with Rollup. The input path matchesrootDirbelow.
5. tsconfig.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
{
"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)
- 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.
- package.json
sideEffects: false: Tells the packager that the module has no side effects, allowing tree shaking. - Generate ESM output: The
modulefield points to the ES moduledist/index.esm.js, Rollup/webpack can perform tree-shaking better. - Build availability: the plugin is not limited to serve.
resolveId/loadrun in dev and build;buildStartvalidates the scan in advance.
8. Usage examples
Install in target project:
npm i -D vite-plugin-auto-allvite.config.ts:
import { defineConfig } from 'vite'
import autoAll from 'vite-plugin-auto-all'
export default defineConfig({
plugins: [ autoAll({ apiDir: 'src/composables' }) ]
})Use:
// 可以直接从虚拟模块导入
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.tsin the consuming project and declare actual business exports; do not edit generateddist/index.d.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-dtscan help merge.d.tsintodist/index.d.ts.
10. Release process (checklist + command)
- Update
package.json(name/version/author/license).Make sure package names are unique. npm run build(or CI automation)- Login npm:
npm login - Publish:
npm publish --access public(if it is a scoped package, use--access public) - 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
- Install local packages (
npm packoryarn add ../path/to/your/plugin) in a test project. - In the test project
import { unusedFunc } from 'virtual:auto-api'Do not reference it in the code, buildnpm run build. - Check the packaged product (such as
dist/assets/*.js) and confirm thatunusedFuncdoes not appear in the final bundle (or use source-map analysis). - Add an API source file with a top-level log to verify that the test detects retained side effects; plugin-package
sideEffects: falsedoes 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 importto generate import statements based on usage location on demand (closer to unplugin-auto-import). - Generate
env.d.tsforvirtual:envand supportimport.meta.envreplacement (AST + magic-string), makingimport.meta.env.FOOtype-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
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 name | value |
|---|---|
NPM_TOKEN | npm’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.0GitHub Actions automatically:
- Install Node environment
npm ciinstall dependencies- Run test
npm run build- Publish to npm using
npm publish - Create a GitHub Release with
dist/**files
🔧 If you also want to support Beta/Alpha releases
For example:
v1.1.0-beta.1Then just keep the same tag rules, and npm will publish according to versions.