The Dev Server: From HTTP Request to Transformed Module
Prerequisites
- ›Article 1: Architecture and Codebase Navigation
- ›Article 2: Configuration and Environment System
- ›Understanding of Node.js connect middleware pattern
The Dev Server: From HTTP Request to Transformed Module
Vite's dev server is what makes the "instant" development experience possible. Instead of bundling your entire application upfront, it serves modules on-demand, transforming them as the browser requests them. But "on-demand" doesn't mean "simple." Behind the scenes, there's a precisely-ordered middleware stack, a request deduplication system with staleness detection, and a plugin container that emulates Rolldown's hook execution model.
This article traces the full lifecycle: from createServer() initialization through the middleware chain to the transform pipeline that turns your TypeScript source into browser-ready JavaScript.
Server Creation and Initialization
Everything starts with _createServer in server/index.ts. The function resolves config, then assembles the server's components:
sequenceDiagram
participant User
participant _createServer
participant Config
participant HTTP
participant Environments
participant Middlewares
User->>_createServer: createServer(inlineConfig)
_createServer->>Config: resolveConfig(inlineConfig, 'serve')
_createServer->>HTTP: resolveHttpServer(middlewares, https)
_createServer->>_createServer: createWebSocketServer(httpServer, config)
_createServer->>_createServer: chokidar.watch(root, configDeps, envFiles)
_createServer->>Environments: Create environments via dev.createEnvironment()
_createServer->>Environments: Initialize each (pluginContainer, moduleGraph)
_createServer->>Middlewares: Register 18 middleware layers
_createServer-->>User: ViteDevServer
On lines 560–579, environments are created and initialized in parallel:
await Promise.all(
Object.entries(config.environments).map(
async ([name, environmentOptions]) => {
const environment = await environmentOptions.dev.createEnvironment(
name, config, { ws }
)
environments[name] = environment
await environment.init({ watcher, previousInstance })
},
),
)
Each environment gets its own DevEnvironment instance with its own module graph and plugin container, as we saw in Article 2. The previousInstance parameter enables seamless server restarts — environments can transfer state from the old instance.
After environments are created, a backward-compatible ModuleGraph wrapper is set up on line 583 that merges client and SSR module graphs for plugins still using the old API.
The 18-Layer Middleware Stack
The middleware registration on lines 920–1031 is one of the most carefully ordered sections in the codebase. Here's the full stack:
flowchart TD
A["1. timeMiddleware (DEBUG only)"] --> B["2. rejectInvalidRequestMiddleware"]
B --> C["3. rejectNoCorsRequestMiddleware"]
C --> D["4. CORS middleware"]
D --> E["5. Host validation middleware"]
E --> F["6. Plugin configureServer hooks (pre)"]
F --> G["7. cachedTransformMiddleware"]
G --> H["8. Proxy middleware"]
H --> I["9. Base path middleware"]
I --> J["10. Open-in-editor (__open-in-editor)"]
J --> K["11. HMR ping handler"]
K --> L["12. Public file serving"]
L --> M["13. transformMiddleware ★"]
M --> N["14. Raw FS serving (/@fs/)"]
N --> O["15. Static file serving"]
O --> P["16. HTML fallback (SPA/MPA)"]
P --> Q["17. Plugin configureServer hooks (post)"]
Q --> R["18. Index HTML transform"]
R --> S["19. 404 handler"]
S --> T["20. Error handler"]
The ordering is intentional:
- Security first (layers 2–5): Invalid requests, CORS violations, and DNS rebinding attacks are rejected before any processing.
- Plugin hooks split (layers 6 and 17):
configureServerhooks can return a function that runs as a "post" hook. This is why the hooks are split —prehooks run before internal middlewares,posthooks run after static serving but before HTML transforms. - Cache before transform (layer 7):
cachedTransformMiddlewarechecks if a request has a matching etag and returns 304 immediately, skipping the full transform pipeline. - Transform is the star (layer 13): This is where modules get resolved, loaded, and transformed on demand.
- HTML transforms last (layer 18): HTML files are processed after everything else, so script tags can be injected and import maps resolved.
Tip: When debugging middleware ordering issues, set
DEBUG=connect:dispatcherto see which named middleware handles each request. Vite names all its middlewares (e.g.,viteCachedTransformMiddleware,viteHMRPingMiddleware) specifically for this purpose.
The Transform Pipeline: Request Deduplication and Caching
When a request reaches the transform middleware and isn't cached, it calls transformRequest. This function implements request deduplication with staleness detection:
sequenceDiagram
participant Browser
participant transformRequest
participant _pendingRequests
participant doTransform
participant loadAndTransform
Browser->>transformRequest: GET /src/App.tsx
transformRequest->>transformRequest: Record timestamp
transformRequest->>_pendingRequests: Check for pending request
alt Pending request exists and is fresh
_pendingRequests-->>transformRequest: Return existing promise
else Pending request is stale
transformRequest->>_pendingRequests: Abort stale, start new
end
transformRequest->>doTransform: Process URL
doTransform->>doTransform: moduleGraph.getModuleByUrl(url)
alt Cache hit (fresh transformResult)
doTransform-->>transformRequest: Return cached result
else Cache miss
doTransform->>doTransform: pluginContainer.resolveId(url)
doTransform->>loadAndTransform: Load and transform
loadAndTransform->>loadAndTransform: pluginContainer.load(id)
loadAndTransform->>loadAndTransform: pluginContainer.transform(code, id)
loadAndTransform->>loadAndTransform: Store in moduleGraph
loadAndTransform-->>transformRequest: TransformResult
end
The deduplication on lines 110–127 handles a subtle race condition. When a module is being processed and gets invalidated (due to HMR or optimizer updates), the pending request becomes stale. The function compares the request's timestamp against module.lastInvalidationTimestamp to decide whether to reuse the pending result or abort and restart.
The doTransform function has two cache check opportunities: first by URL, then by resolved ID (since different URLs can resolve to the same file). Only if both miss does it proceed to loadAndTransform.
loadAndTransform runs the classic Rollup-style hook sequence: load → transform. The loaded code is checked against the file system access policy, then passed through each plugin's transform hook.
The EnvironmentPluginContainer
The plugin container is one of Vite's most historically interesting components. The file header in pluginContainer.ts acknowledges its origin:
This file is refactored into TypeScript based on
https://github.com/preactjs/wmr/blob/main/packages/wmr/src/lib/rollup-plugin-container.js
This WMR-heritage plugin container emulates Rolldown's plugin hook execution during dev mode. Since dev mode doesn't actually bundle, the container simulates the resolveId → load → transform hook chain that plugins expect.
flowchart TD
REQ["transformRequest(url)"] --> RID["resolveId hooks<br/>(first non-null wins)"]
RID --> LOAD["load hooks<br/>(first non-null wins)"]
LOAD -->|"No plugin loaded"| FS["Read from file system"]
LOAD -->|"Plugin returned code"| TRANSFORM
FS --> TRANSFORM["transform hooks<br/>(sequential, each sees previous output)"]
TRANSFORM --> RESULT["TransformResult<br/>{code, map, etag}"]
The container creates a per-plugin PluginContext that provides the this.resolve(), this.emitFile(), and this.getModuleInfo() methods plugins expect. Module info is lazily computed via a Proxy — it's only evaluated when a plugin actually calls this.getModuleInfo().
Module Graph: Per-Environment and Mixed Compatibility
The EnvironmentModuleGraph maintains four indexes for fast lookup:
urlToModuleMap: Map<string, EnvironmentModuleNode> // URL → module
idToModuleMap: Map<string, EnvironmentModuleNode> // resolved ID → module
etagToModuleMap: Map<string, EnvironmentModuleNode> // etag → module
fileToModulesMap: Map<string, Set<EnvironmentModuleNode>> // file → modules
Each EnvironmentModuleNode tracks its import relationships (importers, importedModules), HMR state (acceptedHmrDeps, isSelfAccepting), cached transform results, and invalidation timestamps. The invalidationState field distinguishes between soft invalidation (only timestamps need updating) and hard invalidation (full re-transform required).
For backward compatibility, the ModuleNode wrapper in mixedModuleGraph.ts bridges old and new APIs. It holds references to both _clientModule and _ssrModule, with a _get helper that returns the client module's value first, falling back to SSR:
_get<T extends keyof EnvironmentModuleNode>(
prop: T,
): EnvironmentModuleNode[T] {
return (this._clientModule?.[prop] ?? this._ssrModule?.[prop])!
}
This dual-reference approach lets plugins that still use server.moduleGraph (now deprecated) continue to work while Vite's ecosystem migrates to per-environment module graphs.
What's Coming Next
We've traced a request from the browser through 18 middleware layers, into the transform pipeline, through plugin hooks, and into the module graph. But we haven't looked at what happens inside the plugins themselves. In the next article, we'll explore Vite's plugin system: the Plugin interface that extends Rolldown's, the dual-tier sorting system, the filter optimization that skips hooks early, and deep dives into the core plugins — including the 3500+ line CSS plugin and the import analysis plugin that rewrites your bare imports.