Skip to content

Latest commit

 

History

History
636 lines (458 loc) · 29.7 KB

BUNDLING.md

File metadata and controls

636 lines (458 loc) · 29.7 KB

CLI Package Build Architecture

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.

Overview

The CLI package uses a 4-step build process:

  1. tsdown Build - Bundle all CLI entry points via tsdown
  2. NAPI Binding Build - Compile Rust code to native Node.js bindings
  3. Core Package Export Sync - Re-export @voidzero-dev/vite-plus-core under ./client, ./types/*, etc.
  4. Test Package Export Sync - Re-export upstream vitest under ./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.

Core Dependency Identity

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.

Build Steps

Step 1: tsdown Build (buildWithTsdown)

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.cjs
  • index.cts → dist/index.cjs

Input: src/**/*.ts, src/**/*.cts Output: dist/*.js, dist/*.cjs, dist/*.d.ts, dist/*-<hash>.js (shared chunks)

Step 2: NAPI Binding Build (buildNapiBinding)

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.

Step 3: Core Package Export Sync (syncCorePackageExports)

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.

Step 4: Test Package Export Sync (syncTestPackageExports)

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


Output Structure

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

NAPI Targets

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.


Rolldown Native Binding Integration

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.

Conditional Compilation

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;

Build-Time Feature Activation

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:

  1. Enables the rolldown Cargo feature
  2. Compiles rolldown_binding into the .node file
  3. Extracts napi.dtsHeader from rolldown's package.json for type definitions
  4. Prepends custom type definitions to the generated .d.ts file

Why Conditional Compilation?

Build Type rolldown Feature Use Case
Development (pnpm build) Disabled Faster builds, smaller binaries
Release (RELEASE_BUILD=1) Enabled Full distribution with bundled rolldown

Module Specifier Rewriting

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:

  1. The bundled rolldown code in @voidzero-dev/vite-plus-core/rolldown resolves native bindings through core's own declared optional dependencies (injected at publish time by publish-native-addons.ts)
  2. Users don't need to install separate @rolldown/binding-* platform packages
  3. The platform .node file contains both vite-plus task runner and rolldown bindings

Native Binding Contents

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

Export Chain

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)

Platform-Specific Publishing

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.


Core Package Export Sync Details

Why Shim Files?

The CLI package creates thin shim files that re-export from @voidzero-dev/vite-plus-core rather than bundling the actual code. This approach:

  1. Enables drop-in replacement - Users can replace vite with vite-plus without changing imports
  2. Keeps packages in sync - No need to rebuild CLI when core package changes
  3. Reduces duplication - No file copying, just re-exports
  4. 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.

Export Mapping (Core)

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

Type-Only Exports

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.

Internal Types Blocking

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.

Client Types (Triple-Slash Reference)

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.


Test Package Export Sync Details

Why Shim Files?

Instead of copying vitest's dist files, we create thin shim files that re-export from vitest. This approach:

  1. Keeps packages in sync - No need to rebuild CLI when vitest is upgraded
  2. Reduces duplication - No file copying, just re-exports
  3. Preserves module resolution - Node.js resolves to the actual installed vitest

Export Mapping (Test)

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.

Why provider d.ts shims are inlined

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.

Conditional Export Handling

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:

  • .js for ESM imports
  • .cjs for CommonJS requires
  • .d.ts / .d.cts for type declarations

Shim File Contents

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


Build Dependencies

Package Purpose
@napi-rs/cli NAPI build toolchain for Rust
oxfmt Code formatting for generated JS
tsdown TypeScript bundling

Debug Mode

To build with debug (unoptimized) Rust bindings:

VP_CLI_DEBUG=1 pnpm build

This sets release: false in the NAPI build options, producing larger but faster-to-compile debug binaries.


Build Commands

# 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 build

Package Exports

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


Technical Reference

Build Flow

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

Key Constants

// 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';

Package.json Exports Management

The exports field in package.json has two categories: manual and automated.

Manual exports

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.

Automated exports (./test/*)

All ./test* exports are fully managed by syncTestPackageExports(). The build script:

  1. Reads vitest's package.json exports (resolved via createRequire)
  2. Creates shim files in dist/test/
  3. Removes old ./test* exports from package.json
  4. Merges in newly generated test exports
  5. Relies on the existing dist entry to include the generated dist/test shims