Production Builds, the ViteBuilder, and the Module Runner
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:
- Build the client environment first
- Read the client manifest
- Build the SSR environment with references to client chunks
- 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
startOffsetproperty onESModulesEvaluatoraccounts for the line offset introduced by theAsyncFunctionwrapper. 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 resolveId → load → transform 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:
- Four runtime contexts (node, client, module-runner, shared) enforce clean separation and enable cross-platform execution.
- The environment hierarchy gives each environment its own module graph, plugin container, and optimizer, while the Proxy-based config merging avoids duplication.
- Rolldown unifies the toolchain — replacing the esbuild+Rollup split with a single Rust-based bundler that powers both dev transforms and production builds.
- 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.
- 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.