How Cordis Handles Hot Module Replacement (HMR): Dependency Analysis and Cache Management
Cordis implements HMR through the @cordisjs/plugin-hmr package by recursively analyzing the module dependency graph, classifying changed files as accepted or declined, and atomically clearing both ESM and CJS caches before re-importing updated plugins, with automatic rollback on failure.
Cordis is an extensible framework designed for building scalable, plugin-based applications. Understanding Cordis HMR dependency analysis and cache internals is essential for developers who need reliable hot reloading without state loss. The HMR plugin watches files using chokidar and orchestrates a sophisticated dependency traversal to determine exactly which modules must be invalidated and reloaded.
Building the Dependency Tree with loadDependencies
The core of Cordis HMR analysis relies on the loadDependencies helper located in packages/hmr/src/index.ts. This function recursively traverses a ModuleJob—the internal representation of a loaded module—to collect every reachable URL.
The traversal deliberately excludes Node.js built-ins (URLs starting with node:) and third-party packages (paths containing /node_modules/), ensuring the system only tracks userland code. During initialization, the plugin captures externals by running loadDependencies on the CLI entry point (L15-L24). This set represents framework-level files; any change to these triggers a full process restart rather than a hot swap.
// packages/hmr/src/index.ts L31-L41
async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
const dependencies = new Set<string>()
async function traverse(job: ModuleJob) {
if (ignored.has(job.url) || dependencies.has(job.url)) return
if (job.url.startsWith('node:') || job.url.includes('/node_modules/')) return
dependencies.add(job.url)
const children = await job.linked
await Promise.all(Array.prototype.map.call(children, traverse))
}
await traverse(job)
return dependencies
}
Classifying Changes as Accepted or Declined
When chokidar detects a file change, the URL is added to a stashed set. The analyzeChanges method (L74-L122) then performs a fixed-point iteration to classify every affected file:
- Accepted files are those directly changed or that have a dependent already marked as accepted.
- Declined files belong to the
externalsset or have dependents that are all declined.
The algorithm maintains a pending array and iterates until no further updates occur, ensuring the classification is stable before proceeding to cache clearing.
// packages/hmr/src/index.ts L74-L122 (excerpt)
private async analyzeChanges() {
const pending: string[] = []
this.accepted = new Set(this.stashed)
this.declined = new Set(this.externals)
const isExcluded = (url: string) => url.startsWith('node:') || url.includes('/node_modules/')
// Populate pending with children of stashed files
await Promise.all([...this.stashed].map(async (url) => {
const children = await this.getLinked(url)
for (const child of children) {
if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue
pending.push(child)
}
}))
// Resolve until fixed point
while (pending.length) {
let index = 0, hasUpdate = false
while (index < pending.length) {
const url = pending[index]
const children = await this.getLinked(url)
let isDeclined = true, isAccepted = false
for (const child of children) {
if (this.declined.has(child) || isExcluded(child)) continue
if (this.accepted.has(child)) {
isAccepted = true; break
} else {
isDeclined = false
if (!pending.includes(child)) { hasUpdate = true; pending.push(child) }
}
}
if (isAccepted || isDeclined) {
hasUpdate = true
pending.splice(index, 1)
isAccepted ? this.accepted.add(url) : this.declined.add(url)
} else index++
}
if (!hasUpdate) break
}
for (const url of pending) this.declined.add(url)
}
Determining Which Plugins to Reload
After classification, the partialReload logic (L42-L71) determines which plugin entry files require reloading. It constructs a nameMap of plugin entries and loads each plugin’s dependencies using loadDependencies with the declined set as an ignore list. A plugin is scheduled for reload only if its dependency set intersects with the accepted set, minimizing unnecessary re-instantiations.
// packages/hmr/src/index.ts L42-L71 (excerpt)
for (const [job, plugin] of pending) {
const dependencies = [...await loadDependencies(job, this.declined)]
if (!dependencies.some(dep => this.accepted.has(dep))) continue
dependencies.forEach(dep => this.accepted.add(dep))
reloads.set(plugin, { filename: job.url, runtime: this.ctx.registry.get(plugin) })
}
Clearing ESM and CJS Module Caches
Cordis clears caches for every file in the accepted set. To handle differences in Node.js versions 22-24, the code uses native Map.prototype methods to interact with the internal ESM loadCache. It also purges the CJS require.cache using createRequire. Backups are created for both ESM (esmBackup) and CJS (cjsBackup) entries to enable rollback if the reload fails.
// packages/hmr/src/index.ts L90-L108
const esmBackup: Dict = {}
const cjsBackup: Dict = {}
const require = createRequire(import.meta.url)
for (const filename of this.accepted) {
// 1. ESM cache
const job = Map.prototype.get.call(this.internal.loadCache, filename)
esmBackup[filename] = job
Map.prototype.delete.call(this.internal.loadCache, filename)
// 2. CJS cache
try {
const filepath = fileURLToPath(filename)
if (require.cache[filepath]) {
cjsBackup[filepath] = require.cache[filepath]
delete require.cache[filepath]
}
} catch {}
}
Re-importing and Safe Rollback
The plugin imports each new entry file using ctx.loader.import, unwraps exports, and replaces the old registration in the ctx.registry. Successful reloads emit the hmr/reload event. If any import throws an error, the rollback function restores the ESM and CJS backups from their respective backup objects, guaranteeing the application returns to its pre-reload state. If the changed file belongs to the externals set, Cordis calls loader.exit() to trigger a full process restart (L32-34), ensuring framework consistency.
// packages/hmr/src/index.ts L120-L128 (excerpt)
try {
for (const [, { filename }] of reloads) {
attempts[filename] = this.ctx.loader.unwrapExports(
await this.ctx.loader.import(filename, this.getOuterStack))
}
} catch (e) { handleError(this.ctx, e); return rollback() }
this.ctx.emit('hmr/reload', reloads)
Configuration and Usage Example
To enable HMR, register the @cordisjs/plugin-hmr plugin after the loader. Configure the root directories to watch, set a debounce time to batch rapid changes, and optionally ignore specific patterns.
// src/app.ts
import { Context } from 'cordis'
import loader from '@cordisjs/plugin-loader'
import hmr from '@cordisjs/plugin-hmr'
const ctx = new Context({
loader: { entries: [{ name: 'my-plugin', path: './plugins/my-plugin.ts' }] },
hmr: { root: ['src'], debounce: 100, ignored: ['**/node_modules'] },
})
await ctx.start()
ctx.plugin(loader)
ctx.plugin(hmr)
Listen to the hmr/reload event to react to successful hot swaps:
ctx.on('hmr/reload', (reloads) => {
for (const [plugin, { filename }] of reloads) {
console.log(`Reloaded ${plugin.name} from ${filename}`)
}
})
Summary
loadDependenciesrecursively builds the module graph while excludingnode_modulesand Node built-ins.analyzeChangesuses fixed-point iteration to classify files as accepted or declined based on their dependents.- Dual cache clearing targets both ESM (
this.internal.loadCache) and CJS (require.cache) systems using version-agnosticMap.prototypemethods. - Atomic rollback restores cache backups if re-import fails, preventing application corruption.
- Full restart fallback occurs immediately if framework core files (externals) are modified.
Frequently Asked Questions
How does Cordis decide between a partial reload and a full restart?
Cordis maintains an externals set containing all dependencies reachable from the CLI entry point. If a changed file’s URL exists in this set, the plugin invokes loader.exit() to terminate and restart the entire process. Otherwise, it proceeds with analyzeChanges to attempt a partial hot reload of only affected plugins.
Why does Cordis use Map.prototype.delete instead of direct map access for the ESM cache?
Node.js versions 22 through 24 implement the internal loadCache differently. Using Map.prototype.get.call(this.internal.loadCache, filename) and Map.prototype.delete.call(...) guarantees consistent cache manipulation across these versions, bypassing potential variations in direct property accessibility.
What happens if a hot-reloaded plugin fails to import?
The system executes a rollback function that restores the previous state of both esmBackup and cjsBackup into their respective caches. This ensures that the failed module is not left in a partially loaded state and the application continues running with the last known good version of the plugin.
Does Cordis HMR track dependencies inside node_modules?
No. The loadDependencies function explicitly filters out URLs containing /node_modules/ or starting with node:. This design choice ensures that updates to third-party libraries do not trigger reloads, focusing the hot-swap mechanism exclusively on user-developed code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →