Understanding Internal Events for Plugin Communication in Cordis

Cordis implements a lightweight, type-safe event system centered on EventsService that enables decoupled plugin-to-plugin communication through reserved internal/* namespace events and multiple dispatch strategies.

The cordiverse/cordis framework relies on a central event hub to coordinate interactions between independently loaded plugins. Every Context instance owns an events object that serves as the backbone for message passing, lifecycle hooks, and service orchestration across the entire application.

The EventsService Architecture

The heart of Cordis communication lies in EventsService, defined in packages/core/src/events.ts. Each Context instance holds an events property that references this service, creating a dedicated event bus for every plugin scope. The service maintains an internal _hooks map that stores registered callbacks and exposes methods for binding, dispatching, and managing event listeners throughout the plugin lifecycle.

Registering Event Listeners

Plugins register interest in events through ctx.on(name, listener, options?) or ctx.once(name, listener). These methods delegate to EventsService.on, which performs context binding before storage. The registration flow executes the 'internal/listener' hook first, allowing other plugins to intercept or modify listener attachment:

// packages/core/src/events.ts – registration flow
this.ctx.fiber.assertActive()
listener = this.ctx.reflect.bind(listener)   // bind context utilities
const result = this.bail(this.ctx, 'internal/listener', name, listener, options)
if (result) return result
const label = `ctx.on(${JSON.stringify(name)})`
return this.register(label, name, listener, options)

Dispatch Modes and Execution Strategies

Cordis supports five distinct dispatch strategies through the _resolve method, each encoded as a string literal argument. The mode determines how listeners execute and whether results propagate:

  • emit – Fire all listeners concurrently and discard return values
  • parallel – Execute listeners concurrently while aggregating errors
  • serial – Invoke listeners sequentially until a bail value returns
  • bail – Stop at the first non-null/false/undefined result
  • waterfall – Chain listeners sequentially, passing each output to the next input

After resolution in _resolve, the appropriate execution method (parallel, serial, bail, or waterfall) processes the callback queue.

The Internal Event Namespace

Cordis reserves the internal/* namespace for framework-level communication that drives plugin lifecycles. These events are defined in the Events interface at the bottom of packages/core/src/events.ts and include:

  • internal/plugin – Emitted when packages/loader/src/index.ts instantiates a new plugin fiber
  • internal/status – Fired when a plugin Fiber transitions between states (starting, running, stopped)
  • internal/service – Triggered when core services register or override via ctx.service(name, value)
  • internal/update – Signals configuration updates, allowing listeners to prepend or append logic
  • internal/get and internal/set – Intercept property access on context objects
  • internal/listener – Meta-event that fires when any event listener registers
  • internal/dispatch – Hooks into the dispatch process for logging or modification

Cross-Plugin Communication Patterns

Plugins communicate by publishing custom events while optionally tapping into internal lifecycle hooks. A plugin emits public events using ctx.emit('my-plugin/ready') or ctx.parallel('my-plugin/ready'), while consumers bind with ctx.on('my-plugin/ready', callback). For deeper integration, plugins monitor internal/plugin to react to loading events or internal/status to track state changes in sibling components.

Context Integration and Lifecycle Wiring

The Context class in packages/core/src/context.ts instantiates the event system during construction, binding it to the plugin's Fiber lifecycle:

this.fiber = new Fiber(self, …)
this.reflect = new ReflectService(self)
this.registry = new RegistryService(self)
this.events = new EventsService(self)   // ← creates the event hub
this.logger = new LoggerService(self)

This initialization ties EventsService to the current fiber, ensuring that all registered listeners automatically dispose when the plugin stops. The integration between packages/core/src/fiber.ts and EventsService enables the 'internal/status' emissions that track plugin health.

Practical Implementation Examples

Listen to custom events from other plugins:

ctx.on('my-plugin/data', (payload) => {
  console.log('Received data:', payload)
})

Hook into the plugin load lifecycle:

ctx.on('internal/plugin', (fiber) => {
  console.log(`Plugin loaded: ${fiber.name}`)
})

Intercept configuration updates with waterfall semantics:

ctx.on('internal/update', (config, noSave, next) => {
  console.log('Config about to change:', config)
  return next()
}, { global: true, prepend: true })

Execute serial computation with bail semantics:

ctx.serial('my-plugin/compute', (input) => {
  if (input < 0) return 'negative'   // bail with result
  // fall through to next listener
})

Summary

  • EventsService in packages/core/src/events.ts serves as the centralized event bus for every Context instance
  • Five dispatch modes (emit, parallel, serial, bail, waterfall) control how listeners execute and return values propagate
  • Internal namespace events (internal/*) provide hooks for plugin lifecycle, service registration, and configuration updates
  • Automatic disposal ties event registration to Fiber lifecycles, preventing memory leaks when plugins unload
  • Meta-events like internal/listener and internal/dispatch enable plugins to observe and modify the event system itself

Frequently Asked Questions

What is the difference between ctx.emit and ctx.parallel in Cordis?

ctx.emit fires all matching listeners concurrently and ignores their return values, making it ideal for notifications that require no response. ctx.parallel also executes listeners concurrently but aggregates thrown errors into a single rejection, ensuring that all listeners attempt execution even if some fail. Both methods route through EventsService but use different error-handling strategies in the underlying dispatcher.

How do I listen to internal plugin lifecycle events?

Register listeners for internal/plugin to detect when new plugins load, or internal/status to monitor state transitions like starting, running, and stopped. These events fire from packages/loader/src/index.ts and packages/core/src/fiber.ts respectively, allowing your plugin to react to the overall system state without direct coupling to other components.

Can I intercept event listener registration in Cordis?

Yes. The internal/listener event fires whenever any code calls ctx.on or ctx.once, passing the event name, bound listener function, and options. By listening to this meta-event with ctx.bail or ctx.on, you can block registration by returning a value, modify the listener before storage, or log registration attempts for debugging purposes.

Where are internal event types defined?

The TypeScript interfaces for internal events reside at the bottom of packages/core/src/events.ts, where the Events interface declares type signatures for internal/plugin, internal/status, internal/service, and other framework-level events. These definitions provide type safety when using ctx.on or ctx.emit with internal namespace strings.

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 →