How Cordis Handles Plugin Dependencies and Injection Resolution: A Deep Dive into the Fiber Architecture
Cordis implements a lightweight dependency-injection container built around Fiber execution contexts, where plugins declare required services via the inject field or @Inject() decorator, resolve them through a hierarchical proxy handler that climbs the fiber tree, and automatically refresh when dependencies change.
Cordis is a modular plugin framework maintained by cordiverse that manages service lifecycles through a sophisticated dependency injection system. Understanding how Cordis handles plugin dependencies and injection resolution is essential for building reactive applications that adapt automatically when required services are added, removed, or updated.
Declaring Dependencies with inject and @Inject
Cordis offers two syntaxes for declaring what services a plugin requires: the static inject property and the @Inject() decorator. Both approaches ultimately populate the plugin's injection map, which is processed when the plugin is registered.
In packages/core/src/registry.ts, the Inject class parses these declarations and merges inherited entries into a flat map of service names to optional configuration values.
Using the inject Field
The simplest approach assigns an array or object to the inject property on your plugin definition:
// Array syntax for simple dependencies
export const MyPlugin = {
name: 'my-plugin',
inject: ['logger'],
async apply(ctx) {
ctx.logger.info('Plugin started')
}
}
// Object syntax with configuration
export const OtherPlugin = {
name: 'other',
inject: {
myService: {} // Config object can be supplied here
},
async apply(ctx) {
const service = ctx.myService
}
}
Using the @Inject Decorator
For class-based plugins, the @Inject() decorator offers a method-level or class-level alternative. This decorator adds service names to the plugin's inject map before registration:
import { Context, Inject } from 'cordis'
@Inject('logger', 'database')
class MyPluginClass {
async apply(ctx: Context) {
// Both logger and database are available on ctx
ctx.logger.info('Connected to database')
}
}
Resolution and the Fiber Runtime
When a plugin is loaded via RegistryService.plugin, Cordis must resolve the injection map into a concrete set of services. The Inject.resolve helper walks the plugin's inject description, handles inheritance merging, and produces the final service map.
This map is passed to a newly created Fiber (implemented in packages/core/src/fiber.ts), which stores the injection list and creates a runtime that tracks which services the plugin expects. Each Fiber maintains its own store of provided services while retaining a reference to its parent, forming a hierarchical tree that mirrors the plugin nesting structure.
Providing and Accessing Services
Services enter the container through the ctx.provide() method, implemented in packages/core/src/reflect.ts. When a plugin or core component calls ctx.provide(name, value), the ReflectService registers the implementation in two places: the current Fiber's local store and the shared ReflectService.props registry.
Registering Services with ctx.provide
The internal implementation handles symbol isolation and fiber tracking:
// Simplified from packages/core/src/reflect.ts
export class ReflectService {
provide(name: string, value?: any) {
const key = this.ctx[symbols.isolate][name] ??= Symbol(name)
const impl = { name, value, fiber: this.ctx.fiber }
this.store[key] = impl
this.ctx.fiber.store![name] = impl
// Trigger updates in dependent fibers
this.notify([name])
}
}
Hierarchical Service Resolution
Property accesses on the context are intercepted by a proxy handler (ReflectService.handler). When a plugin accesses ctx.logger, the handler walks the fiber hierarchy:
- First, it checks the current fiber's store for an implementation.
- If not found, it climbs parent fibers until it locates a fiber that provides the requested service.
- If the service is missing or the requesting fiber is inactive, Cordis throws a clear error: "cannot get required service … in inactive context".
This hierarchical resolution allows child plugins to override parent services while maintaining access to shared core utilities.
Automatic Reload on Dependency Changes
Cordis implements reactive dependency management. When ReflectService.provide updates a service, the notify method walks all fibers that inject the changed name. For each dependent fiber, Cordis triggers a refresh that re-executes the plugin with the new service instance.
This mechanism ensures that plugins always consume current service versions without manual lifecycle management.
Module Resolution for Plugin Isolation
Beyond runtime service injection, Cordis handles file-level dependencies through the loader system in packages/loader/src/resolve.ts. For each plugin root, the system generates a tiny resolve.mjs file under .cordis/ that forwards to import.meta.resolve.
The createResolve function returns a resolver scoped to the plugin's directory, ensuring that import specifiers resolve relative to the plugin's own context rather than the global node_modules. This prevents version collisions and allows plugins to import local dependencies safely.
Summary
- Declaration: Plugins specify dependencies via
injectarrays/objects or the@Inject()decorator, parsed inpackages/core/src/registry.ts. - Resolution:
Inject.resolvecreates a flat service map that the Fiber runtime uses to track expectations. - Provision: Services are registered via
ctx.provide()inpackages/core/src/reflect.tsand stored in both the Fiber store andReflectService.props. - Lookup: A proxy handler climbs the fiber hierarchy to resolve service requests, throwing descriptive errors for missing or inactive dependencies.
- Reactivity: The
notifysystem triggers automatic plugin refreshes when provided services change. - Isolation: File-level imports are resolved through
createResolveinpackages/loader/src/resolve.ts, maintaining plugin-scoped module resolution.
Frequently Asked Questions
How does Cordis handle missing or optional dependencies?
If a plugin accesses ctx.serviceName and the service is not found in the fiber hierarchy, Cordis throws a runtime error indicating the service is missing or the context is inactive. Optional dependencies should be checked using ctx.get() rather than direct property access, or handled with try-catch blocks around the plugin initialization.
Can services be overridden by child plugins?
Yes. Because the proxy handler checks the current fiber's store before climbing to parents, a child plugin can call ctx.provide('logger', customLogger) to override the parent's logger implementation for itself and its descendants, without affecting sibling or parent contexts.
What triggers a plugin to reload automatically?
When any plugin calls ctx.provide() with a service name, the internal notify method identifies all fibers that declare that name in their inject map. Cordis then triggers a refresh cycle for those fibers, effectively re-running the dependent plugins with the updated service reference available in their context.
How do I resolve module paths relative to my plugin's location?
Cordis generates a scoped resolver via createResolve in packages/loader/src/resolve.ts. Each plugin receives a resolve function that uses the generated .cordis/resolve.mjs to resolve import specifiers relative to the plugin's root directory, ensuring that import('./local-config.js') refers to the plugin's own files regardless of where the main application is executed.
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 →