Best Practices for Cordis Plugin Entry Points: Configuration, Lifecycle, and Grouping

Cordis plugins are instantiated through lightweight entries—metadata wrappers defined in packages/loader/src/config/entry.ts that manage configuration interpolation, hierarchical IDs, and lifecycle transitions via the init, update, and refresh methods.

Cordis is a modular plugin framework where every plugin registers through the entry system located in the @cordis/loader package. Mastering best practices for Cordis plugin entry points ensures deterministic namespacing, clean configuration management, and predictable lifecycle behavior across your application.

Understanding the Entry Class Architecture

The Entry class serves as the central abstraction for plugin registration. It handles module resolution, context isolation, and state management through a declarative options interface.

EntryOptions Interface Structure

Every entry is defined by an EntryOptions object declared at lines 8-15 of packages/loader/src/config/entry.ts. This interface requires:

  • id (required): Unique identifier within the plugin tree
  • name (required): Module name to import (package or file path)
  • config (optional): Configuration object supporting template interpolation
  • group (optional): Boolean indicating whether the entry contains children
  • disabled (optional): Boolean controlling runtime availability
  • inject (optional): Dependency injection configuration

The id field acts as the canonical reference for lookups, while name specifies the actual module to load via the internal tree.import(name, getOuterStack) method.

Hierarchical ID Namespacing

When an entry belongs to a group, Cordis automatically prefixes its ID with the parent entry's ID using the pattern parent.id + '/' + id. This deterministic namespacing is implemented in the id getter at lines 56-62 of packages/loader/src/config/entry.ts.

This hierarchy enables clean lookup chains and prevents naming collisions across large plugin trees. Child entries inherit their parent's namespace, simplifying configuration scoping and cross-plugin communication.

The Disabled Flag Behavior

The disabled getter at lines 64-73 implements cascade logic: if any ancestor entry is disabled, all children are automatically disabled regardless of their individual flags. Groups themselves are always enabled; disabling a group entry effectively disables every descendant.

A disabled entry is never loaded, and Cordis automatically disposes the associated fibre when an entry transitions to disabled.

Lifecycle Management Best Practices

Cordis manages plugin state through three distinct lifecycle methods: init(), update(), and refresh(). Understanding these transitions prevents memory leaks and ensures clean reconfiguration.

The Init-Update-Refresh Flow

The async flow defined at lines 94-134 handles plugin instantiation and mutation:

  • init(): Imports the module via tree.import(), creates the runtime fibre, registers the plugin with its interpolated configuration, and emits the loader/entry-init event
  • update(): Mutates the entry's options, disposes the old fibre when necessary, emits loader/partial-dispose, and optionally re-initialises the plugin if configuration changes require a full reload
  • refresh(): A convenience method that only re-initialises a non-loaded, enabled entry

Always use entry.update(newOptions) instead of mutating entry.options directly. The update method guarantees proper disposal sequencing and event emission.

Event Hooks and Cleanup

The entry system emits two critical lifecycle events during construction and updates:

  • loader/entry-init: Emitted at line 49 when the entry first initialises
  • loader/partial-dispose: Emitted during update() when replacing an already-loaded entry

Plugins can listen to these events to perform custom cleanup or state migration during hot-reconfiguration scenarios.

Configuration Patterns

Cordis supports declarative configuration through string interpolation and group-based scoping.

Context-Aware Configuration Interpolation

When a plugin's default export is not an entry group, the entry's config object undergoes interpolation through the current context. This process, handled by _resolveConfig and the interpolate method at lines 79-82 of packages/loader/src/config/entry.ts, replaces patterns like ${env.VAR} with actual environment values.

Store raw configuration templates in options.config and let Cordis resolve variables at load time. This keeps configuration portable across environments without hardcoding secrets.

Plugin Groups and Deterministic Namespacing

Set group: true in a parent entry's options to create a namespace container. Child entries automatically receive hierarchical IDs (parent.id/child.id), simplifying tree navigation and configuration inheritance.

Groups provide logical organization for related plugins while maintaining strict isolation between different branches of the plugin tree.

Runtime State Management

Effective entry point management requires atomic state changes and proper error handling.

Atomic Updates via entry.update()

Never mutate entry properties directly. Calling entry.update() ensures:

  1. Proper fibre disposal and cleanup
  2. Emission of loader/partial-dispose events
  3. Conditional re-initialisation based on changed properties
  4. Thread-safe state transitions

Feature Toggling with the Disabled Flag

Leverage the disabled boolean for runtime feature flags without removing entries from the tree. When you disable an entry, Cordis automatically disposes the fibre and skips loading. Re-enabling triggers a fresh initialisation without requiring bootstrap script changes.

This pattern is particularly effective for A/B testing and conditional feature deployment.

Practical Implementation Examples

Defining a Simple Plugin Entry

Create a plugin that exports a default function accepting a Cordis Context:

import { Context } from 'cordis'

export default (ctx: Context) => {
  ctx.on('ready', () => console.log('my-plugin is ready!'))
}

Register the plugin through the loader API exposed in packages/loader/src/index.ts:

await ctx.loader.entry('my-plugin', {
  id: 'my-plugin',
  name: 'my-plugin',
  config: { greeting: '${env.GREETING}' },
  disabled: false,
})

The name field matches the package identifier, while config uses interpolation for environment-specific values.

Creating a Plugin Group

Groups organize related plugins under a common namespace:

// Parent group entry
await ctx.loader.entry('utils', {
  id: 'utils',
  name: 'utils',
  group: true,
})

// Child entry automatically receives ID 'utils/logger'
await ctx.loader.entry('utils/logger', {
  id: 'logger',
  name: '@my/logger',
  config: { level: 'debug' },
})

The child entry's effective ID becomes utils/logger, and its configuration is scoped under the same tree branch.

Disabling a Plugin at Runtime

Toggle plugin availability without tree modification:

const entry = await ctx.loader.entry('feature-x', { 
  id: 'feature-x', 
  name: 'feature-x' 
})

// Dispose fibre and stop plugin
await entry.update({ disabled: true })

// Re-enable later
await entry.update({ disabled: false })

Updating Configuration Without Full Reload

Modify configuration while preserving runtime state when possible:

await entry.update({
  config: { greeting: 'Hello, world!' }
})

Cordis calls fiber.update with the new configuration if the entry is already loaded, avoiding unnecessary disposal cycles.

Summary

  • Always provide stable, unique IDs: Use deterministic naming schemes and avoid changing IDs after creation to prevent lookup chain breaks.
  • Use groups for organization: Set group: true to enable hierarchical namespacing and simplify configuration inheritance.
  • Leverage atomic updates: Call entry.update() for all state changes to ensure proper lifecycle event emission and fibre management.
  • Implement feature toggles: Use the disabled flag instead of removing entries to enable runtime plugin control.
  • Never import manually: Rely on tree.import() through the entry system to ensure proper error handling and stack tracing via the getOuterStack parameter.
  • Interpolate configuration: Store templates in config fields and let Cordis resolve environment variables through context-aware interpolation.

Frequently Asked Questions

What is the difference between an entry's id and name fields?

The id field serves as the unique identifier within the plugin tree and supports hierarchical namespacing when entries belong to groups. The name field specifies the module to import—either a package name resolvable by Node.js or a file path. While id must be unique within the tree, name can be shared across multiple entries loading the same module with different configurations.

How does Cordis handle configuration environment variables?

Cordis interpolates configuration values through the entry's context when the plugin's default export is not an entry group. The _resolveConfig method processes templates like ${env.VARIABLE} at load time, replacing them with actual environment values. This occurs at lines 79-82 of packages/loader/src/config/entry.ts, keeping secrets out of configuration files while maintaining portability.

What happens when I disable a group entry?

When you set disabled: true on a group entry (or any of its ancestors), Cordis immediately disables every descendant entry regardless of their individual flags. The disabled getter at lines 64-73 implements this cascade logic. Disposing a group entry also disposes all child fibres, making this an effective nuclear option for shutting down feature sets.

Can I update a plugin's configuration without restarting the entire application?

Yes. Use entry.update({ config: newConfig }) to trigger a partial update. If the entry is already loaded, Cordis emits loader/partial-dispose and attempts to call fiber.update with the new configuration. This allows hot-reconfiguration for stateful plugins that support incremental updates without full re-initialisation.

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 →