How the Cordis Plugin Registry Handles Registration, Tracking, and Disposal
The Cordis plugin registry uses a centralized RegistryService class that maintains a private Map of plugin runtimes, assigns unique incremental identifiers, tracks active fibers per plugin, and automatically cleans up runtime entries when the last fiber is disposed.
Cordis is a lightweight, modular plugin system for TypeScript applications. At its heart lies the plugin registry, which manages the complete lifecycle of every plugin—from initial registration through active tracking to final disposal. This article examines how RegistryService in packages/core/src/registry.ts implements this orchestration.
Registration: Creating Plugin Runtimes
When you register a plugin via ctx.registry.plugin(...), the registry performs two core operations: it ensures a runtime record exists for that plugin and instantiates a new fiber to run it.
Runtime Record Creation
The registry stores plugin state in a private _internal map typed as Map<Function, Plugin.Runtime>:
// packages/core/src/registry.ts
export class RegistryService {
private _internal = new Map<Function, Plugin.Runtime>()
plugin(callback: Plugin, config?: any) {
let runtime = this._internal.get(callback)
if (!runtime) {
runtime = this.createRuntime(callback)
this._internal.set(callback, runtime)
}
// ... fiber creation
}
}
Each runtime receives a unique identifier through the counter getter, which increments a private _counter field on every access. This provides deterministic ordering for plugin operations.
Fiber Instantiation
Every plugin registration creates a Fiber object (defined in packages/core/src/fiber.ts). The fiber represents the actual execution context of that plugin instance:
// Inside RegistryService.plugin()
const fiber = new Fiber(this.ctx, runtime, config)
await fiber.init()
The fiber immediately registers itself with its runtime:
// packages/core/src/fiber.ts
export class Fiber {
constructor(...) {
const remove = runtime.fibers.push(this)
this.disposers.push(remove)
}
}
Here, runtime.fibers is a DisposableList<Fiber>—a specialized collection that returns a disposal function (remove) when items are pushed.
Tracking: Monitoring Active Plugins
The registry exposes a complete inspection API that operates on the internal _internal map. This allows applications to query plugin state at any time.
Registry Inspection Methods
| Method | Purpose |
|---|---|
keys() |
Iterate over registered plugin constructors |
values() |
Iterate over runtime records |
entries() |
Iterate over [plugin, runtime] pairs |
forEach(fn) |
Execute callback for each entry |
size |
Total number of active plugin runtimes |
has(plugin) |
Check if a plugin has an active runtime |
get(plugin) |
Retrieve the runtime for a specific plugin |
These methods enable runtime introspection:
// Check registration status
console.log('Active plugins:', ctx.registry.size)
console.log('Has myPlugin?', ctx.registry.has(myPlugin))
// Iterate over all runtimes
ctx.registry.forEach((runtime, plugin) => {
console.log(plugin.name, 'has', runtime.fibers.length, 'active fibers')
})
Per-Plugin Fiber Tracking
Each runtime maintains its own fibers list, enabling fine-grained tracking of plugin instances:
// Runtime structure (simplified)
interface Plugin.Runtime {
callback: Plugin
fibers: DisposableList<Fiber>
// ... other metadata
}
This design supports multiple concurrent instances of the same plugin—each gets its own fiber, all tracked under a single runtime entry.
Disposal: Cleaning Up Plugin Resources
The disposal system operates bidirectionally: fibers can dispose themselves (triggering registry cleanup), or the registry can forcibly dispose all fibers for a plugin.
Automatic Fiber-Triggered Cleanup
When a fiber's dispose() method is called, it executes a coordinated cleanup sequence:
// packages/core/src/fiber.ts
async dispose() {
// 1. Execute all registered disposers (effects, listeners, etc.)
await Promise.all(this.disposers.map(d => d()))
// 2. Remove self from runtime's fiber list
if (this.ctx.registry.has(runtime.callback)) {
remove()
// 3. If no fibers remain, delete the runtime from registry
if (!runtime.fibers.length) {
this.ctx.registry.delete(runtime.callback)
}
}
}
This reference-counting approach ensures that plugin runtimes persist only while active fibers exist.
Registry-Forced Disposal via delete()
Applications can also trigger disposal externally through RegistryService.delete():
// packages/core/src/registry.ts
delete(plugin: Plugin): boolean {
const runtime = this.resolve(plugin)
if (!runtime) return false
// Dispose every fiber synchronously
for (const fiber of runtime.fibers) {
fiber.dispose()
}
// Remove runtime from internal map
return this._internal.delete(runtime.callback)
}
Key characteristics of this method:
- Eager execution: All fibers are disposed immediately via synchronous iteration
- Complete cleanup: The runtime entry is removed from
_internalregardless of fiber disposal success - Boolean return: Indicates whether a runtime was actually found and removed
Complete Lifecycle Example
The following example demonstrates the full registration-to-disposal flow:
import { Context } from '@cordis/core'
const ctx = new Context()
// Step 1: Register a plugin
const myPlugin = (ctx: Context, config: { greeting: string }) => {
ctx.on('ready', () => {
console.log(config.greeting)
})
return () => console.log('Plugin disposed')
}
await ctx.registry.plugin(myPlugin, { greeting: 'Hello, Cordis!' })
// Runtime created, fiber instantiated and initialized
// Step 2: Inspect registry state
console.log(ctx.registry.size) // → 1
console.log(ctx.registry.get(myPlugin)) // → Plugin.Runtime { fibers: [Fiber] }
// Step 3: Register same plugin again (creates second fiber)
await ctx.registry.plugin(myPlugin, { greeting: 'Second instance' })
console.log(ctx.registry.get(myPlugin)!.fibers.length) // → 2
// Step 4: Dispose one fiber manually
const runtime = ctx.registry.get(myPlugin)!
const firstFiber = runtime.fibers[0]
await firstFiber.dispose()
console.log(runtime.fibers.length) // → 1 (runtime still exists)
// Step 5: Dispose remaining fiber (triggers automatic runtime deletion)
await runtime.fibers[0].dispose()
console.log(ctx.registry.has(myPlugin)) // → false
console.log(ctx.registry.size) // → 0
// Alternative: Force disposal of all fibers at once
await ctx.registry.plugin(myPlugin, { greeting: 'Temporary' })
ctx.registry.delete(myPlugin) // Disposes fiber, removes runtime
Implementation Files
| File | Responsibility |
|---|---|
packages/core/src/registry.ts |
RegistryService class: _internal map, plugin(), delete(), resolve(), inspection API |
packages/core/src/fiber.ts |
Fiber class: instantiation, dispose(), integration with runtime fiber lists |
packages/core/src/context.ts |
Binds registry service to context instances |
packages/core/src/reflect.ts |
Mixin definitions exposing registry methods on Context |
Summary
- Registration creates a
Plugin.Runtimeon first use, assigns a unique counter ID, and instantiates aFiberthat self-registers with the runtime - Tracking occurs through the
RegistryServiceinspection API and per-runtimefiberslists that act as reference counters - Disposal supports both fiber-initiated cleanup (automatic runtime deletion when
fibers.lengthreaches zero) and registry-forced disposal viadelete()which iterates and disposes all fibers - The private
_internalmap serves as the single source of truth for plugin lifetimes, with automatic garbage collection of empty runtimes
Frequently Asked Questions
How does Cordis prevent memory leaks from disposed plugins?
The registry implements automatic reference counting through the fibers list. When Fiber.dispose() completes, it removes itself from runtime.fibers; if no fibers remain, this.ctx.registry.delete(runtime.callback) executes immediately. This guarantees that plugin runtimes cannot outlive their active instances, and the private _internal map cannot accumulate orphaned entries.
Can multiple instances of the same plugin run simultaneously?
Yes. Each call to ctx.registry.plugin() creates a new Fiber appended to the existing runtime's fibers list. The runtime persists until all fibers are disposed, enabling patterns like plugin reentrancy or scoped sub-instances with independent configurations.
What happens if I call registry.delete() while fibers are still active?
RegistryService.delete() synchronously iterates runtime.fibers and calls fiber.dispose() on each. This triggers the normal disposal sequence for every active instance, ensuring all resources are released before the runtime entry is removed from _internal. The method returns false if the plugin had no registered runtime.
Where is the registry counter used, and can it overflow?
The _counter field increments via the counter getter each time a runtime requests an ID. According to the Cordis source code, this provides sequential identifiers for debugging and ordering purposes. Given JavaScript's Number.MAX_SAFE_INTEGER (9,007,199,254,740,991), overflow is practically impossible in real-world usage.
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 →