How Hot Module Replacement (HMR) is Implemented in Cordis: A Deep Dive into cordis-hmr

Cordis implements HMR through the @cordisjs/plugin-hmr package, which uses a dependency-aware watcher to selectively reload plugins while preserving application state and falling back to full restarts for core framework changes.

The Hot Module Replacement system in Cordis allows developers to update plugin code without restarting the entire application. According to the cordiverse/cordis source code, the official HMR plugin watches file changes and performs intelligent dependency analysis to determine exactly which modules need reloading. This implementation lives primarily in packages/hmr/src/index.ts and integrates deeply with the Cordis loader service.

Core Architecture and Service Registration

The HMR system is built as a first-class service that interacts with Cordis's dependency injection container. Understanding how it registers itself and manages internal state reveals how the framework maintains stability during hot updates.

Service Registration and Context Integration

In packages/hmr/src/index.ts, the Hmr class extends the base Service class and declares dependencies on the loader and timer services. When instantiated, it registers itself on the context as ctx.hmr, making its API available throughout the application lifecycle.

The constructor initializes internal maps for tracking file changes and stashed updates:

// From packages/hmr/src/index.ts#L49-L55
class Hmr extends Service {
  static inject = ['loader', 'timer']
  
  constructor(ctx: Context, public config: Hmr.Config) {
    super(ctx, 'hmr')
    this.stashed = new Set<string>()
    // ...
  }
}

Module Resolution and Cache Abstraction

Before watching can begin, the plugin must resolve module URLs in a version-agnostic way. The _resolve method in packages/hmr/src/index.ts#L90-L95 abstracts differences between loader API versions (v1 and v2), ensuring that the HMR plugin can clear the correct cache entry regardless of the underlying loader implementation.

This abstraction is critical because Cordis supports both ESM and CommonJS module systems. The resolution logic returns a file URL that can be used to purge entries from both the ESM loadCache and the CJS require.cache.

The Hot Reload Lifecycle

When a developer saves a file, the HMR system executes a ten-step process to determine whether to reload a specific plugin, ignore the change, or restart the entire process.

File Watching and Debouncing

Upon initialization, the service creates a chokidar watcher using the user-provided root directories and ignored patterns. The watcher is configured with a debounce timer to batch rapid successive changes:

// Conceptual usage based on packages/hmr/src/index.ts#L108-L115
await ctx.plugin(Hmr, {
  root: ['.'],
  debounce: 50,  // milliseconds
  ignored: ['**/node_modules', '**/.*']
})

The debounce mechanism prevents a cascade of reloads when multiple files change simultaneously or when an editor performs rapid save operations.

Change Detection and Classification

When a file change event fires, the path is added to an internal stashed set. The analyzeChanges() method (located at packages/hmr/src/index.ts#L74-L27) then traverses the dependency graph to classify each changed file into one of three categories:

  • Accepted: Files that can be safely reloaded
  • Declined: Files that should be ignored (not part of any plugin)
  • Externals: Framework core files that require a full process restart

Dependency Graph Analysis

The plugin builds a bidirectional understanding of module relationships. By walking the ModuleJob graph via loadDependencies, the partialReload method (packages/hmr/src/index.ts#L42-L73) constructs a map from config tree URLs to plugin names. It then checks whether any dependencies of a given plugin fall into the accepted set.

Only plugins with at least one dependency in the accepted set are queued for reloading. This dependency-aware analysis ensures that changing a utility file shared by three plugins triggers updates for all three, while changing an unused file triggers nothing.

Selective Plugin Reloading

For each plugin marked for reload, the system performs the following sequence:

  1. Cache Clearing: Removes entries from both ESM loadCache and CJS require.cache using Map.prototype.delete to handle differences across Node.js versions 22-24 (packages/hmr/src/index.ts#L90-L99).

  2. Re-importing: Re-imports the entry file via ctx.loader.import while preserving the outer stack trace (packages/hmr/src/index.ts#L120-L30).

  3. Runtime Replacement: Disposes the old plugin runtime using ctx.registry.delete and instantiates the new one while preserving the original runtime object so that fibers maintain their entry references (packages/hmr/src/index.ts#L31-L38).

Error Handling and Rollback

If any import throws during the reload phase, the handleError utility rolls back the caches to their previous state. This graceful rollback mechanism guarantees that a syntax error in a hot update never leaves the application in a broken state—the previous working version remains active while the error is logged for the developer to fix.

After successful completion, the plugin emits two events:

  • hmr/change: Fired for individual file changes
  • hmr/reload: Contains a map of all reloaded plugins

Configuration and Practical Usage

To enable HMR in a Cordis project, add the plugin to your configuration file:


# cordis.yml

- id: hmr
  name: '@cordisjs/plugin-hmr'
  config:
    root:
      - .
    debounce: 50
    ignored:
      - '**/node_modules'
      - '**/.*'
      - 'cache'
      - 'data'

Programmatic Setup Example

For programmatic control, instantiate the plugin directly:

import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Hmr from '@cordisjs/plugin-hmr'

async function main() {
  const ctx = new Context()
  await ctx.plugin(Loader)
  await ctx.plugin(Hmr, {
    root: ['.'],
    debounce: 100,
    ignored: ['**/node_modules']
  })

  // Register a plugin to be hot-reloaded
  await ctx.loader.create({ 
    name: 'my-plugin', 
    config: { path: './my-plugin.ts' } 
  })
  
  // Listen for reload events
  ctx.on('hmr/reload', (reloads) => {
    console.log('Reloaded:', [...reloads.keys()].map(p => p.name))
  })
}

Reacting to File Changes

Monitor specific file updates without triggering plugin reloads:

ctx.on('hmr/change', (url) => {
  console.log('File changed:', new URL(url).pathname)
})

Querying the Dependency Graph

Inspect dependencies for debugging purposes:

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:', linked)

This corresponds to the test implementation found in packages/hmr/tests/index.spec.ts#L84-L92.

Summary

  • Service-based architecture: The Hmr class extends Service and registers as ctx.hmr in packages/hmr/src/index.ts, integrating with Cordis's loader and timer services.
  • Intelligent change classification: The analyzeChanges() method separates files into accepted, declined, and external categories, triggering full restarts only for framework core changes.
  • Dependency-aware reloading: By traversing the ModuleJob graph, the system reloads only plugins affected by changed dependencies, not the entire application.
  • Cross-platform cache management: Uses Map.prototype.delete to safely clear both ESM and CJS caches across Node.js versions 22-24.
  • Atomic error handling: Failed imports trigger automatic cache rollback via handleError, ensuring the application never remains in a broken state.
  • Event-driven feedback: Emits hmr/change and hmr/reload events for monitoring and logging hot update cycles.

Frequently Asked Questions

How does cordis-hmr handle changes to node_modules?

Files matching the ignored patterns (which default to **/node_modules) are excluded from the watcher. If a dependency outside the watched tree changes, it will not trigger a hot reload unless it is explicitly included in the root configuration. However, if a core framework file within node_modules is part of the Cordis externals graph, changing it will trigger a full process restart via loader.exit().

What happens if a hot reload fails due to a syntax error?

The system implements graceful rollback. When ctx.loader.import throws during the re-import phase, the handleError utility restores the original ESM loadCache and CJS require.cache entries to their pre-reload state. The application continues running the previous working version of the plugin, and the error is logged for the developer to fix.

Can I adjust how long the watcher waits before reloading?

Yes. The debounce configuration option controls the throttling window in milliseconds. With a setting of debounce: 100, rapid successive changes to the same file within 100 milliseconds are batched into a single reload event. This is implemented in packages/hmr/src/index.ts#L108-L115 using the timer service.

Does cordis-hmr work with both ESM and CommonJS modules?

Yes. The implementation in packages/hmr/src/index.ts clears both the ESM loadCache (used by the Cordis loader) and the Node.js require.cache (used by CommonJS). The _resolve method abstracts loader version differences, ensuring that module URLs are correctly mapped for cache eviction regardless of the module system used by your plugins.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →