How to Implement Hot Module Replacement (HMR) in Cordis
Cordis provides an official @cordisjs/plugin-hmr package that enables hot module replacement by watching plugin files, analyzing dependency graphs to determine affected plugins, and reloading only changed code while preserving application state, with automatic fallback to full process restart when core framework files change.
The Cordis framework (maintained in the cordiverse/cordis repository) ships with a sophisticated HMR system that eliminates the need for full server restarts during development. By leveraging the @cordisjs/plugin-hmr package, developers can implement hot module replacement that intelligently reloads only modified plugins while maintaining the integrity of the dependency tree and application context.
How the Cordis HMR Plugin Works
The core implementation resides in packages/hmr/src/index.ts, where the Hmr class extends the base Service to provide dependency-aware reloading capabilities. The system operates through a structured process that balances granular updates with safety guarantees.
Service Registration and Context Integration
The Hmr class is injected with the loader and timer services and registered on the Context as ctx.hmr. In packages/hmr/src/index.ts at lines 49-55, the service establishes itself within the Cordis dependency injection container, making HMR capabilities available throughout the application lifecycle.
Module Resolution and File Watching
Before watching begins, the _resolve method at lines 90-95 abstracts internal loader versions (v1 and v2) to normalize module URLs for subsequent cache clearing. The service then initializes a chokidar watcher at lines 108-115, configured with user-provided root directories and ignored patterns. Change events are automatically debounced using the configurable timer to prevent reload cascades during rapid file edits.
Dependency Analysis and Change Classification
When files change, they enter a stashed queue. The analyzeChanges() method at lines 27-74 recursively traverses the ModuleJob dependency graph via loadDependencies to classify changes into three categories:
- Accepted: Files that must be reloaded
- Declined: Files that should be ignored
- Externals: Framework core files that trigger full process restart
The system detects externals by traversing the dependency graph of the CLI entry point at lines 122-155, ensuring that changes to Cordis internals trigger loader.exit() rather than attempting unsafe hot swaps.
Selective Plugin Reloading
The partialReload logic at lines 42-73 builds a mapping from configuration tree URLs to plugin names, then determines which plugins have dependencies in the accepted set. Only those specific plugins are queued for reload, avoiding unnecessary disruption to unaffected parts of the application.
Cache Management and Safe Re-importing
Before re-importing, the system clears both ESM loadCache and CJS require.cache for every accepted file at lines 90-99. The implementation uses Map.prototype.delete to ensure compatibility across Node.js versions 22-24, where internal cache structures differ.
The plugin re-imports each entry file using ctx.loader.import while preserving stack traces. If imports fail, handleError rolls back caches to their previous state at lines 120-130, ensuring that compilation errors never leave the application in a broken state.
Runtime Reregistration and Events
For each successfully imported plugin, the old runtime is disposed via ctx.registry.delete and the new one is instantiated with preserved runtime objects, ensuring fibers maintain their original entry objects at lines 31-38.
After successful reloads, the plugin emits two events on the context:
hmr/change: Fires for individual file changeshmr/reload: Provides a map of all reloaded plugins
Consumers can listen to these events to react to hot updates programmatically at lines 130-132.
Configuring HMR in Your Cordis Project
Add the @cordisjs/plugin-hmr package to your cordis.yml configuration file:
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root:
- .
debounce: 50
ignored:
- '**/node_modules'
- '**/.*'
- 'cache'
- 'data'
The root field specifies directories to watch, while ignored excludes generated folders and dependencies. The debounce value controls throttling in milliseconds, batching rapid successive changes into single reload cycles.
Practical Implementation Examples
Basic Setup with Hot-Reloaded Plugins
This example demonstrates setting up a Cordis context with HMR enabled and loading a user plugin that supports hot reloading:
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Hmr from '@cordisjs/plugin-hmr'
import Logger from '@cordisjs/plugin-logger-console'
async function main() {
const ctx = new Context()
await ctx.plugin(Logger)
await ctx.plugin(Loader)
await ctx.plugin(Hmr, {
root: ['.'],
debounce: 100,
ignored: ['**/node_modules', '**/.*'],
})
// Load a user plugin defined in ./my-plugin.ts
await ctx.loader.create({
name: 'my-plugin',
config: { path: './my-plugin.ts' }
})
// Listen to HMR events
ctx.on('hmr/reload', (reloads) => {
console.log('Reloaded plugins:', [...reloads.keys()].map(p => p.name))
})
}
main()
After ctx.loader.create registers the plugin, any modifications to my-plugin.ts trigger hot reloading without process restart.
Reacting to File-Level Changes
Monitor specific file modifications that don't trigger full plugin reloads:
ctx.on('hmr/change', (url) => {
console.log('File changed:', new URL(url).pathname)
})
This event fires for any file outside the external framework tree that doesn't require a full process restart.
Querying the Dependency Graph
Use the getLinked helper to inspect dependencies of specific modules:
import { pathToFileURL } from 'url'
import { resolve } from 'path'
const url = pathToFileURL(resolve('src/utils.ts')).href
const linked = await ctx.hmr.getLinked(url)
console.log('Dependencies of utils.ts:', linked)
This functionality is tested in packages/hmr/tests/index.spec.ts at lines 84-92, demonstrating how to traverse the module graph for debugging or optimization purposes.
Handling Errors During Hot Reloads
Implement graceful error handling to maintain application stability when hot reloads fail:
import { handleError } from '@cordisjs/plugin-hmr'
try {
await ctx.loader.import(pluginPath)
} catch (e) {
handleError(ctx, e) // rolls back caches and logs the problem
}
This pattern ensures that syntax errors or runtime exceptions during module loading roll back to the previous stable state rather than crashing the application.
Debouncing Rapid File Changes
The built-in debounce mechanism consolidates rapid edits into single reload cycles:
// Simulate rapid edits to the same file
await ctx.hmr.watcher?.emit('change', 'src/plugin.ts')
await ctx.hmr.watcher?.emit('change', 'src/plugin.ts')
await ctx.hmr.watcher?.emit('change', 'src/plugin.ts')
With debounce: 100, these three events result in a single reload operation, optimizing performance during intense development sessions.
Summary
-
The
@cordisjs/plugin-hmrpackage implements dependency-aware hot module replacement that reloads only affected plugins while preserving application state. -
The system uses chokidar for file watching with configurable debouncing, and recursively analyzes the
ModuleJobgraph to classify changes as accepted, declined, or external. -
Cache clearing targets both ESM and CJS module systems for Node.js 22-24 compatibility, with automatic rollback mechanisms that restore previous states when imports fail.
-
Changes to Cordis core files (externals) trigger full process restarts via
loader.exit(), while plugin changes usectx.registry.deleteand re-instantiation to maintain fiber contexts. -
Developers can listen to
hmr/changeandhmr/reloadevents to build custom tooling around the hot reload lifecycle.
Frequently Asked Questions
What is the difference between hmr/change and hmr/reload events?
The hmr/change event fires immediately when a file modification is detected, providing the file URL. The hmr/reload event fires after successful plugin reloading, providing a Map of all reloaded plugin instances. Use hmr/change for logging or preprocessing, and hmr/reload for updating dependent services or clearing derived caches.
Why does changing certain files trigger a full restart instead of hot reloading?
When you modify files within the Cordis framework itself (classified as externals), the HMR system calls loader.exit() to perform a full process restart. This safety mechanism prevents instability that could result from hot-swapping core runtime components while maintaining active service references.
How does Cordis HMR handle ESM and CJS module compatibility?
The implementation in packages/hmr/src/index.ts clears both the ESM loadCache and CJS require.cache using Map.prototype.delete operations. This dual-cache approach ensures that module reloading works correctly across Node.js versions 22-24, regardless of whether your project uses ES modules or CommonJS.
Can I exclude specific directories from triggering reloads?
Yes. Configure the ignored array in your HMR plugin configuration to exclude directories using glob patterns. Common exclusions include '**/node_modules', '**/.*' for hidden files, and project-specific folders like 'cache' or 'data' to prevent unnecessary reloads when generated files change.
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 →