How Group Configuration Enables Hierarchical Plugin Loading in Cordis
Cordis uses a special group configuration flag in EntryOptions to create nested EntryGroup instances that isolate plugins in separate contexts while maintaining parent-child inheritance through prototype chaining.
Group configuration is the foundation of Cordis's hierarchical plugin system. It allows developers to organize plugins into nested, dynamically reconfigurable trees—each with its own scope, lifecycle, and service namespace. This article explains how cordiverse/cordis implements this architecture through its EntryTree, EntryGroup, and Entry classes.
What Is a Group in Cordis?
A group is a container entry that holds an array of plugin configurations (EntryOptions[]) and manages a dedicated Context. Unlike regular plugin entries, groups don't directly execute code—they orchestrate child plugins.
When group: true is set in EntryOptions, Cordis instantiates a Group subclass instead of a standard Entry. This disables the disabled-check cascade and enables nested loading behavior.
// packages/loader/src/config/entry.ts
export interface EntryOptions {
id?: string
name?: string
group?: boolean | null // ← triggers group mode
disabled?: boolean
// ...
}
The EntryGroup Base Class and Group Implementation
The EntryGroup abstract class (located in packages/loader/src/config/group.ts) defines the contract for group behavior. Its concrete implementation, Group, handles runtime configuration updates through the internal/update event.
// packages/loader/src/config/group.ts
export abstract class EntryGroup {
abstract data: EntryOptions[]
abstract ctx: Context
// ...
}
export class Group extends EntryGroup {
data: EntryOptions[]
ctx: Context
constructor(public parent: Context, public config: EntryOptions) {
super()
// Link to the fiber tree for hierarchy tracking
const tree = parent.fiber.entry!.parent.tree
// Register update listener for dynamic reconfiguration
parent.on('internal/update', (config) => this.update(config))
}
*init(): Generator<() => void, void, unknown> {
yield () => this.stop()
await this.update(this.config)
}
async update(config: EntryOptions) {
// Iterate data array, create/remove/patch child entries
// ...
}
stop() {
// Cleanup all child entries
// ...
}
}
The constructor establishes three critical connections:
- Tree linkage — Connects to
parent.fiber.entry!.parent.treefor global entry tracking - Context creation — Creates a child
Contextthat inherits from the parent - Update subscription — Listens for
internal/updateto enable runtime reconfiguration
How the Entry Tree Manages Hierarchy
EntryTree (in packages/loader/src/config/tree.ts) maintains the global registry of all entries and provides group resolution utilities.
// packages/loader/src/config/tree.ts
export abstract class EntryTree {
store = new Map<string, Entry>()
resolveGroup(id: string | null): EntryGroup {
if (id === null) return this.root
const entry = this.store.get(id)
if (!entry) throw new Error(`entry ${id} not found`)
if (!entry.group) throw new Error(`entry ${id} is not a group`)
return entry.group
}
create(parentId: string | null, options: EntryOptions): string {
const group = this.resolveGroup(parentId)
const id = randomId()
group.data.push({ ...options, id })
this.write()
return id
}
remove(id: string) {
// Find and splice from parent's data array
// ...
this.write()
}
update(id: string, options: EntryOptions) {
// Patch existing entry config
// ...
this.write()
}
abstract write(): void // Persist configuration
}
The resolveGroup method is the gateway to hierarchical navigation:
- Returns the root group when
idisnull - Validates that the target entry is actually a group
- Enables CRUD operations at any level of the tree
The Hierarchical Loading Flow
Cordis builds plugin hierarchies through a recursive initialization process:
1. Root Group Creation
When ctx.loader.init(config) is called, the EntryTree constructor creates the root EntryGroup as the entry point.
2. Group Detection and Instantiation
During update, the loader checks each configuration object. If group: true, it instantiates a new Group rather than creating a leaf Entry.
3. Context Isolation with Inheritance
Each group creates its own Context with prototype-chained inheritance:
Object.setPrototypeOf(this.ctx, this.parent.ctx)
This design provides:
- Service access — Child plugins can resolve services from ancestor contexts
- Namespace isolation — Each group's services don't collide with siblings
- Clean disposal — Removing a group automatically cleans up its entire subtree
4. Recursive Descent
The Group.update() method iterates over its data array, calling tree.create() for each child. If a child has group: true, the process repeats, building deeper levels.
Runtime Reconfiguration of Groups
Groups remain mutable after initialization. The internal/update event triggers Group.update(), which performs diff-driven reconciliation:
| Operation | Trigger | Effect |
|---|---|---|
| Create | New entry in data array |
Instantiate new Entry or Group |
| Remove | Entry missing from data |
Call entry.stop() and cleanup |
| Patch | Same id, different options |
Update fiber config or reinitialize |
This makes hierarchical plugin trees fully dynamic—groups can be restructured without application restart.
import { Context } from 'cordis'
// Initial configuration with nested group
const config = [
{
id: 'api',
name: '@cordis/api',
group: true // ← creates isolated API context
},
{ id: 'web', name: '@cordis/web' }
]
const ctx = new Context()
await ctx.loader.init(config)
// Dynamically extend the 'api' subgroup with new plugins
ctx.loader.updateGroup('api', [
{ id: 'graphql', name: '@cordis/graphql' },
{ id: 'rest', name: '@cordis/rest', group: true } // ← nested subgroup
])
Key Files and Responsibilities
packages/loader/src/config/group.ts— DefinesEntryGroupandGroup; handles group-level lifecycle and update propagationpackages/loader/src/config/entry.ts— Implements plugin entries; recognizesgroupflag and delegates toEntryGrouppackages/loader/src/config/tree.ts— Manages global entry registry; providesresolveGroupand CRUD operationspackages/loader/tests/group.spec.ts— Test coverage for group declaration, nesting, and hot-reloading
Summary
- Group configuration uses
group: trueinEntryOptionsto triggerGroupinstantiation instead of leafEntry EntryGroupstoresEntryOptions[]and manages a isolatedContextwith prototype inheritance from parentEntryTreeprovides hierarchical navigation viaresolveGroup(id)and persists changes throughwrite()- Recursive initialization builds context trees where each group can contain plugins or nested groups
- Runtime updates through
internal/updateenable dynamic addition, removal, and reconfiguration of entire subtrees
Frequently Asked Questions
How do I declare a plugin group in Cordis configuration?
Add group: true to your EntryOptions object. This can be done in your initial config array passed to ctx.loader.init() or via ctx.loader.create() at runtime. The loader will instantiate a Group class instead of a standard Entry, creating a new context scope for its children.
Can groups be nested arbitrarily deep?
Yes. Any entry inside a group's data array can itself have group: true, triggering recursive Group instantiation. The EntryTree tracks all entries in a flat store map while maintaining parent-child relationships through the tree structure, so depth is limited only by memory and practical organization needs.
How does context inheritance work between parent and child groups?
When a Group is created, it calls Object.setPrototypeOf(this.ctx, this.parent.ctx) to establish prototype chaining. This means child contexts can access services registered in ancestors via normal property lookup, while services registered in the child shadow parent definitions. Removing a group breaks the chain and triggers cleanup of its entire subtree.
What happens when a group's configuration is updated at runtime?
The Group constructor registers an internal/update listener that calls this.update(config). This method diffs the new data array against existing entries: new items trigger tree.create(), missing items trigger entry.stop(), and changed items trigger entry.update() or full reinitialization if the plugin name changed. The entire process is atomic and persists via tree.write().
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 →