Cordis EventsService Dispatch Modes Explained: emit, parallel, serial, bail, and waterfall
Cordis EventsService provides five distinct dispatch modes—emit, parallel, serial, bail, and waterfall—that control how event listeners are executed, ranging from simple synchronous notification to complex async pipelines and early-return patterns.
The Cordis framework (maintained at cordiverse/cordis) exposes a powerful event system through its Context API, allowing plugins and core services to communicate via type-safe events. These dispatch modes are defined in packages/core/src/events.ts and determine the execution strategy, error handling, and return value propagation when multiple listeners are registered for the same event.
Overview of Cordis Event Dispatch Modes
At the core of Cordis’s event architecture is the EventsService, which manages listener registration in _hooks and dispatches events through five distinct strategies. Each mode serves specific use cases:
emit: Simple synchronous broadcastingparallel: Concurrent asynchronous executionserial: Sequential execution with early exitbail: First-success-wins patternwaterfall: Data transformation pipeline
All modes are accessible as methods on the Context prototype—ctx.emit(), ctx.parallel(), ctx.serial(), ctx.bail(), and ctx.waterfall()—providing a unified interface for plugin developers.
The Five Dispatch Modes Deep Dive
Emit Mode
emit calls every listener synchronously and ignores all return values. This is the simplest dispatch strategy, ideal for fire-and-forget notifications where the order of execution and results do not matter.
// Simple notification - listeners run synchronously
ctx.emit('user/online', userId)
According to the source code in packages/core/src/events.ts, emit iterates through matching hooks immediately without awaiting results or collecting return values.
Parallel Mode
parallel executes all listeners concurrently using Promise.allSettled() and awaits their completion. If any listener throws, the errors are collected and re-thrown as an AggregateError. This mode is optimal for I/O-bound operations like broadcasting messages to multiple subsystems.
// Concurrent execution - all cache clearers run at once
await ctx.parallel('cache/clear')
The implementation in packages/core/src/events.ts handles error aggregation, ensuring that one failing listener does not prevent others from completing while still surfacing all errors to the caller.
Serial Mode
serial calls listeners one after another and stops as soon as a listener returns a non-bailing value (anything other than null, false, or undefined). The returned value is propagated to the caller, making this ideal for sequential permission checks or validation pipelines.
// Sequential pipeline with early exit
const authResult = await ctx.serial('auth/check', credentials)
if (authResult) console.log('Authenticated!')
As implemented in packages/core/src/events.ts, this mode uses a standard loop that breaks on the first truthy return, preventing unnecessary execution of downstream listeners.
Bail Mode
bail is similar to serial but strictly optimized for lookup scenarios. It stops at the first non-bailing return value and returns it directly to the caller. Use this for fast-path lookups where the first successful match should win, such as command alias resolution.
// First match wins
const command = ctx.bail('command/find', name)
if (command) command.execute(args)
Unlike serial, bail focuses on returning the actual value rather than just checking for existence, making it semantically clearer for resolver patterns.
Waterfall Mode
waterfall passes the result of each listener as the first argument to the next listener in the chain. If no listener returns a value (i.e., all return bailing values), a fallback function is invoked. This creates data-transformation pipelines where each step refines the input, such as configuration merging.
// Data transformation pipeline
const finalConfig = ctx.waterfall(
'config/merge',
baseConfig,
(merged) => console.log('Merged config:', merged) // fallback
)
The waterfall implementation chains listeners by passing the previous return value as the first parameter to the next hook, enabling functional composition patterns.
Implementation Architecture
The event system relies on three key mechanisms defined in packages/core/src/events.ts:
-
Resolution (
_resolve): Extracts the optionalthisArg, resolves the event name, and gathers matching hooks while respecting theglobalflag and anyContext.filter. It also emits an internaldispatchevent for diagnostics. -
Hook Registration:
EventsService.on()stores listeners in_hooks, wrapping them withctx.reflect.bind()to correctly inject the currentContextinstance. -
Lifecycle Integration: As defined in
packages/core/src/fiber.ts, listeners are automatically disposed when their owning fiber ends, preventing memory leaks in plugin lifecycles.
The type safety is enforced through the Events interface, which provides compile-time checking for event names and argument types across all dispatch modes.
Summary
- emit: Synchronous broadcast, no return value handling
- parallel: Concurrent async execution with
AggregateErrorcollection - serial: Sequential execution stopping at first non-bailing return
- bail: First non-bailing return wins (lookup pattern)
- waterfall: Result chaining with fallback support
All modes are exposed via Context methods in packages/core/src/events.ts and support automatic cleanup through Cordis’s fiber system (packages/core/src/fiber.ts).
Frequently Asked Questions
What is the difference between serial and bail modes in Cordis?
Both serial and bail stop execution when a listener returns a non-bailing value, but bail is specifically designed for lookup patterns where you want the first successful result returned immediately. serial is better suited for validation pipelines where you check if any step succeeds (like authentication), while bail fits resolver patterns (like finding a command handler) where you need the actual returned value.
How does parallel mode handle listener errors?
When using ctx.parallel(), Cordis executes all listeners concurrently using Promise.allSettled(). If one or more listeners throw, the implementation collects all errors and re-throws them as a single AggregateError. This ensures that non-failing listeners complete their work while still surfacing all failures to the caller for proper error handling.
When should I use waterfall instead of serial?
Use waterfall when you need to transform data through a chain of listeners, where each step receives the output of the previous step as its first argument. Use serial when you want independent checks that might return early, but do not need to pass transformed data between listeners. waterfall is ideal for configuration merging or request preprocessing pipelines.
How are event listeners automatically cleaned up in Cordis?
Listeners registered via EventsService.on() are wrapped using ctx.reflect.bind() and integrated with the fiber lifecycle system defined in packages/core/src/fiber.ts. When the Context's fiber is disposed, the effect system automatically removes all associated hooks from _hooks, preventing memory leaks without manual unsubscribe calls.
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 →