5.6 · difficulty 4/4 · 14 min read
ModuleRunner Deep Dive
How does ModuleRunner execute modules inside a specified Environment? What is its internal mechanism? How does it handle HMR, module caching, and circular dependencies?
What is ModuleRunner
ModuleRunner is the mechanism in the Environment API for executing modules inside a specified environment. Its role is equivalent to "importing a module within the context of this particular Environment".
Analogy: if Environment is an isolated runtime sandbox, then ModuleRunner is the import() function inside that sandbox.
// Conceptual illustration (refer to official docs for the exact API)
const runner = new ModuleRunner(ssrEnvironment)
// Execute a module inside the ssr environment
const { render } = await runner.import("/src/entry-server.tsx")
const html = await render("/page")ModuleRunner's internal flow
runner.import("/src/App.tsx")
│
▼
1. Check the module cache
├── Hit and not invalidated → return the cached module exports directly
└── Miss / invalidated → continue →
2. Request a module transform from DevEnvironment
├── resolve → load → transform pipeline executes
└── Returns the compiled JS code
3. Execute the code inside the runner's current execution context
├── Similar to new Function(code)()
└── But with full import/export support
4. Collect exports, add to cache
5. Return the module exports objectModule cache and HMR
ModuleRunner maintains its own module cache. When a file changes:
File changed: /src/utils.ts
│
▼
DevEnvironment receives the watcher event
│
▼
Notifies the runner via HotChannel: utils.ts is invalidated
│
▼
runner.moduleCache.invalidate("/src/utils.ts")
│
▼
Next runner.import("/src/utils.ts") re-executesThis means the runner's cache invalidation is lazy: it does not immediately re-execute; it waits until the next import to reload.
Standard patterns for using ModuleRunner
Pattern 1: SSR server-side rendering
// server.js (conceptual illustration)
import { createServer } from "vite"
const vite = await createServer({
server: { middlewareMode: true },
})
// Get the ModuleRunner for the ssr environment
// Refer to official docs for the exact API
const runner = getModuleRunner(vite.environments.ssr)
app.use(async (req, res) => {
try {
// Each request uses runner.import to get the latest module
// If the file hasn't changed, the cache is hit — nearly zero overhead
const { render } = await runner.import("/src/entry-server.tsx")
const html = await render(req.url)
res.setHeader("Content-Type", "text/html")
res.end(html)
} catch (e) {
vite.ssrFixStacktrace(e)
res.status(500).end(String(e))
}
})Pattern 2: Batch processing
// Execute multiple modules at once
const results = await Promise.all([
runner.import("/src/routes/home.tsx"),
runner.import("/src/routes/about.tsx"),
runner.import("/src/routes/404.tsx"),
])Pattern 3: Lifecycle management
// The runner should be closed alongside the server's lifecycle
process.on("SIGTERM", async () => {
await runner.close() // clean up resources
await vite.close()
})Comparison with ssrLoadModule
| Dimension | ssrLoadModule | ModuleRunner |
|---|---|---|
| Environment-aware | ❌ Shared global graph | ✅ Independent Environment |
| Caching | Re-executes every time | Cached, re-executes on invalidation |
| HMR integration | Manual | Automatic |
| Multi-environment | ❌ Not supported | ✅ Natively supported |
| API stability | Stable | RC |
Module execution context
ModuleRunner executes modules in an isolated execution context. This means:
// Code executing inside the runner:
import { readFile } from "node:fs/promises" // Node.js API
// Global objects are Node.js globals
console.log(typeof window) // "undefined" (if running in Node)
console.log(typeof process) // "object"
// But if the runner is running in an Edge Runtime:
// console.log(typeof window) // "object" (Edge has window-like objects)
// console.log(typeof process) // "undefined"The execution environment of a runner depends on which runtime it runs in. An ssr runner runs in Node.js; a worker runner runs in workerd.
Self-check
- When does ModuleRunner's module cache get invalidated?
- Why is the runner's cache invalidation "lazy" rather than immediately re-executing? What are the benefits of this design?
- If the same
/src/utils.tsis imported by both a client runner and an ssr runner, does each runner maintain its own separate cache? - What is the fundamental difference between
runner.import("/src/App.tsx")and a directimport("/src/App.tsx")?
// Implement a simple SSR rendering server:
// 1. Create a Vite dev server
// 2. Get the ModuleRunner for the ssr environment
// 3. For each HTTP request:
// a. Use runner.import to get the entry-server module
// b. Call render(url) to get the HTML
// c. Return the complete HTML
import { createServer } from "node:http"
import { createServer as createViteServer } from "vite"
async function main() {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
environments: {
ssr: {
resolve: {
conditions: ["node", "import"],
},
},
},
})
// TODO:
// 1. Get the runner for the ssr environment (use official API)
// 2. Create an HTTP server
// 3. In the request handler, use runner.import to execute entry-server
const server = createServer(async (req, res) => {
// TODO
})
server.listen(3000)
console.log("SSR server: http://localhost:3000")
}
main()