Read OSS

Inside Vite's Dev Server: Middleware Stack, Transform Pipeline, and Module Graph

Advanced

Prerequisites

  • Articles 1-2: Architecture, configuration, and plugin system
  • Node.js HTTP server and middleware pattern (connect/express)
  • Rollup plugin hook concepts (resolveId, load, transform)

Inside Vite's Dev Server: Middleware Stack, Transform Pipeline, and Module Graph

When you run vite dev, a dev server starts that does something unusual: instead of bundling your source code, it serves each module individually as native ESM, transforming files on-demand as the browser requests them. This unbundled approach is what makes Vite's dev server fast — but it requires sophisticated infrastructure to handle import rewriting, caching, HMR invalidation, and dependency pre-bundling. Let's trace how all of it works, starting from server creation.

Server Creation and Environment Initialization

The createServer() function is just a thin wrapper around _createServer(), which begins on line 476. The creation process follows this sequence:

sequenceDiagram
    participant CLI as CLI / User Code
    participant CS as _createServer()
    participant Config as resolveConfig()
    participant HTTP as HTTP Server
    participant WS as WebSocket Server
    participant Env as DevEnvironment (per-env)
    participant MW as Middleware Stack

    CLI->>CS: createServer(inlineConfig)
    CS->>Config: resolveConfig(inlineConfig, 'serve')
    Config-->>CS: ResolvedConfig
    CS->>HTTP: resolveHttpServer(middlewares, https)
    CS->>WS: createWebSocketServer(httpServer, config)
    loop For each environment in config.environments
        CS->>Env: createEnvironment(name, config, { ws })
        Env->>Env: init({ watcher })
        Note over Env: Creates moduleGraph,<br/>pluginContainer,<br/>depsOptimizer
    end
    CS->>MW: Assemble middleware stack
    CS-->>CLI: ViteDevServer

The environment initialization loop on lines 562-579 creates each DevEnvironment using the factory function from its resolved config:

const environment = await environmentOptions.dev.createEnvironment(
  name, config, { ws },
)
environments[name] = environment
await environment.init({ watcher, previousInstance })

Each DevEnvironment gets its own EnvironmentModuleGraph, EnvironmentPluginContainer, and optionally a DepsOptimizer. The ViteDevServer interface then provides unified access to all environments through server.environments.

The Middleware Stack

Vite uses connect as its middleware framework. The middleware stack is assembled in _createServer() between lines 920-1030, and the ordering is security-first:

flowchart TD
    A["timeMiddleware (DEBUG only)"] --> B
    B["rejectInvalidRequestMiddleware"] --> C
    C["rejectNoCorsRequestMiddleware"] --> D
    D["corsMiddleware"] --> E
    E["hostValidationMiddleware"] --> F
    F["configureServer pre-hooks"] --> G
    G["cachedTransformMiddleware"] --> H
    H["proxyMiddleware"] --> I
    I["baseMiddleware"] --> J
    J["launchEditorMiddleware"] --> K
    K["viteHMRPingMiddleware"] --> L
    L["servePublicMiddleware"] --> M
    M["transformMiddleware ⭐"] --> N
    N["serveRawFsMiddleware"] --> O
    O["serveStaticMiddleware"] --> P
    P["htmlFallbackMiddleware"] --> Q
    Q["configureServer post-hooks"] --> R
    R["indexHtmlMiddleware"] --> S
    S["notFoundMiddleware"] --> T
    T["errorMiddleware"]

    style M fill:#f96,stroke:#333,stroke-width:2px

The security layer rejects malformed requests, blocks cross-origin requests without CORS headers, and validates the Host header to prevent DNS rebinding attacks. The cachedTransformMiddleware (line 957) provides ETag-based 304 responses before any transform work happens.

The star of the stack is transformMiddleware, which intercepts requests for JS, CSS, and import-query URLs, runs them through transformRequest(), and serves the result. It only handles the client environment — SSR transforms go through a different path (the module runner, covered in Part 5).

Tip: Plugin authors can inject middlewares via the configureServer hook. Middlewares returned from the hook run before internal middlewares. Return a function from the hook to add post middlewares — they execute after the internal stack, useful for catch-all routes.

The Plugin Container

During dev, there's no actual Rollup/Rolldown bundle process. Instead, Vite emulates Rollup's plugin execution model through the plugin container — based on WMR's implementation as credited in pluginContainer.ts.

Each DevEnvironment gets its own EnvironmentPluginContainer instance, created during environment.init(). The container implements the same resolveId, load, and transform hook interfaces that Rolldown uses during builds, providing a PluginContext with:

  • this.environment — the current DevEnvironment
  • this.resolve() — inter-plugin resolution
  • this.emitFile() — asset emission
  • this.parse() — AST parsing via Rolldown's parser
classDiagram
    class EnvironmentPluginContainer {
        +resolveId(id, importer, options)
        +load(id, options)
        +transform(code, id, options)
        +buildStart()
        +close()
        -plugins: Plugin[]
        -environment: DevEnvironment
    }
    class DevEnvironment {
        +pluginContainer: EnvironmentPluginContainer
        +moduleGraph: EnvironmentModuleGraph
        +transformRequest(url): TransformResult
    }
    DevEnvironment --> EnvironmentPluginContainer

This is the abstraction that makes Rollup/Rolldown plugins work during dev: the plugin container calls the same hooks in the same order as a real bundle process, just one module at a time.

transformRequest(): The Heart of Dev Mode

When the transform middleware catches a request for /src/App.tsx, it calls environment.transformRequest(url), which delegates to the transformRequest() function:

sequenceDiagram
    participant MW as transformMiddleware
    participant TR as transformRequest()
    participant PC as pluginContainer
    participant MG as moduleGraph
    participant FS as File System

    MW->>TR: transformRequest(url)
    TR->>TR: Check pending requests (dedup)
    TR->>PC: resolveId(url)
    PC-->>TR: resolved id + metadata
    TR->>MG: ensureEntryFromUrl(url)
    TR->>PC: load(id)
    alt Plugin provides code
        PC-->>TR: code
    else No plugin handles
        TR->>FS: fs.readFile(id)
        FS-->>TR: code
    end
    TR->>PC: transform(code, id)
    PC-->>TR: transformed code + sourcemap
    TR->>TR: Generate ETag
    TR->>MG: Update transformResult
    TR-->>MW: TransformResult { code, map, etag }

The function starts with request deduplication on lines 109-130. If two requests for the same URL arrive simultaneously, the second one waits for the first's result rather than doing duplicate work. But there's a subtlety: if the module was invalidated between when the first request started and the second arrived, the pending result is stale, so the second request aborts the first and starts fresh.

The TransformResult type captures everything needed to serve the response:

export interface TransformResult {
  code: string
  map: SourceMap | { mappings: '' } | null
  etag?: string
  deps?: string[]
  dynamicDeps?: string[]
}

Import Analysis: Bridging Native ESM and Vite

After transform hooks run, the importAnalysisPlugin (a dev-only plugin, ~1100 lines) performs the critical work of rewriting imports for the browser. Consider this source code:

import React from 'react'
import './styles.css'
import { helper } from './utils'

The browser can't resolve bare imports like 'react' or understand CSS imports. The import analysis plugin transforms this to:

import React from '/node_modules/.vite/deps/react.js?v=abc123'
import './styles.css?import'
import { helper } from '/src/utils.ts?t=1234567890'

It does this by:

  1. Parsing imports with es-module-lexer
  2. Resolving each specifier through the plugin container's resolveId
  3. Rewriting bare imports to pre-bundled dependency paths in .vite/deps/
  4. Appending timestamp queries for cache busting on file changes
  5. Injecting HMR boundary markers for modules that call import.meta.hot.accept()
  6. Handling import.meta.env and import.meta.glob substitutions
flowchart LR
    A["import React from 'react'"] -->|resolveId| B["/.vite/deps/react.js?v=abc"]
    C["import './styles.css'"] -->|CSS detection| D["./styles.css?import&t=123"]
    E["import { x } from './utils'"] -->|timestamp| F["./utils.ts?t=123"]
    G["import.meta.hot.accept()"] -->|HMR injection| H["__vite__createHotContext(url)"]

Tip: If you ever wonder why a module is being fully reloaded instead of hot-updated, look at the import analysis plugin's HMR boundary detection. A module that doesn't call import.meta.hot.accept() — directly or via a framework's HMR handling — forces propagation upward through its importers.

The Module Graph Data Structure

The EnvironmentModuleGraph is the data structure that tracks all module relationships within an environment. It maintains three lookup indices for fast access:

urlToModuleMap: Map<string, EnvironmentModuleNode>   // URL → module
idToModuleMap: Map<string, EnvironmentModuleNode>    // resolved ID → module
fileToModulesMap: Map<string, Set<EnvironmentModuleNode>>  // file → modules

A single file can map to multiple modules (e.g., with different query strings), which is why fileToModulesMap stores a Set.

Each EnvironmentModuleNode tracks:

classDiagram
    class EnvironmentModuleNode {
        +url: string
        +id: string | null
        +file: string | null
        +type: "js" | "css" | "asset"
        +importers: Set~EnvironmentModuleNode~
        +importedModules: Set~EnvironmentModuleNode~
        +acceptedHmrDeps: Set~EnvironmentModuleNode~
        +acceptedHmrExports: Set~string~ | null
        +isSelfAccepting?: boolean
        +transformResult: TransformResult | null
        +lastHMRTimestamp: number
        +lastInvalidationTimestamp: number
        +invalidationState: TransformResult | "HARD_INVALIDATED" | undefined
    }
    class EnvironmentModuleGraph {
        +urlToModuleMap: Map
        +idToModuleMap: Map
        +fileToModulesMap: Map
        +etagToModuleMap: Map
        +getModuleByUrl(url)
        +getModulesByFile(file)
        +invalidateModule(mod, ...)
        +onFileChange(file)
    }
    EnvironmentModuleGraph --> EnvironmentModuleNode : manages
    EnvironmentModuleNode --> EnvironmentModuleNode : importers / importedModules

The importers and importedModules sets form a bidirectional graph. When a file changes, Vite looks up its modules via fileToModulesMap, then walks importers upward to find HMR boundaries. The acceptedHmrDeps set records which imported modules a given module has agreed to accept updates from (via import.meta.hot.accept(deps, callback)).

The invalidationState field distinguishes between soft invalidation (only timestamps need updating — the transform result is still valid) and hard invalidation (the code changed and must be re-transformed). This optimization avoids unnecessary re-transforms within HMR chains.

For backward compatibility, Vite also provides a ModuleGraph (imported from mixedModuleGraph.ts) on server.moduleGraph that merges the client and SSR environment graphs. Access to this is gated behind a future deprecation warning — new code should use server.environments.client.moduleGraph directly.

What's Next

We've now traced a request from the browser through the middleware stack, into the transform pipeline, and watched the module graph being populated. But what happens when you save a file? In the next article, we'll follow the complete HMR cycle — from chokidar file detection through module graph traversal, WebSocket dispatch, and client-side module re-fetch — and then explore how dependency pre-bundling discovers, bundles, and serves npm packages.