How Cordis ModuleLoader Manages Entry Trees and Config Loading
The Cordis ModuleLoader organizes plugins into a hierarchical entry tree using the abstract EntryTree class in packages/loader/src/config/tree.ts, while loading configurations through the isolate plugin and delegating module resolution to Node's internal ESM loader.
Cordis relies on a first-class loader service to handle dynamic plugin management and configuration loading. The ModuleLoader (exposed as ctx.loader) extends EntryTree to provide CRUD operations on entries, intercept configuration changes, and bridge Node's native module system. This article breaks down the implementation details from the cordiverse/cordis source code.
Entry Tree Architecture in EntryTree
The entry tree forms the backbone of Cordis's plugin hierarchy. The abstract EntryTree class in packages/loader/src/config/tree.ts maintains two core data structures:
this.root— anEntryGroupinstance holding top-level entriesthis.store: Dict<Entry>— a flat map for O(1) entry lookup by unique ID
Tree Traversal and Resolution
The *entries() generator yields all entries recursively, enabling full-tree operations. For ID-based navigation, the loader implements colon-separated path resolution:
| Method | Purpose |
|---|---|
resolve(id) |
Locates a concrete entry from a path like a:b:c |
resolveGroup(id) |
Returns the parent EntryGroup for a given ID (or root) |
CRUD Operations
create(options, parent?, position?) inserts a new entry, updates the parent group, and persists the tree. Source: tree.ts lines 76-81.
remove(id) deletes an entry by ID and cleans up its parent group reference.
update(id, options, parent?, position?) modifies or relocates an entry, then triggers a tree write. This powers runtime configuration updates. Source: tree.ts lines 89-101.
import(name) handles dynamic module loading, delegating to either built-in modules or Node's internal loader.
The Loader Service Implementation
The concrete Loader class in packages/loader/src/index.ts extends EntryTree and wires Cordis's service ecosystem to the entry tree.
Construction and Internal State
public internal = ModuleLoader.fromInternal() // Node's ESM loader wrapper
public builtins: Dict<any> = Object.create(null) // "cordis:" prefixed modules
The fromInternal() factory detects Node version and instantiates the appropriate loader implementation (v1 for Node 22-23, v2 for Node 24+).
Event Hook Registration
The loader binds to three internal Cordis events during construction (lines 74-80, 88-124):
internal/update— Persists configuration changes toentry.optionsand writes the updated treeinternal/plugin— Tracks plugin fiber lifecycle, assignsfiber.entry, and logs loading/unloadingloader/config-update— Emitted after successful configuration refresh
These hooks keep the entry tree synchronized with runtime state changes.
Configuration Interception with Service.check
The Loader implements Service.check (lines 33-37) to respect the await flag from Loader.Intercept. This delays disposal until pending tasks complete via getTasks().
// Pseudo-code from source
if (this.ctx.get('loader')?.await) {
await Promise.all(this.getTasks())
}
Dynamic Import Resolution
Loader.import(name) (lines 103-119) implements a two-tier resolution strategy:
public async import(name: string) {
// Built-in shortcut
if (name.startsWith('cordis:')) {
return this.builtins[name.slice(7)]
}
// Delegate to Node's internal loader
return this.ctx.loader.internal.import(name)
}
This enables both user plugins (import('my-plugin')) and framework internals (import('cordis:logger')) to load through a unified interface.
Node Internal Module Loader Wrapper
Cordis abstracts Node's evolving ESM loader API through ModuleLoader in packages/loader/src/internal.ts. The version detection logic (lines 12-23) branches based on process.versions.node:
| Node Version | Loader Implementation |
|---|---|
| 22.x - 23.x | ModuleLoaderV1 |
| 24.x+ | ModuleLoaderV2 |
Both implementations expose import, resolve, and load methods, allowing Loader to call this.ctx.loader.internal.import() without version-specific handling.
Config Loading via the Isolate Plugin
Configuration files execute in isolated contexts through the isolate plugin registered at line 26 of index.ts:
ctx.plugin(isolate)
The isolate plugin (packages/loader/src/config/isolate.ts) creates a sandboxed execution environment for each entry's configuration. This captures side-effects as structured EntryOptions and prevents configuration pollution across plugins.
The isolation mechanism ensures that:
- Configuration evaluation errors are contained per-entry
- Global modifications from one config don't affect others
- The loader can extract and persist
options.configdeterministically
Complete Entry Lifecycle Flow
Understanding how components interact clarifies the design:
- Bootstrap:
new Loader(ctx)creates the root entry group and registers event listeners - Plugin Discovery: The isolate plugin evaluates a config file, producing
EntryOptions - Tree Insertion:
loader.create(options, parent, position)adds the entry tothis.storeandparent.group - Runtime Monitoring: The
internal/pluginhook attaches the Cordis fiber toentry.fiber - Hot Reload:
internal/updatefires →loader.update()modifiesentry.options→ tree persists →loader/config-updateemits
Practical Code Examples
Creating and Accessing Entries
import { Context } from 'cordis'
import Loader from '@cordis/loader'
const ctx = new Context()
await ctx.start()
const loader = new Loader(ctx) // registers as ctx.loader
const entry = await loader.create({
name: 'data-processor',
config: { batchSize: 100 }
}, null, 0) // parent=null (root), position=0
console.log(entry.id) // generated unique identifier
Programmatic Configuration Update
// Modify existing entry configuration
await loader.update('data-processor', {
config: { batchSize: 500, retry: true }
})
// Triggers tree write and config-update event
Loading Modules Through the Loader
// User module via Node's internal loader
const processor = await loader.import('./processors/csv.js')
// Built-in module via cordis: prefix
const logger = await loader.import('cordis:logger')
Summary
EntryTree(packages/loader/src/config/tree.ts) provides the hierarchical storage and CRUD interface for plugin entriesLoader(packages/loader/src/index.ts) extendsEntryTreewith service integration, event hooks, and module resolutionModuleLoader(packages/loader/src/internal.ts) version-detects and wraps Node's native ESM loader- Isolate plugin sandboxes configuration evaluation, producing structured entry options
- The entry lifecycle flows from config isolation → tree creation → fiber attachment → update synchronization
Frequently Asked Questions
How does Cordis resolve colon-separated entry IDs?
The EntryTree.resolve() method in packages/loader/src/config/tree.ts splits IDs like a:b:c and traverses nested EntryGroup instances. Each segment maps to a child entry or group, with the final segment resolving to a concrete Entry object. Missing segments throw resolution errors.
What happens when a plugin configuration changes at runtime?
The internal/update event fires (line 74-80 in index.ts), triggering loader.update() to modify entry.options.config, persist the tree structure, and emit loader/config-update. The Service.check await flag can delay this until pending tasks finish.
Why does Cordis wrap Node's internal ESM loader?
Node's ESM loader API changed between versions 22-23 (v1) and 24+ (v2). The ModuleLoader abstraction in packages/loader/src/internal.ts provides version detection and a unified interface, letting Loader.import() work across Node versions without conditional code.
What is the purpose of the cordis: prefix in imports?
The cordis: prefix routes module requests to loader.builtins instead of the filesystem. This allows core services to expose stable interfaces while keeping the internal file structure flexible. Built-ins are registered in Object.create(null) to avoid prototype pollution.
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 →