This document explains how vite-plus is built and how it re-exports from both the core and test packages to serve as a drop-in replacement for vite.
The CLI package uses a 4-step build process:
- TypeScript Compilation - Compile TypeScript source to JavaScript
- 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
@voidzero-dev/vite-plus-testunder./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 core and test packages.
Compiles TypeScript source files using the TypeScript compiler API:
const program = createProgram({
rootNames: fileNames,
options,
host,
});
program.emit();Input: src/*.ts files
Output: dist/*.js, dist/*.d.ts
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="@voidzero-dev/vite-plus-core/client" />
// dist/module-runner.js
export * from '@voidzero-dev/vite-plus-core/module-runner';
// dist/types/importMeta.d.ts (type-only export)
export type * from '@voidzero-dev/vite-plus-core/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 the test package's exports and creates shim files that re-export everything under ./test/*:
// For each test package export like "./browser-playwright"
// Creates a shim file: dist/test/browser-playwright.js
export * from '@voidzero-dev/vite-plus-test/browser-playwright';Input: ../test/package.json exports
Output: dist/test/*.js, dist/test/*.d.ts, updated package.json exports
packages/cli/
├── dist/
│ ├── index.js # Main entry (ESM)
│ ├── index.cjs # Main entry (CJS)
│ ├── index.d.ts # Type declarations
│ ├── bin.js # CLI entry point
│ ├── client.d.ts # ./client types (triple-slash ref)
│ ├── module-runner.js # ./module-runner shim
│ ├── module-runner.d.ts
│ ├── internal.js # ./internal shim
│ ├── internal.d.ts
│ ├── client/ # Synced client runtime files
│ │ ├── client.mjs # ESM client shim
│ │ ├── client.d.ts
│ │ ├── env.mjs
│ │ └── ...
│ ├── types/ # Synced type definitions
│ │ ├── importMeta.d.ts # Type shims (export type *)
│ │ ├── importGlob.d.ts
│ │ ├── customEvent.d.ts
│ │ └── ...
│ └── test/ # Synced test exports
│ ├── index.js # Re-exports @voidzero-dev/vite-plus-test
│ ├── index.cjs
│ ├── index.d.ts
│ ├── browser-playwright.js
│ ├── browser-playwright.d.ts
│ ├── plugins/
│ │ ├── runner.js
│ │ ├── utils.js
│ │ ├── spy.js
│ │ └── ... (33+ plugin shims)
│ └── ...
├── binding/
│ ├── index.js # NAPI binding JS wrapper
│ ├── index.d.ts # NAPI type declarations
│ └── *.node # Platform-specific binaries
└── bin/
└── vite # 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 | vite-plus.linux-arm64-gnu.node |
x86_64-unknown-linux-gnu |
Linux | x64 | vite-plus.linux-x64-gnu.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 all @rolldown/binding-* imports to point to vite-plus/binding:
// In packages/core/build.ts
if (process.env.RELEASE_BUILD) {
// @rolldown/binding-darwin-arm64 → vite-plus/binding
source = source.replace(/@rolldown\/binding-([a-z0-9-]+)/g, 'vite-plus/binding');
}Transformation examples:
| Original Import | After Rewrite |
|---|---|
@rolldown/binding-darwin-arm64 |
vite-plus/binding |
@rolldown/binding-linux-x64-gnu |
vite-plus/binding |
@rolldown/binding-win32-x64-msvc |
vite-plus/binding |
This means:
- The bundled rolldown code in
@voidzero-dev/vite-plus-core/rolldownresolves native bindings fromvite-plus/binding - Users don't need to install separate
@rolldown/binding-*platform packages - The single
.nodefile contains both vite-plus task runner and rolldown bindings
When compiled with RELEASE_BUILD=1, the .node file contains:
| Component | Source | Purpose |
|---|---|---|
vite_task |
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: vite-plus/binding (rewritten from @rolldown/binding-*)
→ binding/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 | @voidzero-dev/vite-plus-linux-arm64-gnu |
| Linux x64 | @voidzero-dev/vite-plus-linux-x64-gnu |
| Windows x64 | @voidzero-dev/vite-plus-win32-x64-msvc |
These are automatically installed via optionalDependencies based on the user's platform.
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, vitepress). 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 '@voidzero-dev/vite-plus-core/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="@voidzero-dev/vite-plus-core/client" />This allows TypeScript to pick up types like import.meta.hot, CSS module types, and asset imports without explicit imports.
Instead of copying the actual dist files from the test package, we create thin shim files that re-export from @voidzero-dev/vite-plus-test. This approach:
- Keeps packages in sync - No need to rebuild CLI when test package changes
- Reduces duplication - No file copying, just re-exports
- Preserves module resolution - Node.js resolves to the actual test package
All test package exports are mapped under ./test/*:
| Test Package Export | CLI Package Export |
|---|---|
@voidzero-dev/vite-plus-test |
vite-plus/test |
@voidzero-dev/vite-plus-test/browser |
vite-plus/test/browser |
@voidzero-dev/vite-plus-test/browser-playwright |
vite-plus/test/browser-playwright |
@voidzero-dev/vite-plus-test/plugins/runner |
vite-plus/test/plugins/runner |
The sync handles complex conditional exports with import/require/node/types conditions.
Test package'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-playwright.js):
export * from '@voidzero-dev/vite-plus-test/browser-playwright';CJS shim (dist/test/index.cjs):
module.exports = require('@voidzero-dev/vite-plus-test');Type shim (dist/test/browser-playwright.d.ts):
import '@voidzero-dev/vite-plus-test/browser-playwright';
export * from '@voidzero-dev/vite-plus-test/browser-playwright';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 |
typescript |
TypeScript compilation |
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. buildCli() TypeScript compilation -> dist/*.js
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';
// Test package name for re-exports
const TEST_PACKAGE_NAME = '@voidzero-dev/vite-plus-test';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 compiled TypeScript (built by buildCli() via tsc):
| 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
packages/test/package.jsonexports - Creates shim files in
dist/test/ - Removes old
./test*exports frompackage.json - Merges in newly generated test exports
- Ensures
dist/testis in thefilesarray