Read OSS

Production Builds, the ViteBuilder, and the Module Runner

Advanced

Prerequisites

  • Article 1: Architecture and Codebase Navigation
  • Article 2: Configuration and Environment System
  • Article 3: Dev Server and Transform Pipeline
  • Article 4: Plugin System and Core Plugins
  • Article 5: HMR and Dependency Optimization

Production Builds, the ViteBuilder, and the Module Runner

Development and production are fundamentally different in Vite. In dev, modules are transformed on-demand as the browser requests them. In production, everything is bundled, tree-shaken, minified, and written to disk. Yet both modes share the same plugin system and much of the same configuration.

This final article covers the three remaining subsystems: the build() pipeline that produces optimized output via Rolldown, the ViteBuilder that coordinates multi-environment builds, and the ModuleRunner that evaluates server-side code in any JavaScript runtime.

The build() Function and BuildEnvironment

Production builds start with the build() function, which (as we saw in Article 1) is invoked from the CLI via createBuilder. The actual work happens in buildEnvironment:

sequenceDiagram
    participant CLI
    participant createBuilder
    participant buildEnvironment
    participant Rolldown

    CLI->>createBuilder: createBuilder(inlineConfig)
    createBuilder->>createBuilder: resolveConfig(inlineConfig, 'build')
    createBuilder->>createBuilder: Create BuildEnvironment per env
    createBuilder->>buildEnvironment: builder.build(environment)
    buildEnvironment->>buildEnvironment: resolveRolldownOptions(environment)
    buildEnvironment->>Rolldown: rolldown(rollupOptions)
    Rolldown-->>buildEnvironment: RolldownBuild
    buildEnvironment->>Rolldown: bundle.write(outputOptions)
    Rolldown-->>buildEnvironment: RolldownOutput
    buildEnvironment-->>CLI: Output files

The BuildEnvironment class extends BaseEnvironment with a mode: 'build' flag and an isBuilt tracker. It's deliberately simpler than DevEnvironment — no module graph, no hot channel, no pending request tracking.

The resolveRolldownOptions function translates Vite's configuration into Rolldown's RolldownOptions. This includes resolving entry points from the HTML file (for app mode) or from config (for library mode), setting up output options (format, chunk naming, asset inlining thresholds), and integrating the plugin pipeline.

The buildEnvironment function on lines 792–860 calls rolldown() to create a build, then either bundle.write() for normal builds or sets up a watcher for --watch mode. Watch mode uses Rolldown's native file watcher with chokidar options resolved from Vite's config.

ViteBuilder and Multi-Environment Builds

The createBuilder function creates a ViteBuilder that can build multiple environments. The key method is buildApp() on lines 1807–1841:

sequenceDiagram
    participant Framework
    participant buildApp
    participant Plugins
    participant configBuilder

    buildApp->>Plugins: Run 'pre' and 'normal' buildApp hooks
    Plugins-->>buildApp: (may build some environments)
    buildApp->>configBuilder: configBuilder.buildApp(builder)
    configBuilder-->>buildApp: (default: no-op)
    buildApp->>Plugins: Run 'post' buildApp hooks
    buildApp->>buildApp: Any environments not built?
    alt Some environments unbuilt
        buildApp->>buildApp: Build remaining environments sequentially
    end

The buildApp plugin hook is how frameworks control build orchestration. A meta-framework like Nuxt or SolidStart can use this hook to:

  1. Build the client environment first
  2. Read the client manifest
  3. Build the SSR environment with references to client chunks
  4. Coordinate output directories

The hook uses the same enforce/order sorting as other hooks. Hooks with order: 'pre' and normal hooks run first, then configBuilder.buildApp (the user's builder config), then order: 'post' hooks.

A fallback on lines 1832–1840 ensures that if no buildApp hook builds any environment, all environments are built sequentially:

if (Object.values(builder.environments).every(
  (environment) => !environment.isBuilt,
)) {
  for (const environment of Object.values(builder.environments)) {
    await builder.build(environment)
  }
}

For per-environment config isolation, createBuilder optionally resolves config separately for each environment (when sharedConfigBuild is false, the default). This ensures plugins get fresh instances per build, matching the ecosystem expectation that plugins process one bundle at a time.

Build-Time Import Analysis

The buildImportAnalysisPlugin handles imports differently from its dev counterpart. In dev, imports are rewritten to add timestamp queries and redirect bare imports to optimized deps. In build, the focus shifts to:

flowchart TD
    A["Parse imports with es-module-lexer"] --> B{"Dynamic import?"}
    B -->|Yes| C["Insert __vitePreload wrapper"]
    B -->|No| D["Leave as static import"]
    C --> E["Collect CSS deps for preload"]
    E --> F["Generate preload directive"]
    F --> G["Replace __VITE_PRELOAD__ marker in generateBundle"]

The preload system is critical for performance. When chunk A dynamically imports chunk B, and chunk B imports a CSS file, the preload directive ensures the CSS is fetched in parallel with chunk B rather than waterfall-loading. The __VITE_PRELOAD__ marker is inserted during transform and replaced with actual chunk paths during generateBundle, when the full chunk graph is known.

The build plugin also delegates to a native Rolldown plugin (viteBuildImportAnalysisPlugin from rolldown/experimental) for parts of the analysis that can run in Rust.

The ModuleRunner System

The ModuleRunner is Vite's solution for executing server-side code in any JavaScript runtime. It's the foundation for SSR and can run in Node.js, workers, or edge runtimes.

graph TD
    subgraph "ModuleRunner"
        MR["ModuleRunner"] --> EM["EvaluatedModules<br/>(module cache)"]
        MR --> TR["Transport<br/>(to DevEnvironment)"]
        MR --> HMR["HMRClient<br/>(optional)"]
        MR --> EV["ModuleEvaluator"]
    end

    subgraph "ESModulesEvaluator"
        EV --> AF["new AsyncFunction(<br/>  __vite_ssr_exports__,<br/>  __vite_ssr_import_meta__,<br/>  __vite_ssr_import__,<br/>  ...<br/>  code<br/>)"]
    end

    TR -->|"fetchModule(id)"| DEV["DevEnvironment<br/>(Vite Server)"]
    DEV -->|"transform + ssrTransform"| TR

The constructor on lines 53–79 takes options, an optional evaluator (defaulting to ESModulesEvaluator), and an optional debugger. If HMR is enabled, it creates an HMRClient using the shared implementation from src/shared/hmr.ts — the same HMRClient class used by the browser client.

The ESModulesEvaluator uses AsyncFunction to execute transformed code:

const initModule = new AsyncFunction(
  ssrModuleExportsKey,
  ssrImportMetaKey,
  ssrImportKey,
  ssrDynamicImportKey,
  ssrExportAllKey,
  ssrExportNameKey,
  '"use strict";' + code,
)

This approach was chosen over vm.runInNewContext because AsyncFunction works in any JavaScript environment — browsers, Deno, Cloudflare Workers — not just Node.js. The module's exports are sealed after evaluation with Object.seal() to catch accidental mutation.

Tip: The startOffset property on ESModulesEvaluator accounts for the line offset introduced by the AsyncFunction wrapper. This ensures source maps align correctly when errors are thrown from evaluated modules.

Transport Layer and Cross-Environment Communication

The ModuleRunnerTransport interface abstracts communication between the module runner and the Vite server:

export interface ModuleRunnerTransport {
  connect?(handlers: ModuleRunnerTransportHandlers): Promise<void> | void
  disconnect?(): Promise<void> | void
  send?(data: HotPayload): Promise<void> | void
  invoke?(data: HotPayload): Promise<{ result: any } | { error: any }>
  timeout?: number
}

There are two communication models: send/connect (message-based, for WebSocket or worker messages) and invoke (RPC-style, for same-process communication). The normalizeModuleRunnerTransport function wraps either model into a unified InvokeableModuleRunnerTransport with typed RPC methods.

graph LR
    subgraph "Same Process"
        RUNNER1["ModuleRunner"] -->|"invoke()"| DEV1["DevEnvironment"]
    end
    subgraph "Worker Thread"
        RUNNER2["ModuleRunner"] -->|"send/connect"| MSG["MessagePort"] -->|"send/connect"| DEV2["DevEnvironment"]
    end
    subgraph "Remote (Edge)"
        RUNNER3["ModuleRunner"] -->|"send/connect"| WS["WebSocket"] -->|"send/connect"| DEV3["DevEnvironment"]
    end

For the send/connect model, the transport implementation on lines 43–80 creates an RPC layer using nanoid-generated request IDs and a Map of pending promises. Responses are matched by ID with configurable timeouts.

Dev vs. Build: A Comparison

To conclude this series, here's a side-by-side view of how modules flow through Vite's two modes:

Aspect Dev (transformRequest) Build (Rolldown bundle)
Entry Browser HTTP request Config entry points / HTML
Resolution Plugin container resolveId Rolldown native + plugin resolveId
Loading Plugin container load → fs fallback Rolldown native + plugin load
Transforms Plugin container transform (sequential) Rolldown + plugin transform (parallel)
Output Single module response Bundled chunks, tree-shaken
Module graph EnvironmentModuleGraph (per-environment) Rolldown internal graph
Imports Rewritten by importAnalysisPlugin Resolved by buildImportAnalysisPlugin
CSS Injected via <style> tags Extracted to .css files
Dependencies Pre-bundled by optimizer Bundled inline or split
HMR WebSocket → re-import N/A (watch mode rebuilds)

The convergence point is the plugin system. The same plugins run in both modes, receiving the same hook calls. Vite-specific hooks like hotUpdate are naturally dev-only, and build-specific hooks like generateBundle only fire during builds. But the core resolveIdloadtransform pipeline is shared.

With Vite 8's Rolldown migration, the gap between dev and build narrows further. Rolldown's native resolver powers both modes. The experimental full-bundle dev mode (Article 5) collapses the distinction entirely — dev serves bundled output, just like production.

Series Conclusion

Over these six articles, we've traced Vite 8 from its 79-line CLI bootstrap through configuration resolution, the 18-layer middleware stack, the plugin system with 28+ core plugins, HMR boundary propagation, dependency pre-bundling, production builds, and the module runner. The key insights:

  1. Four runtime contexts (node, client, module-runner, shared) enforce clean separation and enable cross-platform execution.
  2. The environment hierarchy gives each environment its own module graph, plugin container, and optimizer, while the Proxy-based config merging avoids duplication.
  3. Rolldown unifies the toolchain — replacing the esbuild+Rollup split with a single Rust-based bundler that powers both dev transforms and production builds.
  4. The plugin system is the extension point — 28+ core plugins compose to handle resolution, CSS, assets, HTML, imports, and more, all through Rolldown-compatible hooks.
  5. HMR and pre-bundling are deeply integrated — the module graph, file watcher, and WebSocket channel work together to deliver sub-second updates.

Whether you're building a Vite plugin, contributing to Vite core, or debugging a tricky transform issue, this map of the codebase should help you find your way.