> your AI agent picks dependencies from memory; give it dated facts — try starlog.dev ↗ vet your agent's deps ↗ vibe-coding is fine. vibe-importing isn’t. — try starlog.dev ↗ vibe-importing isn’t fine ↗ your agent has never seen your private packages — try starlog.dev ↗ facts for private packages ↗ a linter for the dependencies your AI agent picks — try starlog.dev ↗ a linter for agent deps ↗ whois is redacted, cdns mask the rest — get the real operator — try whoisgeni.us ↗ who really runs that domain ↗ domain attribution that shows its work — full evidence chain — try whoisgeni.us ↗ domain intel w/ evidence ↗

← Back to Articles

Cordis: The Meta-Framework That Treats Plugin Lifetime as a First-Class Dimension

[ View on GitHub ]

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

Lifecycle

Effect Tracking

Service Layer

Context Hierarchy

fork

fork

provides

accesses via proxy

accesses via proxy

registers

registers

dispose signal

dispose signal

hierarchical propagation

hierarchical propagation

rebuilds

tracks files

tracks files

Root Context

Plugin A Context

Plugin B Context

Database Service

Effects: timers, listeners

Effects: timers, listeners

Cleanup A

Cleanup B

Event System

Hot Reload Loader

System architecture — auto-generated

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:

  1. Calls forkA.dispose() to clean up all effects
  2. Clears the require cache for that module
  3. Re-imports the new code
  4. 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.