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 — an EntryGroup instance holding top-level entries
  • this.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):

  1. internal/update — Persists configuration changes to entry.options and writes the updated tree
  2. internal/plugin — Tracks plugin fiber lifecycle, assigns fiber.entry, and logs loading/unloading
  3. loader/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.config deterministically

Complete Entry Lifecycle Flow

Understanding how components interact clarifies the design:

  1. Bootstrap: new Loader(ctx) creates the root entry group and registers event listeners
  2. Plugin Discovery: The isolate plugin evaluates a config file, producing EntryOptions
  3. Tree Insertion: loader.create(options, parent, position) adds the entry to this.store and parent.group
  4. Runtime Monitoring: The internal/plugin hook attaches the Cordis fiber to entry.fiber
  5. Hot Reload: internal/update fires → loader.update() modifies entry.options → tree persists → loader/config-update emits

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 entries
  • Loader (packages/loader/src/index.ts) extends EntryTree with service integration, event hooks, and module resolution
  • ModuleLoader (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:

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 →