This document explains how vite-plus is built and how it re-exports from @voidzero-dev/vite-plus-core (bundled vite/rolldown/tsdown) and from upstream vitest to serve as a drop-in replacement for vite.
The CLI package uses a 4-step build process:
- tsdown Build - Bundle all CLI entry points via tsdown
- NAPI Binding Build - Compile Rust code to native Node.js bindings
- Core Package Export Sync - Re-export
@voidzero-dev/vite-plus-coreunder./client,./types/*, etc. - Test Package Export Sync - Re-export upstream
vitestunder./test/*
This architecture allows users to import everything from a single package (vite-plus) as a drop-in replacement for vite, without needing to know about the separate @voidzero-dev/vite-plus-core bundle or vitest.
The CLI declares its core dependency as vite, using
workspace:@voidzero-dev/vite-plus-core@* in the workspace and an exact npm
alias in packed releases. Runtime imports and generated shims use vite and
its subpaths. This gives the CLI and plugins the same dependency name and
avoids separate core instances under the alias and canonical package name.
resolve-core.ts resolves the alias from the selected CLI package and checks
its core version. It also checks any Vite dependency declared by the command's
target project. An incidental hoisted peer does not trigger project validation.
These checks run before Vite or packaging commands start. Keep the canonical
name in release metadata and alias targets to identify the published package.
For npm projects without overrides, pin a declared vite alias to the exact
installed vite-plus version. npm can replace a tagged alias such as
npm:@voidzero-dev/vite-plus-core@latest with upstream Vite to satisfy Vitest's
peer range. The CLI rejects that mismatch and directs users to vp migrate,
which writes an exact alias and the package-manager overrides. The npm fixtures
check shared core identity with an exact alias and rejection of the tagged layout.
Bundles all CLI entry points using tsdown (configured in tsdown.config.ts). The config defines two builds:
ESM build — bundles all entry points to dist/:
- Public API entries:
bin,index,define-config,fmt,lint,pack,pack-bin - Global command entries:
create,migrate,version,config,hooks,mcp,staged - All third-party dependencies are inlined at build time
- Only packages that must be resolved at runtime stay external (NAPI binding,
vite,vitest,oxfmt,oxlint) - Code splitting creates shared chunks for code used by multiple entries
- DTS (
.d.ts) files are generated for all entries
CJS build — produces dual-format output for:
define-config.ts→dist/define-config.cjsindex.cts→dist/index.cjs
Input: src/**/*.ts, src/**/*.cts
Output: dist/*.js, dist/*.cjs, dist/*.d.ts, dist/*-<hash>.js (shared chunks)
Builds native Rust bindings using @napi-rs/cli:
const cli = new NapiCli();
await cli.build({
packageJsonPath: '../package.json',
cwd: 'binding',
platform: true,
release: process.env.VP_CLI_DEBUG !== '1',
esm: true,
});Input: binding/*.rs (Rust source)
Output: binding/*.node (platform-specific binaries)
The build generates platform-specific native binaries and formats the generated JavaScript wrapper with oxfmt.
Creates shim files that re-export from @voidzero-dev/vite-plus-core, enabling this package to be a drop-in replacement for upstream vite. This is critical for compatibility with existing Vite plugins and configurations.
Prerequisites: The core package must be built first (its dist/vite/ directory must exist). See Core Package Bundling for details on how the core package bundles vite, rolldown, and tsdown.
Export paths created:
| Export Path | Type | Description |
|---|---|---|
./client |
Types only | Triple-slash reference for ambient type declarations (CSS modules, asset imports, etc.) |
./module-runner |
JS + Types | Re-exports the Vite module runner for SSR/environments |
./internal |
JS + Types | Re-exports internal Vite APIs |
./dist/client/* |
JS | Client runtime files (.mjs, .cjs) |
./types/* |
Types only | Type-only re-exports using export type * |
./types/internal/* |
Blocked | Set to null to prevent access to internal types |
Shim file examples:
// dist/client.d.ts (triple-slash reference for ambient types)
/// <reference types="vite/client" />
// dist/module-runner.js
export * from 'vite/module-runner';
// dist/types/importMeta.d.ts (type-only export)
export type * from 'vite/types/importMeta.d.ts';Note on export ordering: In package.json, the ./types/internal/* export (set to null) must appear before ./types/* for correct precedence. More specific patterns must precede wildcards.
Reads Vitest's exports plus the Playwright and Preview provider packages and creates re-export shims under ./test/*.
Ship vite as a dependency alias for the same @voidzero-dev/vite-plus-core version. Vitest v5 requires a vite peer. Yarn users must be able to install vite-plus without adding vite or vitest to their project. Keep the alias in dependencies, not devDependencies.
The main surface mirrors Vitest 5.0.1, except for package metadata and wildcard
exports. Export tests snapshot the final keys. Vite+ 1.0 removes the legacy
coverage, reporters, environments, and snapshot aliases under
vite-plus/test/. Run vp migrate to replace imports from either vitest/*
or vite-plus/test/* with the node or runtime entry:
| Removed path suffix | Replacement |
|---|---|
coverage, reporters |
vite-plus/test/node |
environments, snapshot |
vite-plus/test/runtime |
The vite-plus/test/mocker shim re-exports @vitest/mocker; migration still
uses this path because Vitest v5 has no corresponding entry.
Playwright and Preview provider paths and browser/providers/* aliases remain
available. WebDriverIO has no Vite+ shims. Migration restores its legacy aliases
to the community-maintained @vitest/browser-webdriverio package. Keep the
optional peer declaration synchronized with Vitest's dependency metadata.
The plugins/* compatibility paths mirror public exports of their named
standalone packages and retain upstream type declarations.
Removed runner, suite, expect-plugin, and internal module-runner paths have no
partial shim. Use TestRunner and expect from vite-plus/test where the root
API supports the old symbol. The migration reports uses without a replacement.
The resolver uses an explicit package allowlist, so standalone @vitest/expect,
deprecated @vitest/ws-client, and @vitest/istanbul-lib-* dependencies keep
their own project resolution.
Inline projects inherit the declaring config's Vite+ plugins. Independent
projects (extends: false or an external base) receive their own plugins.
Referenced configs that use defineConfig or defineProject receive the same
integration, including nested projects. Raw referenced configs retain upstream
resolution and its soft coverage-version warning when no Vite+ hook runs.
// For each vitest export like "./node"
// Creates a shim file: dist/test/node.js
export * from 'vitest/node';
// For each @vitest/browser-* provider, two shim surfaces are projected:
// dist/test/browser-playwright.js (matches old wrapper path)
// dist/test/browser/providers/playwright.js (alias path)
export * from '@vitest/browser-playwright';Provider .d.ts shims are NOT bare re-exports — see the Provider Type Identity note below.
Input: resolved vitest/package.json exports plus each @vitest/browser-* package's exports (all resolved via createRequire)
Output: dist/test/*.js, dist/test/*.d.ts, updated package.json exports
packages/cli/
├── dist/
│ ├── bin.js # CLI entry point (bundled)
│ ├── index.js # Main entry (ESM, bundled)
│ ├── index.cjs # Main entry (CJS)
│ ├── index.d.ts # Type declarations
│ ├── define-config.js # Config helper (ESM)
│ ├── define-config.cjs # Config helper (CJS)
│ ├── define-config.d.ts
│ ├── fmt.js # Re-exports oxfmt
│ ├── lint.js # Re-exports oxlint types
│ ├── pack.js # Re-exports vite-plus-core/pack
│ ├── pack-bin.js # tsdown CLI for `vp pack`
│ ├── create.js # Global command: vp create
│ ├── migrate.js # Global command: vp migrate
│ ├── version.js # Global command: vp --version
│ ├── config/bin.js # Global command: vp config
│ ├── hooks/bin.js # Global command: vp hooks
│ ├── mcp.js # Global command: vp mcp
│ ├── staged/bin.js # Global command: vp staged
│ ├── *-<hash>.js # Shared chunks (code splitting)
│ ├── versions.js # Generated tool versions
│ ├── client.d.ts # ./client types (triple-slash ref)
│ ├── module-runner.js # ./module-runner shim
│ ├── internal.js # ./internal shim
│ ├── client/ # Synced client runtime files
│ ├── types/ # Synced type definitions
│ └── test/ # Synced test exports
├── binding/
│ ├── index.js # NAPI binding JS wrapper
│ ├── index.d.ts # NAPI type declarations
│ └── *.node # Platform-specific binaries
└── bin/
└── vp # Shell entry point
The CLI builds native bindings for the following platform targets:
| Target | Platform | Architecture | Output File |
|---|---|---|---|
aarch64-apple-darwin |
macOS | ARM64 | vite-plus.darwin-arm64.node |
x86_64-apple-darwin |
macOS | x64 | vite-plus.darwin-x64.node |
aarch64-unknown-linux-gnu |
Linux | ARM64 glibc | vite-plus.linux-arm64-gnu.node |
aarch64-unknown-linux-musl |
Linux | ARM64 musl | vite-plus.linux-arm64-musl.node |
x86_64-unknown-linux-gnu |
Linux | x64 glibc | vite-plus.linux-x64-gnu.node |
x86_64-unknown-linux-musl |
Linux | x64 musl | vite-plus.linux-x64-musl.node |
aarch64-pc-windows-msvc |
Windows | ARM64 | vite-plus.win32-arm64-msvc.node |
x86_64-pc-windows-msvc |
Windows | x64 | vite-plus.win32-x64-msvc.node |
These targets are defined in package.json under the napi.targets field.
The CLI package integrates with Rolldown at the native binding level, allowing vite-plus to ship as a self-contained package without requiring users to install separate @rolldown/binding-* packages.
Rolldown bindings are optionally compiled into the vite-plus native module via Cargo feature flags.
In binding/Cargo.toml:
[dependencies]
rolldown_binding = { workspace = true, optional = true }
[features]
rolldown = ["dep:rolldown_binding"]In binding/src/lib.rs:
#[cfg(feature = "rolldown")]
pub extern crate rolldown_binding;The rolldown feature is only enabled during release builds:
// In build.ts
await cli.build({
features: process.env.RELEASE_BUILD ? ['rolldown'] : void 0,
release: process.env.VP_CLI_DEBUG !== '1',
});When RELEASE_BUILD=1:
- Enables the
rolldownCargo feature - Compiles
rolldown_bindinginto the.nodefile - Extracts
napi.dtsHeaderfrom rolldown's package.json for type definitions - Prepends custom type definitions to the generated
.d.tsfile
| Build Type | rolldown Feature | Use Case |
|---|---|---|
Development (pnpm build) |
Disabled | Faster builds, smaller binaries |
Release (RELEASE_BUILD=1) |
Enabled | Full distribution with bundled rolldown |
During release builds, the core package rewrites each supported @rolldown/binding-* import to the matching Vite+ platform package (see packages/core/build-support/rewrite-rolldown-binding.ts):
Transformation examples:
| Original Import | After Rewrite |
|---|---|
@rolldown/binding-darwin-arm64 |
@voidzero-dev/vite-plus-darwin-arm64 |
@rolldown/binding-linux-x64-gnu |
@voidzero-dev/vite-plus-linux-x64-gnu |
@rolldown/binding-win32-x64-msvc |
@voidzero-dev/vite-plus-win32-x64-msvc |
This means:
- The bundled rolldown code in
@voidzero-dev/vite-plus-core/rolldownresolves native bindings through core's own declared optional dependencies (injected at publish time bypublish-native-addons.ts) - Users don't need to install separate
@rolldown/binding-*platform packages - The platform
.nodefile contains both vite-plus task runner and rolldown bindings
When compiled with RELEASE_BUILD=1, the .node file contains:
| Component | Source | Purpose |
|---|---|---|
vt |
packages/cli/binding/src/lib.rs |
Task runner session management |
rolldown_binding |
rolldown/crates/rolldown_binding |
Rolldown bundler NAPI bindings |
User imports 'vite-plus/rolldown'
→ packages/cli re-exports from @voidzero-dev/vite-plus-core/rolldown
→ packages/core/dist/rolldown/index.mjs
→ Native binding: @voidzero-dev/vite-plus-darwin-arm64
(rewritten from @rolldown/binding-darwin-arm64)
→ vite-plus.darwin-arm64.node (contains rolldown_binding)
Native bindings are published as separate platform packages for optimal install size:
| Platform | Published Package |
|---|---|
| macOS ARM64 | @voidzero-dev/vite-plus-darwin-arm64 |
| macOS x64 | @voidzero-dev/vite-plus-darwin-x64 |
| Linux ARM64 glibc | @voidzero-dev/vite-plus-linux-arm64-gnu |
| Linux ARM64 musl | @voidzero-dev/vite-plus-linux-arm64-musl |
| Linux x64 glibc | @voidzero-dev/vite-plus-linux-x64-gnu |
| Linux x64 musl | @voidzero-dev/vite-plus-linux-x64-musl |
| Windows ARM64 | @voidzero-dev/vite-plus-win32-arm64-msvc |
| Windows x64 | @voidzero-dev/vite-plus-win32-x64-msvc |
These are automatically installed via optionalDependencies based on the user's platform. publish-native-addons.ts injects the exact-pinned entries into both vite-plus (via napi-rs prePublish) and @voidzero-dev/vite-plus-core during publish; the committed package.json files carry none of them.
See publish-native-addons.ts for the publishing pipeline.
The CLI package creates thin shim files that re-export from @voidzero-dev/vite-plus-core rather than bundling the actual code. This approach:
- Enables drop-in replacement - Users can replace
vitewithvite-pluswithout changing imports - Keeps packages in sync - No need to rebuild CLI when core package changes
- Reduces duplication - No file copying, just re-exports
- Preserves module resolution - Node.js resolves to the actual core package
Note: The @voidzero-dev/vite-plus-core package itself bundles multiple upstream projects (vite, rolldown, tsdown). See Core Package Bundling for details.
| Upstream Vite Export | CLI Package Export | Description |
|---|---|---|
vite/client |
vite-plus/client |
Ambient types for HMR, CSS modules, assets |
vite/module-runner |
vite-plus/module-runner |
SSR/Environment module runner |
vite/internal |
vite-plus/internal |
Internal APIs |
vite/dist/client/* |
vite-plus/dist/client/* |
Client runtime files |
vite/types/* |
vite-plus/types/* |
Type definitions |
For ./types/* exports, shim files use export type * syntax (TypeScript 5.0+) to ensure only type information is re-exported:
// dist/types/importMeta.d.ts
export type * from 'vite/types/importMeta.d.ts';This is important because ./types/* only exposes .d.ts files and should never include runtime code.
The ./types/internal/* export is set to null in package.json to block access to internal type definitions:
"./types/internal/*": null,
"./types/*": { "types": "./dist/types/*" }The syncTypesDir() helper skips the top-level internal directory when creating shims, since access is blocked at the exports level.
The ./client export uses a triple-slash reference instead of a regular export because Vite's client.d.ts contains ambient type declarations (for CSS modules, assets, etc.) that should be globally available:
// dist/client.d.ts
/// <reference types="vite/client" />This allows TypeScript to pick up types like import.meta.hot, CSS module types, and asset imports without explicit imports.
Instead of copying vitest's dist files, we create thin shim files that re-export from vitest. This approach:
- Keeps packages in sync - No need to rebuild CLI when vitest is upgraded
- Reduces duplication - No file copying, just re-exports
- Preserves module resolution - Node.js resolves to the actual installed vitest
Every entry under vitest's own exports is shimmed under ./test/* (wildcard exports and ./package.json are skipped). The shim is purely a re-export — vite-plus/test and friends are aliases for the matching subpath of upstream vitest. Examples:
| Vitest Export | CLI Package Export |
|---|---|
vitest |
vite-plus/test |
vitest/browser |
vite-plus/test/browser |
vitest/node |
vite-plus/test/node |
vitest/config |
vite-plus/test/config |
vitest/runtime |
vite-plus/test/runtime |
The full set is regenerated on every build from the upstream vitest package.json, so the exact list tracks vitest itself.
In addition to vitest's own exports, the official browser providers have these aliases:
| Provider Package | CLI Package Exports |
|---|---|
@vitest/browser-playwright |
vite-plus/test/browser-playwright, vite-plus/test/browser/providers/playwright |
@vitest/browser-preview |
vite-plus/test/browser-preview, vite-plus/test/browser/providers/preview |
Each provider's own subpaths (e.g. ./context) are mirrored under both alias prefixes.
Browser-context runtime aliases re-export vitest/browser, the virtual entry used by Vitest v5. This applies to provider /context paths, ./test/browser/context, ./test/context, and ./test/plugins/browser-context. Their declarations retain the upstream browser-context types and module augmentations. The upstream @vitest/browser/context JavaScript file is now an error stub, not the browser runtime.
@vitest/browser and @vitest/browser-preview are bundled runtime dependencies. Playwright stays an optional peer and a development dependency for shim generation. Migration keeps the Playwright provider in the user's dependencies, aligns it with bundled Vitest, and ensures its framework peer.
Vite+ 1.0 has no WebDriverIO exports or shims. Users import from the community-maintained @vitest/browser-webdriverio and manage its versions and peers. The optional peer declaration remains synchronized with Vitest's dependency metadata; the development dependency supports compatibility tests only. Migration restores legacy provider aliases to the community package and moves legacy runtime /context aliases to the shared vite-plus/test/browser/context entry.
Provider .d.ts shims are NOT plain export * from '@vitest/browser-playwright' re-exports — they inline the upstream .d.ts content with vitest/node / vitest/browser / @vitest/browser* bare specifiers rewritten to relative paths inside dist/test/. The two private shims dist/test/_at-vitest-browser.d.ts and dist/test/_at-vitest-browser/context.d.ts re-export @vitest/browser/@vitest/browser/context and are referenced from those rewrites.
This avoids a pnpm-edge type-identity split: when the upstream .d.ts is loaded by reference (export * from '@vitest/browser-playwright'), TypeScript resolves its internal import { BrowserProvider } from 'vitest/node' through the provider package's own pnpm-edge, which can be a different vitest copy than the one a user's vite.config.ts sees through vite-plus. The mismatch produces two structurally identical but nominally distinct BrowserProvider types, so provider: playwright() fails the user's typecheck. Rewriting the specifiers routes every type import through vite-plus's own subpath shims, guaranteeing a single vitest identity across the user's whole config.
The copied Playwright declarations still import playwright directly. Declare it as an optional peer of vite-plus, matching the provider's upstream peer range, so isolated dependency layouts can resolve these types from the copied files.
The sync handles complex conditional exports with import/require/node/types conditions.
Vitest's main export ("."):
".": {
"import": { "types": "...", "node": "...", "default": "..." },
"require": { "types": "...", "default": "..." }
}Becomes CLI package export ("./test"):
"./test": {
"import": {
"types": "./dist/test/index.d.ts",
"node": "./dist/test/index.js",
"default": "./dist/test/index.js"
},
"require": {
"types": "./dist/test/index.d.cts",
"default": "./dist/test/index.cjs"
}
}For each condition, appropriate shim files are created:
.jsfor ESM imports.cjsfor CommonJS requires.d.ts/.d.ctsfor type declarations
ESM shim (dist/test/browser.js):
export * from 'vitest/browser';CJS shim (dist/test/index.cjs):
module.exports = require('vitest');Type shim (dist/test/browser.d.ts):
import 'vitest/browser';
export * from 'vitest/browser';Note: Type shims include a side-effect import to preserve module augmentations (e.g., toMatchSnapshot on the Assertion interface).
| Package | Purpose |
|---|---|
@napi-rs/cli |
NAPI build toolchain for Rust |
oxfmt |
Code formatting for generated JS |
tsdown |
TypeScript bundling |
To build with debug (unoptimized) Rust bindings:
VP_CLI_DEBUG=1 pnpm buildThis sets release: false in the NAPI build options, producing larger but faster-to-compile debug binaries.
# Build the CLI package (requires core package to be built first)
pnpm -C packages/cli build
# Build from monorepo root (builds all dependencies first)
pnpm build --filter vite-plus
# Debug build
VP_CLI_DEBUG=1 pnpm -C packages/cli buildAfter building, the CLI package exports:
| Export Path | Description |
|---|---|
. |
Main entry (CLI utilities) |
./client |
Client types (ambient declarations) |
./module-runner |
Vite module runner for SSR |
./internal |
Internal Vite APIs |
./dist/client/* |
Client runtime files |
./types/* |
Type definitions |
./bin |
CLI binary entry point |
./binding |
NAPI native binding |
./test |
Test package main entry |
./test/browser |
Browser testing utilities |
./test/browser-playwright |
Playwright integration |
./test/plugins/* |
Plugin shims for pnpm overrides |
./package.json |
Package metadata |
See package.json for the complete list of exports.
1. buildWithTsdown() tsdown bundle -> dist/*.js, dist/*.d.ts
2. buildNapiBinding() Rust -> binding/*.node (per platform)
3. syncCorePackageExports() Read core pkg dist -> dist/client/, dist/types/
├── createClientShim() Triple-slash reference for ./client
├── createModuleRunnerShim() JS + types for ./module-runner
├── createInternalShim() JS + types for ./internal
├── syncClientDir() Shims for ./dist/client/*
└── syncTypesDir() Type-only shims for ./types/*
4. syncTestPackageExports() Read test pkg exports -> dist/test/*
├── createShimForExport() Generate shim files
├── createConditionalShim() Handle import/require conditions
└── updateCliPackageJson() Update exports in package.json
// Core package name for Vite compatibility exports
const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
const CORE_IMPORT_SPECIFIER = 'vite';
// Test package name for re-exports (vitest itself, not a bundled wrapper)
const TEST_PACKAGE_NAME = 'vitest';The exports field in package.json has two categories: manual and automated.
All non-./test* exports are manually maintained in package.json. These fall into two groups:
CLI-native exports — point to CLI's own bundled TypeScript (built by buildWithTsdown() via tsdown):
| Export | Description |
|---|---|
. |
Main entry (CLI utilities) |
./bin |
CLI binary entry point |
./binding |
NAPI native binding |
./lint |
Lint utilities |
./pack |
Pack utilities |
./package.json |
Package metadata |
Core shim exports — point to shim files auto-generated by syncCorePackageExports() that re-export from @voidzero-dev/vite-plus-core. The shim files are regenerated on each build, but the package.json entries themselves are manual:
| Export | Description |
|---|---|
./client |
Triple-slash reference for ambient type declarations (CSS modules, etc) |
./module-runner |
Vite module runner for SSR/environments |
./internal |
Internal Vite APIs |
./dist/client/* |
Client runtime files |
./types/internal/* |
Blocked (null) to prevent access to internal types |
./types/* |
Type-only re-exports |
Note: The core package's own exports (which the shims point to) are generated upstream by packages/tools/src/sync-remote-deps.ts. See Core Package Bundling for details.
All ./test* exports are fully managed by syncTestPackageExports(). The build script:
- Reads vitest's
package.jsonexports (resolved viacreateRequire) - Creates shim files in
dist/test/ - Removes old
./test*exports frompackage.json - Merges in newly generated test exports
- Relies on the existing
distentry to include the generateddist/testshims