Cordis: The Meta-Framework That Treats Plugin Lifetime as a First-Class Dimension
Hook
Most Node.js plugin systems can't hot-reload correctly because they treat state as global and time as an afterthought. Cordis fixes this by making plugin lifetime a spatial dimension you can fork, dispose, and rebuild without losing application state.
Context
If you've built a chatbot framework, modular game server, or any long-running Node.js application with plugins, you know the pain: change one plugin file, restart the entire process, lose all runtime state, wait 30 seconds to reconnect. Hot-reload libraries exist, but they corrupt state—listeners multiply, timers leak, database connections orphan. The problem isn't implementation quality; it's architectural. Traditional dependency injection treats plugins as static trees initialized at boot time. When you swap a plugin, there's no safe way to dispose of only that subtree's effects.
Cordis introduces 'spatiotemporal composability'—a term that sounds academic but solves a concrete problem. Instead of one global container, you get contexts that fork like Git branches. Each context is both a dependency injection scope and a lifecycle manager. When you dispose a context, everything it spawned—timers, event listeners, database clients—cleans up automatically through effect tracking. File changes trigger surgical rebuilds of only affected context subtrees. This means you can modify plugin code and see changes in under a second without restarting your application or losing the WebSocket connections, user sessions, or cached data that took minutes to establish.
Technical Insight
The architecture centers on three primitives that work together: contexts, effects, and services. A context is a proxy-wrapped container that tracks every side-effect created within its scope. When you write ctx.setInterval(callback, 1000), Cordis wraps the native timer, registers it as an effect, and stores a disposal function. When the context dies, all registered effects execute their cleanup in reverse order.
Here's what plugin isolation looks like in practice:
import { Context, Service } from 'cordis'
class DatabaseService extends Service {
constructor(ctx: Context) {
super(ctx, 'database', true) // true = immediate availability
this.client = createClient()
// This cleanup runs when the context disposes
ctx.on('dispose', () => this.client.close())
}
}
// Plugin A creates a child context with its own lifecycle
export function pluginA(ctx: Context) {
ctx.plugin(DatabaseService)
// This timer is tracked as an effect
ctx.setInterval(() => {
ctx.database.query('SELECT 1')
}, 5000)
// Event listeners are also effects
ctx.on('message', (msg) => {
ctx.database.insert({ content: msg })
})
}
// Plugin B forks a sibling context
export function pluginB(ctx: Context) {
// Gets the SAME database instance from parent context
ctx.logger.info(ctx.database.stats())
}
// Root application
const app = new Context()
const forkA = app.plugin(pluginA)
const forkB = app.plugin(pluginB)
// Dispose only Plugin A's context
forkA.dispose() // Clears timer, removes listener, keeps database alive for Plugin B
The magic is in service resolution. When you access ctx.database, Cordis walks up the context tree until it finds a context that provides that service. Child contexts inherit parent services but maintain independent effect lifetimes. This solves the diamond dependency problem elegantly—both pluginA and pluginB depend on DatabaseService, but only one instance exists, and it outlives individual plugins that use it.
The fork/dispose pattern enables the hot-reload workflow. Cordis's loader watches your plugin files and maintains a dependency graph. When pluginA.ts changes, the loader:
- Calls
forkA.dispose()to clean up all effects - Clears the require cache for that module
- Re-imports the new code
- Calls
app.plugin(pluginA)to create a fresh context
The database connection stays alive because it belongs to the parent context. User sessions in other plugins remain untouched. Only the changed plugin's subtree rebuilds.
Effect tracking isn't limited to timers and events. You can register any cleanup function:
export function cachePlugin(ctx: Context) {
const cache = new Map()
// Register custom disposal logic
ctx.on('dispose', () => {
cache.clear()
console.log('Cache cleared')
})
// Alternatively, use the accept helper for conditional updates
ctx.accept(['config'], (config) => {
// This callback runs on hot-reload instead of full disposal
cache.clear()
return initializeCache(config)
})
}
The accept API is borrowed from Vite's HMR system. It lets plugins declare which dependencies trigger updates versus full reloads. If only configuration changes, you might clear caches but keep WebSocket connections alive. This granular control is impossible with process-level restarts.
Service injection happens through TypeScript declaration merging. When you define a service, you augment the Context interface:
declare module 'cordis' {
interface Context {
database: DatabaseService
}
}
Now ctx.database has full type-safety with zero runtime overhead beyond the initial Proxy wrapper. No decorators on every property, no manual container lookups. The tradeoff is that static analysis can't trace these dependencies—your IDE won't show you which plugins use database without runtime introspection.
The spatiotemporal model shines for per-user isolation in chat applications. Instead of one global plugin set, you fork contexts for each user session:
const userContexts = new Map<string, Context>()
app.on('user:join', (userId) => {
const userCtx = app.plugin(() => {}) // Empty plugin creates fork
userCtx.plugin(rateLimiter, { limit: 10 })
userCtx.plugin(permissions, { role: getUserRole(userId) })
userContexts.set(userId, userCtx)
})
app.on('user:leave', (userId) => {
userContexts.get(userId)?.dispose() // Cleanup all user-specific effects
userContexts.delete(userId)
})
Each user gets isolated rate limiters and permission checkers that disappear when they disconnect. No manual bookkeeping, no memory leaks from forgotten cleanup.
Gotcha
The spatiotemporal model's biggest weakness is its learning curve. You must internalize context hierarchies and effect lifecycles, which is alien if you're used to simple initialization order. Debugging is painful when contexts don't dispose correctly—effects linger, services leak, and you're hunting through proxy chains to understand why ctx.database resolves to the wrong instance. The framework provides inspection tools, but they're runtime-only. You can't visualize the context tree statically.
Proxy-based injection breaks dead code elimination. If you never call ctx.database in production but import the service, bundlers can't tree-shake it because the property access happens through a Proxy trap. This is fine for server applications but problematic if you're trying to minimize bundle size for edge deployments. The invisible dependencies also confuse dependency graph tools—tools like Madge or webpack-bundle-analyzer can't trace what depends on what without executing code.
Hot-reload has sharp edges with stateful services. If your database service maintains a connection pool, query caches, or prepared statements, disposing and recreating it breaks active queries. You need manual recreation logic using the accept API, which defeats the automatic lifecycle promise. The documentation suggests marking such services as 'global' so they never dispose, but then you lose hot-reload for configuration changes. There's no perfect answer—stateful services fundamentally conflict with rebuilding context trees.
Inter-context communication is awkward. Sibling contexts can't message each other directly; you must emit events on a shared parent or expose services upward. This creates tight coupling to the context hierarchy structure. Refactoring plugin relationships means rewiring context trees, which is more invasive than changing import statements.
Verdict
Use if: You're building chatbot frameworks, development tools, modular game servers, or any long-running Node.js application where iteration speed matters and plugins have complex interdependencies. The hot-reload correctness and automatic effect cleanup are transformative when you're managing dozens of plugins and need sub-second feedback loops without losing runtime state. Also consider it for multi-tenant SaaS platforms where per-tenant plugin isolation beats process-level separation. Skip if: You're building stateless microservices, simple CLI tools, standard REST APIs, or anything deployed to serverless environments. The conceptual overhead only pays off when manual lifecycle management would be more painful than learning spatiotemporal composition. If your dependencies fit in a flat container and you restart on every deploy, InversifyJS or Awilix gives you 80% of the benefits with 20% of the complexity.