Inside Cordis: The Lifecycle Tree That Powers DeepSeek's Plugin Architecture
Cordis is a meta-framework for TypeScript / Node.js applications. It doesn't dictate whether you must build a bot, a web service, or a command-line tool; it provides more fundamental capabilities: allowing plugins, services, events, configuration, and resource lifecycles to be safely composed together.
If you only look at how it's used, Cordis is simple:
const ctx = new Context()
await ctx.plugin(plugin)
ctx.emit('event-name')
But its real value lies not in these few API lines, but in its runtime mechanisms: how plugin dependencies wait, how services are resolved by scope, how resources are automatically cleaned up, and why dependent plugins can automatically reload when services change.
To understand Cordis, you can start with four objects: Context, Fiber, Reflect, and Registry.
Context (Application Context)
├─ Registry: manages plugins
├─ Reflect: manages services
├─ Events: manages events
└─ Fiber: manages the lifecycle of a single plugin run
Context: Looks Like an Object, Is Actually a Service Entry Point
The most common variable in Cordis is ctx:
ctx.database
ctx.logger
ctx.on('message', callback)
On the surface, it looks like a plain JavaScript object; but in implementation, Context is wrapped by a Proxy. So when you access ctx.database, the framework doesn't just read an object field — it performs a service resolution.
The general flow is:
Read ctx.database
↓
Does Context itself own this property?
├─ Yes: return directly
└─ No: treat database as a service name
↓
Search in the current plugin Fiber
↓
Search upward through parent Fibers
↓
Return the service found within the corresponding scope
This means plugins don't need to care where a service instance is created, nor do they need to manually import a global singleton from another file. They just use ctx.database, and Cordis finds the correct service in the current context.
This is also the foundation that allows Cordis to support multiple service instances, multiple tenants, or multiple bot instances.
Source code entry: packages/core/src/context.ts establishes the Proxy when creating Context; packages/core/src/reflect.ts handles reading and writing service properties.
Fiber: One Plugin Installation Is One Manageable Runtime Unit
Calling:
const fiber = await ctx.plugin(plugin)
Cordis doesn't simply execute plugin(ctx) and finish. It creates a Fiber.
A Fiber can be understood as "the runtime instance of this plugin." It stores:
- The plugin itself and its configuration
- The parent Context
- Declared dependency services
- The plugin's current state
- All resources created by this plugin
- All services provided by this plugin
Its lifecycle can be simplified to:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
Where:
PENDING: dependencies are not yet ready, temporarily not running;LOADING: dependencies are satisfied, initializing;ACTIVE: plugin is working normally;UNLOADING: dependencies disappeared, configuration updated, or plugin actively unloaded;DISPOSED: all resources have been released.
So the Fiber returned by ctx.plugin() is important:
const fiber = await ctx.plugin(plugin)
await fiber.dispose()
Calling dispose() isn't simply "turning off the plugin function"; it destroys all resources established during the plugin's entire lifecycle.
The code for plugin registration and Fiber creation is located in packages/core/src/registry.ts, and the lifecycle state and execution logic is in packages/core/src/fiber.ts.
inject: Cordis's Dependency Injection Is a Continuously Effective Dependency Relationship
Ordinary dependency injection frameworks often only pass objects to you at initialization time. Cordis goes further: it continuously observes whether dependencies are available.
For example:
const plugin = {
inject: ['database'],
apply(ctx) {
ctx.database.query('SELECT 1')
},
}
Here, inject is not just a type declaration; it tells Cordis: this plugin can only run when the database service is available.
If the database hasn't been installed yet, the plugin won't error and won't execute early; it stays in a waiting state:
database does not yet exist
↓
plugin stays PENDING
When the database service appears:
database is provided
↓
plugin begins LOADING
↓
executes apply(ctx)
↓
plugin enters ACTIVE
If the database later disconnects, is unloaded, or is replaced:
database becomes invalid
↓
plugin that depends on it unloads
↓
cleans up resources it created
↓
waits for a new database, or stays PENDING
In implementation, the Fiber combines the runtime instance IDs corresponding to dependency services into an epoch (version identifier). Whenever the dependency set changes, the epoch changes, and the Fiber will execute unloading or reloading accordingly.
This means Cordis manages not a one-time object injection, but the relationship between "service availability and plugin runtime state."
Service: Exposing Capabilities into the Context
Cordis typically exposes long-lived capabilities through Service:
class Database extends Service {
constructor(ctx: Context) {
super(ctx, 'database')
}
query(sql: string) {
// execute query
}
}
After installation:
await ctx.plugin(Database)
ctx.database.query('SELECT 1')
What super(ctx, 'database') does is register the current instance as the database service. After that, other plugins only need to declare:
inject: ['database']
and then can use it via ctx.database.
Services are bound to Fibers. If the plugin providing the database service is unloaded, ctx.database will also automatically become invalid; plugins that depend on the database will then reload or stop accordingly.
Therefore, Cordis discourages unbounded global variables. Services always have a clear provider, scope, and lifecycle.
effect: Attaching Side Effects to the Lifecycle Tree
Plugins often create resources that must be cleaned up:
- Timers
- WebSockets
- File watchers
- Database connections
- Message subscriptions
- Sub-plugins
For example, a native timer:
const timer = setInterval(task, 1000)
If you forget to execute clearInterval(timer) after the plugin is unloaded, the timer will continue running — this is a resource leak.
Cordis uses ctx.effect() to solve this problem:
ctx.effect(() => {
const timer = setInterval(task, 1000)
return () => {
clearInterval(timer)
}
}, 'periodic task')
The returned function is the cleanup logic.
When the owning plugin is unloaded, Cordis automatically executes it:
Install plugin
↓
Create timer
↓
Register clearInterval as an effect
↓
Unload plugin
↓
Automatically execute clearInterval
ctx.effect() supports synchronous cleanup functions, asynchronous cleanup functions, generators, and async generators. When a Fiber unloads, it releases registered resources in reverse order, ensuring that resources created later are closed first.
Event listeners follow the same mechanism:
ctx.on('message', callback)
Internally, Cordis registers the function that "cancels this listener" to the current Fiber. Therefore, after a plugin is unloaded, the event listeners it registered won't linger.
If a plugin also installs sub-plugins, the resource relationships form a tree:
Parent Plugin
├─ Event listener
├─ Timer
└─ Sub-plugin
└─ WebSocket
When unloading the parent plugin, Cordis releases resources in reverse order, ensuring that dependent resources created later are closed first.
isolate: Why a Service with the Same Name Can Have Multiple Instances
In many applications, the same service name doesn't necessarily correspond to only one instance.
For example, in a multi-tenant system, each tenant might have an independent database:
const tenantA = root.isolate('database')
const tenantB = root.isolate('database')
Then provide services separately:
tenantA.provide('database', databaseA)
tenantB.provide('database', databaseB)
Then:
tenantA.database // databaseA
tenantB.database // databaseB
Although both sides access ctx.database, the underlying service is not the same one.
Cordis assigns different internal identifiers to isolated service names, so when looking up a service, it can determine which instance the current Context should see:
root
├─ tenantA
│ └─ database → databaseA
│
└─ tenantB
└─ database → databaseB
The same plugin can be installed into two contexts:
await tenantA.plugin(plugin)
await tenantB.plugin(plugin)
The plugin code doesn't need to determine which tenant it belongs to; it just depends on ctx.database. This is the compositional power that scoping brings.
Events: Event Listeners Are Also Scoped Resources
Cordis's event system supports:
emit(): synchronous broadcast;parallel(): concurrent execution and waiting;serial(): sequential async execution, getting the first valid result;bail(): synchronous short-circuit;waterfall(): middleware-style call chain.
Listeners are not simply stored in a global array. Each listener records which Context it belongs to; when dispatching events, Cordis decides which listeners can respond based on the current context and isolation rules.
This brings two results:
- After a plugin is unloaded, the listeners it registered automatically disappear;
- Plugins in different scopes won't accidentally handle each other's events.
Therefore, the event system, like the service system, is not globally unbounded.
Why Configuration Updates and Hot Reloading Are Possible
Cordis plugins don't execute once and then lose control. Each plugin has its own Fiber, so when configuration, dependencies, or code changes, the framework can execute a deterministic process:
Old Fiber unloads
↓
Release old events, timers, connections, and sub-plugins
↓
Retain new configuration or dependency state
↓
Re-execute plugin
↓
Generate new resource tree
This is precisely the prerequisite for Loader and HMR to work.
The Loader is responsible for finding and loading plugins based on a configuration file; HMR is responsible for detecting module changes during development and reloading plugins. The reason they rarely produce problems like duplicate listeners or lingering old timers is that Cordis has already gathered side effects into the Fiber lifecycle.
Summary
Cordis's core idea can be summarized in one sentence:
Place a plugin's dependencies, services, events, and side effects into a traceable lifecycle tree.
It achieves this through:
Context + Proxyresolving services in the current scope;Fiberrepresenting a single plugin run;injectmaintaining service dependencies and plugin state;effectautomatically tracking and releasing side effects;isolateallowing services with the same name to exist independently in different contexts;Eventsmanaging scopable, automatically cancellable listeners.
So the essence of Cordis is not "providing a bunch of APIs," but providing a way to organize applications: modules can be installed, wait for dependencies, provide capabilities, create resources, reload, and disappear cleanly on exit.
Suggested Source Code Reading Order
packages/core/src/context.tspackages/core/src/registry.tspackages/core/src/fiber.tspackages/core/src/reflect.tspackages/core/src/events.ts