Read OSS

Vite 8 Architecture: A Map of the Codebase

Intermediate

Prerequisites

  • Basic understanding of frontend build tools (webpack, Rollup, or esbuild)
  • Familiarity with ES Modules and Node.js
  • Basic TypeScript reading ability

Vite 8 Architecture: A Map of the Codebase

Vite has become the default frontend build tool for React, Vue, Svelte, and a growing number of frameworks. But reading its source code for the first time can feel like parachuting into a city without a map. This series fixes that. We'll walk through every major subsystem in Vite 8 — from CLI bootstrap to production builds — giving you the mental model you need to navigate and contribute to the project.

This first article establishes the map. By the end, you'll know what every directory does, how the four runtime contexts relate to each other, and why Vite 8's migration to Rolldown is such a big deal.

Monorepo Structure and the Three Packages

Vite is a pnpm-based monorepo. At the top level, three packages matter:

Package Path Purpose
vite packages/vite The core — dev server, build pipeline, plugin system, HMR
create-vite packages/create-vite Scaffolding CLI (npm create vite@latest)
plugin-legacy packages/plugin-legacy Legacy browser support via SystemJS

The packages/vite directory accounts for the overwhelming majority of the codebase. Its package.json reveals the basics: it's an ESM-first package (type: "module") exposing a bin/vite.js entry and version 8.0.8 at the time of this writing.

The exports map is worth noting:

{
  ".": "./dist/node/index.js",
  "./client": { "types": "./client.d.ts" },
  "./module-runner": "./dist/node/module-runner.js",
  "./internal": "./dist/node/internal.js"
}

There are four distinct entry points exposed to consumers — the main API, client types, the module runner, and an internal API. This isn't accidental; it mirrors the four runtime contexts we'll explore next.

The Four Runtime Contexts

Inside packages/vite/src, there are four directories that correspond to four fundamentally different execution contexts:

graph TD
    subgraph "packages/vite/src"
        N["node/"] -->|"Server-side core"| DESC1["Dev server, build pipeline,<br/>config, plugins"]
        C["client/"] -->|"Browser runtime"| DESC2["HMR client, error overlay,<br/>CSS injection"]
        M["module-runner/"] -->|"Environment-agnostic"| DESC3["SSR evaluation,<br/>module cache, transport"]
        S["shared/"] -->|"Cross-context"| DESC4["HMR protocol, utils,<br/>transport normalization"]
    end

src/node/ is the heart of Vite. It runs in Node.js and contains everything: configuration resolution, the dev server, the build pipeline, the plugin system, the optimizer, and the middleware stack. When you run vite dev or vite build, this is what executes.

src/client/ is injected into the browser during development. The main file client.ts establishes a WebSocket connection back to the dev server and processes HMR update payloads. It also manages the error overlay and CSS hot-injection.

src/module-runner/ is Vite's environment-agnostic module execution engine. The ModuleRunner class evaluates server-side code using AsyncFunction, maintaining its own module cache (EvaluatedModules) and supporting HMR. This is what powers SSR and can run in workers, edge runtimes, or any JavaScript environment.

src/shared/ contains code that must work in all contexts. The HMRClient class, for example, is used by both the browser client and the module runner. Transport normalization lives here too, so the same protocol works over WebSockets, worker messages, or direct function calls.

Tip: When exploring the codebase, always check which src/ subdirectory you're in. Code in shared/ cannot import from node/, and client/ code runs in browsers — these constraints shape every design decision.

CLI Bootstrap Sequence

When you type vite in your terminal, execution starts in bin/vite.js. This 79-line file does more than you'd expect, and it does it in a careful order:

sequenceDiagram
    participant Shell
    participant bin/vite.js
    participant CLI (cli.ts)

    Shell->>bin/vite.js: Execute
    bin/vite.js->>bin/vite.js: Enable source maps (if not in node_modules)
    bin/vite.js->>bin/vite.js: Capture global.__vite_start_time
    bin/vite.js->>bin/vite.js: Parse --debug, --filter, --profile from argv
    bin/vite.js->>bin/vite.js: Set process.env.DEBUG before any imports
    bin/vite.js->>bin/vite.js: start(): enable compile cache, schedule flush
    alt --profile flag present
        bin/vite.js->>bin/vite.js: Start V8 Profiler via node:inspector
        bin/vite.js->>CLI (cli.ts): import('../dist/node/cli.js')
    else Normal start
        bin/vite.js->>CLI (cli.ts): import('../dist/node/cli.js')
    end

Three design choices stand out:

  1. Debug before imports. Lines 19–46 parse --debug and --filter flags and set process.env.DEBUG before anything else is imported. This ensures the debug package (used throughout Vite internals) picks up the flag immediately.

  2. Compile cache with a flush timer. The start() function on lines 48–63 calls module.enableCompileCache() (Node 22.8+) and schedules a flush after 10 seconds. The comment explains why: for long-running dev servers, the cache is never written because Node waits for process exit, and the dev server typically exits via process.exit() which skips the flush.

  3. Profiler support. The --profile flag on lines 65–78 starts a V8 CPU profiler session that can be toggled via the p shortcut key during development — useful for diagnosing slow transforms.

Command Routing and the Public API

Once the bootstrap completes, control passes to src/node/cli.ts, which uses the cac library to define four commands:

flowchart LR
    CLI["vite CLI"] --> DEV["vite [root]<br/>(default command)"]
    CLI --> BUILD["vite build [root]"]
    CLI --> OPTIMIZE["vite optimize [root]<br/>(deprecated)"]
    CLI --> PREVIEW["vite preview [root]"]

    DEV -->|"imports"| CS["createServer()"]
    BUILD -->|"imports"| CB["createBuilder()"]
    PREVIEW -->|"imports"| PV["preview()"]

Each command lazily imports its handler — createServer for dev, createBuilder for build, preview for the preview server. This keeps startup fast since you only pay for what you use.

The dev command action (lines 207–303) creates a server, calls server.listen(), prints URLs, and binds CLI shortcuts. The build command (lines 343–382) uses createBuilder and calls builder.buildApp(), which orchestrates multi-environment builds.

Note the --app flag on the build command: when set, it configures { builder: {} }, enabling the new multi-environment build system where frameworks can control how client and SSR builds are coordinated.

The programmatic API surface is defined in src/node/index.ts. This file re-exports the core functions — createServer, build, createBuilder, preview, defineConfig — along with Rolldown utilities:

export { parse, parseSync, minify, minifySync, Visitor } from 'rolldown/utils'

These Rolldown re-exports are how Vite exposes Oxc-powered parsing and minification to its ecosystem. Plugins that previously used esbuild.transform() can now use parse() and minify() from vite directly.

The Rolldown Migration: From esbuild+Rollup to a Unified Toolchain

The most significant architectural change in Vite 8 is the migration from a dual-toolchain approach (esbuild for dev transforms + Rollup for production bundling) to Rolldown — a Rust-based bundler compatible with Rollup's plugin API.

graph TB
    subgraph "Vite ≤7 (Dual Toolchain)"
        DEV7["Dev Server"] --> ESB["esbuild<br/>(transpile, dep optimization)"]
        BUILD7["Production Build"] --> ROLLUP["Rollup<br/>(bundling, tree-shaking)"]
    end
    subgraph "Vite 8 (Unified Toolchain)"
        DEV8["Dev Server"] --> RD["Rolldown<br/>(transpile, dep optimization,<br/>bundling, tree-shaking)"]
        BUILD8["Production Build"] --> RD
        RD --> OXC["Oxc<br/>(parsing, transforms)"]
    end

The evidence of this migration is everywhere. In package.json, rolldown is a direct dependency at version 1.0.0-rc.15. While esbuild remains a direct dependency for backward compatibility, it is also listed in peerDependencies as optional — signaling that it is no longer the primary toolchain. In constants.ts, the ROLLUP_HOOKS array lists every Rolldown hook that Vite's plugin container emulates during dev — this is what keeps dev/build parity.

The index.ts file still exports a backward-compatible esbuildVersion constant hardcoded to '0.25.0', along with rollupVersion and rolldownVersion — a snapshot of the transition in progress.

Tip: If you're writing a Vite plugin and wondering whether to use esbuild or Rolldown APIs, use Rolldown. The parse, parseSync, minify, and Visitor exports from vite are the new standard. esbuild remains available as an optional peer dependency for backward compatibility, but new code should target Rolldown/Oxc.

What's Coming Next

Now that you have the high-level map — three packages, four runtime contexts, a lazy CLI that routes to createServer/createBuilder/preview, and a unified Rolldown toolchain — we're ready to go deeper.

In the next article, we'll explore configuration resolution: the 350+ line resolveConfig pipeline, the environment hierarchy (PartialEnvironment → BaseEnvironment → DevEnvironment | BuildEnvironment), the clever Proxy-based config merging that avoids duplicating config per environment, and the extensive esbuild-to-Rolldown compatibility layer that keeps the ecosystem running during the migration.