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:

  1. Tree linkage — Connects to parent.fiber.entry!.parent.tree for global entry tracking
  2. Context creation — Creates a child Context that inherits from the parent
  3. Update subscription — Listens for internal/update to 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 id is null
  • 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

Summary

  • Group configuration uses group: true in EntryOptions to trigger Group instantiation instead of leaf Entry
  • EntryGroup stores EntryOptions[] and manages a isolated Context with prototype inheritance from parent
  • EntryTree provides hierarchical navigation via resolveGroup(id) and persists changes through write()
  • Recursive initialization builds context trees where each group can contain plugins or nested groups
  • Runtime updates through internal/update enable 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:

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 →