Cordis Context Shadows and Extension Patterns: A Deep Dive into Isolated Service Architecture
Cordis implements modularity through a single proxy-based Context object that uses internal symbols to create isolated "shadow" contexts for every plugin while preserving access to shared services.
The cordiverse/cordis framework achieves its plugin isolation and extensibility through a sophisticated context shadowing system. By intercepting property access and method calls through proxies, Cordis ensures that each service receives a shadow context pointing to its original caller, preventing state leakage while maintaining shared utility access. This article examines the implementation details found in the core source code and demonstrates the practical extension patterns enabled by this architecture.
The Foundation: Context and Internal Symbols
The entire shadow mechanism rests on three internal symbols defined in packages/core/src/utils.ts. These symbols control how the proxy layer creates and manages context isolation:
symbols.shadow— Stores a reference to the caller’s original context when a service is instantiated, forming the basis of the shadowing mechanism.symbols.caller— Exposes the real caller to extensions that need to know their invocation source, particularly useful for callable services.symbols.tracker— Holds metadata (associate,property,noShadow) used by proxy machinery to determine shadow creation behavior.
How Context Shadows Work in Cordis
Proxy Interception in Context.ts
The Context class, defined in packages/core/src/context.ts, constructs a proxy using ReflectService.handler that intercepts all property access, method calls, and new-style extensions. The extend() method clones the current context while preserving the original shadow through prototype chain manipulation:
// packages/core/src/context.ts (excerpt)
extend(meta = {}): this {
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
const self = Object.create(getTraceable(this, this))
for (const prop of Reflect.ownKeys(meta)) {
Object.defineProperty(self, prop, Reflect.getOwnPropertyDescriptor(meta, prop)!)
}
if (!shadow) return self
return Object.assign(Object.create(self), { [symbols.shadow]: shadow })
}
The isolate(name, label?) method creates a sub-shadow that isolates a subset of symbols, allowing plugins to register isolated namespaces without contaminating the parent context.
Shadow Method Creation in utils.ts
All property reads traverse createTraceable in packages/core/src/utils.ts. When a property’s value is a function, the system generates a shadow method that swaps the this context:
// packages/core/src/utils.ts (excerpt)
function createShadowMethod(ctx: Context, value: any, outer: any, shadow: {}) {
return new Proxy(value, {
apply: (target, thisArg, args) => {
if (thisArg === outer) thisArg = shadow // ← swap `this` to the shadow
return getTraceable(ctx, Reflect.apply(target, thisArg, args))
},
})
}
When a service’s Tracker marks noShadow: true, the proxy skips shadow creation and exposes the raw caller via symbols.caller instead. This behavior is demonstrated in the Probe test within packages/core/tests/shadow.spec.ts.
Cordis Extension Patterns
Simple Service with Shadow Isolation
The most common pattern extends the base Service class, automatically receiving a shadow context that preserves isolation while granting access to shared utilities:
import { Context, Service } from 'cordis'
export default class Greeter extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter')
}
greet(name: string) {
// `this.ctx` is a shadow pointing to the caller's context
this.ctx.logger.info(`Hello, ${name}!`)
}
}
When loaded via root.plugin(Greeter), the this.ctx inside greet references a shadow of the caller’s context, maintaining isolation while allowing access to the shared logger service.
Callable Services and Caller Exposure
Services can expose themselves as functions by implementing [Service.invoke], allowing them to capture the caller context explicitly:
class Counter extends Service {
private n = 0
protected [Service.invoke]() {
// `symbols.caller` points to the context that invoked the service
return (this as any)[symbols.caller].logger.info(`count = ${++this.n}`)
}
}
Calling await ctx['counter']() triggers the shadow-aware proxy, returning the caller’s context via symbols.caller as shown in the "exposes the caller to callable services" test.
No-Shadow Services for Raw Context Access
For services requiring direct access to the original context without shadow wrapping, set noShadow: true in the tracker metadata:
class Probe {
[Service.tracker] = { property: 'ctx', noShadow: true }
constructor(public ctx: Context) {}
inspect() {
// `symbols.caller` is available, but no shadow is created
return (this as any)[symbols.caller]
}
}
The noShadow flag instructs the proxy to expose the raw caller context directly, bypassing the shadow creation mechanism entirely.
Plugin Loading with Shadow Stripping
When services load additional plugins, Cordis prevents context leakage by stripping the current shadow before invocation. In the Loader implementation, the framework removes the active shadow before calling ctx.plugin(), ensuring that newly loaded plugins receive the root context rather than the loader’s internal shadow. This prevents accidental exposure of loader-specific state to child plugins, as verified by the "strips service shadow before creating plugins" test in shadow.spec.ts.
Key Implementation Files
| File | Role |
|---|---|
packages/core/src/context.ts |
Core Context class, extend(), isolate(), and symbol handling. |
packages/core/src/utils.ts |
Symbol definitions, shadow/tracker logic, proxy factories (createTraceable, createShadowMethod). |
packages/core/src/service.ts |
Base Service class, integration of Tracker, callable transformation via createCallable. |
packages/core/tests/shadow.spec.ts |
Test suite validating shadow behavior, no-shadow services, and loader shadow stripping. |
packages/core/src/reflect.ts |
Provides ReflectService.handler powering the context proxy constructor. |
Summary
- Cordis context shadows are created through a proxy system in
packages/core/src/context.tsthat intercepts property access and method calls. - Three internal symbols—
symbols.shadow,symbols.caller, andsymbols.tracker—manage context isolation and caller exposure. - The
extend()method clones contexts while preserving shadow references through prototype chain manipulation. - Extension patterns range from simple shadow-isolated services to callable services exposing
symbols.caller, and no-shadow services using raw context access. - The
Loaderstrips shadows before creating new plugins to prevent context leakage from parent services to child plugins.
Frequently Asked Questions
How does Cordis prevent plugins from accessing each other's internal state?
Cordis creates a shadow context for every plugin instantiation, stored via symbols.shadow. When a service method is invoked, the proxy layer (implemented in packages/core/src/utils.ts) swaps the this argument to the shadow context through createShadowMethod. This ensures each plugin operates within its isolated context bubble while still accessing shared framework services through the prototype chain.
What is the difference between symbols.shadow and symbols.caller in Cordis?
symbols.shadow holds a reference to the original context at service instantiation time, creating an isolated layer for method execution. symbols.caller exposes the actual context that invoked a callable service, used primarily when a service defines [Service.invoke] and needs to know which context called it as a function. While shadows maintain isolation, symbols.caller provides explicit access to the invocation source.
When should I use noShadow: true in a Cordis service?
Set noShadow: true in the [Service.tracker] metadata when your service requires direct access to the original context without proxy wrapping. This is necessary for services that inspect the raw caller context or operate as thin wrappers around existing objects. According to packages/core/tests/shadow.spec.ts, this pattern is used by diagnostic tools like Probe that need to verify the exact context reference rather than a shadow proxy.
How does the extend() method preserve context shadows?
The extend() method in packages/core/src/context.ts retrieves the existing shadow via Reflect.getOwnPropertyDescriptor(this, symbols.shadow), creates a new object inheriting from the traceable context, and conditionally assigns the shadow to the new instance. If no shadow exists, it returns the bare extended object; otherwise, it creates a new object with symbols.shadow explicitly set to the original shadow reference, ensuring the chain of isolation persists through context extensions.
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 →