Cordis
Cordis is an AOP framework for modern JavaScript applications. You can think of it as a kind of meta-framework as developers can build their own frameworks on top of it.
import { Context } from 'cordis'
const ctx = new Context()
ctx.plugin(plugin)
ctx.on(event, callback)
ctx.start()
Contents
Guide ↑
Creating a cordis application is very simple:
import { Context } from 'cordis'
const ctx = new Context()
Almost every feature of cordis is based on contexts. We will see how to use them in the following sections.
Events ↑
Cordis has a built-in event model with lifecycle management.
Listen to events ↑
To add an event listener, simply use ctx.on()
, which is similar to the EventEmitter
that comes with Node.js: the first parameter indicates the name of the event and the second parameter is the callback function. We also support similar methods ctx.once()
, which is used to listen to events only once, and ctx.off()
, which is used to cancel as event listeners.
ctx.on('some-event', callback)
ctx.once('some-event', callback)
ctx.off('some-event', callback)
One difference between cordis Context
and Node.js EventEmitter
is that both ctx.on()
and ctx.once()
returns a dispose function, which can be called to cancel the event listener. So you do not actually have to use ctx.once()
and ctx.off()
. Here is an example of add a listener that will only be called once:
const dispose = ctx.on('some-event', (...args) => {
dispose()
})
Trigger events ↑
In cordis, triggering an event can take many forms. Currently, we support four methods with some differences between them:
- emit: calling all listeners at the same time
- parallel: the asynchronous version of
emit
- bail: calling all listeners in the order they were registered; when a value other than
false
, null
or undefined
is returned, the value is returned and subsequent listeners will not be called - serial: the synchronous version of
bail
The usage of these methods is also similar to EventEmitter
. The first parameter is the event name, and the following parameters are passed to the listeners. Below is an example:
ctx.emit('some-event', arg1, arg2, ...rest)
ctx.on('some-event', (arg1, arg2, ...rest) => {})
Events with this
argument ↑
A custom this
argument can be passed to the listeners:
ctx.emit(thisArg, 'some-event', arg1, arg2, ...rest)
ctx.on('some-event', function (arg1, arg2, ...rest) {
})
An optional symbol Context.filter
on this
argument can be used to filter listeners:
thisArg[Context.filter] = (ctx) => {
}
Application lifecycle ↑
There are some special events related to the application lifecycle. You can listen to them as if they were normal events, but they are not triggered by ctx.emit()
.
ready
: triggered when the application startsdispose
: triggered when the context is unloadedfork
: triggered every time when the plugin is loaded
The ready
event is triggered when the application starts. If a ready
listener is registered in an application that has already started, it will be called immediately. Below is an example:
ctx.on('ready', async () => {
await someAsyncWork()
console.log(1)
})
console.log(2)
await ctx.start()
ctx.on('ready', () => {
console.log(3)
})
It is recommended to wrap code in the ready
event in the following scenarios:
- contains asynchronous operations (for example IO-intensive tasks)
- should be called after other plugins are ready (for example performance checks)
We will talk about dispose
and fork
events in the next section.
Plugin ↑
A plugin is in one of three basic forms:
- a function that accepts two parameters, of which the first is the plugin context, and the second is the provided options
- a class that accepts above parameters
- an object with an
apply
method in the form of the above function
When a plugin is loaded, it is basically equivalent to calling the above function or class. Therefore, the following four ways of adding an event listener is basically equivalent:
ctx.on(event, callback)
ctx.plugin(ctx => ctx.on(event, callback))
ctx.plugin({
apply: ctx => ctx.on(event, callback),
})
ctx.plugin(class {
constructor(ctx) {
ctx.on(event, callback)
}
})
It seems that this just changes the way of writing the direct call, but plugins can help us organize complicated logics while managing the options, which can greatly improve code maintainability.
Plugin as a module ↑
It is recommended to write plugins as modules, specifically, as default exports or namespace exports.
export default class Foo {
constructor(ctx) {}
}
export const name = 'bar'
export function apply(ctx) {}
import Foo from './foo'
import * as Bar from './bar'
ctx.plugin(Foo)
ctx.plugin(Bar)
Unload a plugin ↑
ctx.plugin()
returns a ForkScope
instance. To unload a plugin, we can use the dispose()
method of it:
const fork = ctx.plugin((ctx) => {
ctx.on(event1, callback1)
ctx.on(event2, callback2)
ctx.on(event3, callback3)
})
fork.dispose()
Some plugins can be loaded multiple times. To unload every fork of a plugin without access to the ForkScope
instance, we can use ctx.registry
:
ctx.registry.delete(plugin)
Clear side effects ↑
The dispose
event is triggered when the context is unloaded. It can be used to clean up plugins' side effects.
Most of the built-in methods of Context
are already implemented to be disposable (including ctx.on()
and ctx.plugin()
), so you do not need to handle these side effects manually. However, if some side effects are introduced by other means, a dispose
listener is necessary.
Below is an example:
export function apply(ctx) {
const server = createServer()
ctx.on('ready', () => {
server.listen(80)
})
ctx.on('dispose', () => {
server.close()
})
}
In this example, without the dispose
event, the port 80
will still be occupied after the plugin is unloaded. If the plugin is loaded a second time, the server will fail to start.
Reusable plugins ↑
By default, a plugin is loaded only once. If we want to create a reusable plugin, we can use the fork
event:
function callback(ctx, config) {
console.log('outer', config.value)
ctx.on('fork', (ctx, config) => {
console.log('inner', config.value)
})
}
ctx.plugin(callback, { value: 'foo' })
ctx.plugin(callback, { value: 'bar' })
Note that the fork
listener itself is a plugin function. You can also listen to dispose
event inside fork
listeners, which serves a different purpose: the inner dispose
listener is called when the fork is unloaded, while the outer dispose
listener is called when the whole plugin is unloaded (either via ctx.registry.delete()
or when unloaded all forks).
function callback(ctx) {
ctx.on('dispose', () => {
console.log('outer dispose')
})
ctx.on('fork', (ctx) => {
ctx.on('dispose', () => {
console.log('inner dispose')
})
})
}
const fork1 = ctx.plugin(callback)
const fork2 = ctx.plugin(callback)
fork1.dispose()
fork2.dispose()
Also, you should never use methods from the outer ctx
parameter because they are not bound to the fork and cannot be cleaned up when the fork is disposed. Instead, simply use the ctx
parameter of the fork
listener.
Finally, cordis provides a syntactic sugar for fully reusable plugins (i.e. plugins which only have fork listeners):
export const reusable = true
export function apply(ctx) {
}
export function apply(ctx) {
ctx.on('fork', (ctx) => {
})
}
For class plugins, simply use the static property:
export default class MyPlugin {
static reusable = true
constructor(ctx) {
}
}
Service ↑
A service is an object that can be accessed by multiple contexts. Most of the contexts' functionalities come from services.
For ones who are familiar with IoC / DI, services provide an IoC (inversion of control), but is not implemented through DI (dependency injection). Cordis provides easy access to services within the context through TypeScript's unique mechanism of declaration merging.
Built-in services ↑
Cordis has three built-in services:
ctx.events
: event model and lifecyclectx.registry
: plugin managementctx.root
: the root context
You can access these services from any contexts.
Use services ↑
Some plugins may depend on certain services. For example, supposing we have a service called database
, and we want to use it in a plugin:
export function apply(ctx) {
ctx.database.get(table, id)
}
Trying to load this plugin is likely to result in an error because ctx.database
may be undefined
when the plugin is loaded. The way to fix this problem depends on when and how the service is used.
If the service is only optional needed when the application is running (e.g. referenced in some event listener), we can simply check the availability of the service before using it:
export function apply(ctx) {
ctx.on('custom-event', () => {
if (!ctx.database) return
ctx.database.get(table, id)
})
}
However, If a plugin completely depends on the service, we cannot just check the service in the plugin callback, because when the plugin is loaded, the service may not be available yet. To make sure that the plugin is loaded only when the service is available, we can use a special property called inject
:
export const inject = ['database']
export function apply(ctx) {
ctx.database.get(table, id)
}
export default class MyPlugin {
static inject = ['database']
constructor(ctx) {
ctx.database.get(table, id)
}
}
inject
is a list of service dependencies. If a service is a dependency of a plugin, it means:
- the plugin will not be loaded until the service becomes truthy
- the plugin will be unloaded as soon as the service changes
- if the changed value is still truthy, the plugin will be reloaded
For plugins whose functions depend on a service, we also provide a syntactic sugar ctx.inject()
:
ctx.inject(['database'], (ctx) => {
ctx.database.get(table, id)
})
ctx.plugin({
inject: ['database'],
apply: (ctx) => {
ctx.database.get(table, id)
},
})
Similar to fork callbacks, always use the ctx
parameter of the callback instead of the outer ctx
for disposability.
Write services ↑
Custom services can be loaded as plugins. To create a service plugin, simply derive a class from Service
:
import { Service } from 'cordis'
class CustomService extends Service {
constructor(ctx) {
super(ctx, 'custom', true)
}
method() {
}
}
The second parameter of the constructor is the service name. After loading the service plugin, we can access the custom service through ctx.custom
:
ctx.plugin(CustomService)
ctx.custom.method()
The third parameter of the constructor is a boolean value of whether the service is immediately available. If it is false
(by default), the service will only be available after the application is started.
There are also some abstract methods for lifecycle events:
class CustomService extends Service {
constructor(ctx) {
super(ctx, 'custom', true)
}
start() {}
stop() {}
fork() {}
}
Write disposable methods ↑
It is good practice to write disposable methods for services so that plugins can use them without worrying about the cleanup of resources. Take a simple list service as an example:
class ListService extends Service {
constructor(ctx) {
super(ctx, 'list', true)
this.data = []
}
addItem(item) {
this.data.push(item)
return this.ctx.collect('list-item', () => {
return this.removeItem(item)
})
}
removeItem(item) {
const index = this.data.indexOf(item)
if (index >= 0) {
this.data.splice(index, 1)
return true
} else {
return false
}
}
}
ListService
provides two methods: addItem
and removeItem
.
- The
addItem
method adds an item to the list and returns a dispose function which can be used to remove the item from the list. When the caller context is disposed, the disposable function will be automatically called. - The
removeItem
method removes an item from the list and returns a boolean value indicating whether the item is successfully removed.
In the above example, addItem
is implemented as disposable via this.ctx.collect()
. caller
is a special property which always points to the last context which access the service. ctx.collect()
accepts two parameters: the first is the name of disposable, the second is the callback function.
Service isolation ↑
Note: this is an experimental API and may be changed in the future.
By default, a service is available in all contexts. Below is an example:
ctx.custom
const fork = ctx.plugin(CustomService)
ctx.custom
fork.dispose()
ctx.custom
Registering multiple services will only override themselves. In order to limit the scope of a service (so that multiple services may exist at the same time), simply create an isolated scope:
const ctx1 = ctx.isolate('foo')
const ctx2 = ctx.isolate('bar')
ctx.foo = { value: 1 }
ctx1.foo
ctx2.foo
ctx1.bar = { value: 2 }
ctx.bar
ctx2.bar
ctx.isolate()
accepts a parameter key
and returns a new context. Service named key
will be isolated in the new context, while other services are still shared with the parent context.
Note: there is an edge case when using service isolation, service dependencies and fork
events at the same time. Forks from a partially reusable plugin are not responsive to isolated service changes, because it may cause unexpected reloading across forks. If you want to write reusable plugin with service dependencies, just use reusable
property instead of listening to fork
event.
Context ↑
Context provides API for framework developers rather than users. You can create your own framework based on cordis with context API.
Services and mixins ↑
Context.service()
is a static method that registers a service. If you write your service as a derived class, you do not need to call this method because cordis will automatically register the service.
This method is useful for framework developers who may want to provide built-in services or just declare abstract services which may not be implemented by plugins.
Context.service('database')
function apply(ctx) {
ctx.database.get(table, id)
}
Context.mixin()
is a static method that allows you to delegate properties and methods to the context.
Note: please don't abuse this feature, as adding a lot of mixins can lead to name conflicts.
Context.mixin('state', {
methods: ['collect', 'accept', 'update'],
})
Mixins from services will still support service features such as disposable and isolation.
API
Context
ctx.extend(meta)
- meta:
Partial<Context.Meta>
additional properties - returns:
Context
Create a new context with the current context as the prototype. Properties specified in meta
will be assigned to the new context.
ctx.isolate(key)
Note: this is an experimental API and may be changed in the future.
- key:
string
service name - returns:
Context
Create a new context with the current context as the prototype. Service named key
will be isolated in the new context, while other services are still shared with the parent context.
See: Service isolation
Events
ctx.events
is a built-in service of event model and lifecycle. Most of its methods are also directly accessible in the context.
ctx.emit(thisArg?, event, ...param)
- thisArg:
object
binding object - event:
string
event name - param:
any[]
event parameters - returns:
void
Trigger the event called event
, calling all associated listeners synchronously at the same time, passing the supplied arguments to each. If the first argument is an object, it will be used as this
when executing each listener.
ctx.parallel(thisArg?, event, ...param)
- thisArg:
object
binding object - event:
string
event name - param:
any[]
event parameters - returns:
Promise<void>
Trigger the event called event
, calling all associated listeners asynchronously at the same time, passing the supplied arguments to each. If the first argument is an object, it will be used as this
when executing each listener.
ctx.bail(thisArg?, event, ...param)
- thisArg:
object
binding object - event:
string
event name - param:
any[]
event parameters - returns:
any
Trigger the event called event
, calling all associated listeners synchronously in the order they were registered, passing the supplied arguments to each. If the first argument is an object, it will be used as this
when executing each listener.
If any listener returns a value other than false
, null
or undefined
, that value is returned. If all listeners return false
, null
or undefined
, an undefined
is returned. In either case, subsequent listeners will not be called.
ctx.serial(thisArg?, event, ...param)
- thisArg:
object
binding object - event:
string
event name - param:
any[]
event parameters - returns:
Promise<any>
Trigger the event called event
, calling all associated listeners asynchronously in the order they were registered, passing the supplied arguments to each. If the first argument is an object, it will be used as this
when executing each listener.
If any listener is fulfilled with a value other than false
, null
or undefined
, the returned promise is fulfilled with that value. If all listeners are fulfilled with false
, null
or undefined
, the returned promise is fulfilled with undefined
. In either case, subsequent listeners will not be called.
ctx.on()
ctx.once()
ctx.off()
ctx.events.start()
ctx.events.stop()
ctx.events.register()
ctx.events.unregister()
Registry
ctx.registry
is a built-in service of plugin management. It is actually a subclass of Map<Plugin, MainScope>
, so you can access plugin runtime via methods like ctx.registry.get()
and ctx.registry.delete()
.
ctx.plugin(plugin, config?)
- plugin:
object
the plugin to apply - config:
object
config for the plugin - returns:
ForkScope
Apply a plugin.
ctx.inject(deps, callback)
- deps:
string[] | Inject
dependencies - callback:
Function
plugin function
A syntax sugar of below code:
ctx.plugin({
inject: deps,
plugin: callback,
})
See: Use services
EffectScope
EffectScope
can be accessed via ctx.scope
or passed-in in some events.
scope.uid
An auto-incrementing unique identifier for the effect scope.
scope.runtime
The plugin runtime associated with the effect scope. If the scope is a runtime, then this property refers to itself.
scope.parent
scope.context
scope.config
scope.collect()
scope.restart()
scope.update()
scope.dispose()
ForkScope
MainScope
MainScope is a subclass of EffectScope
, representing the main scope of a plugin.
It can be accessed via ctx.scope.main
or passed-in in some events.
runtime.name
runtime.plugin
runtime.children
runtime.isForkable
Events
ready()
The ready
event is triggered when the application starts. If a ready
listener is registered in an application that has already started, it will be called immediately.
See: Application lifecycle
dispose()
The dispose
event is triggered when the context is unloaded. It can be used to clean up plugins' side effects.
See: Clear side effects
fork(ctx, config)
The fork
event is triggered when the plugin is loaded. It is used to create reusable plugins.
See: Reusable plugins
internal/warning(...param)
internal/hook(name, listener, prepend)
- name:
string
- listener:
Function
- prepend:
boolean
- returns:
() => boolean
internal/service(name)
- name:
string
- oldValue:
any
internal/runtime(runtime)
internal/fork(fork)
internal/update(fork, config)
- fork:
ForkScope
- config:
any