cordis2
v1.0.1
Published
Isolated cordis for koishi-plugin-keine
Downloads
164
Readme
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) // use plugins
ctx.on(event, callback) // listen to events
ctx.start() // start app
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()
// do something
})
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
orundefined
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)
// corresponds to
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)
// corresponds to
ctx.on('some-event', function (arg1, arg2, ...rest) {
// `this` will point to `thisArg`
})
An optional symbol Context.filter
on this
argument can be used to filter listeners:
thisArg[Context.filter] = (ctx) => {
// return truthy to call the listener, falsy to skip the listener
// if not specified, all listeners will be called
}
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)
// start the application
// trigger the `ready` event
await ctx.start()
ctx.on('ready', () => {
console.log(3)
})
// output: 2 1 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.
// foo.ts (default export)
export default class Foo {
constructor(ctx) {}
}
// bar.ts (namespace export)
// it is also recommended to export a `name` in this case
export const name = 'bar'
export function apply(ctx) {}
// index.ts
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:
// load a plugin
const fork = ctx.plugin((ctx) => {
ctx.on(event1, callback1)
ctx.on(event2, callback2)
ctx.on(event3, callback3)
})
// unload the plugin, removing all listeners
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
:
// remove all forks of the plugin
// return true if the plugin is active
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:
// an example server plugin
export function apply(ctx) {
const server = createServer()
ctx.on('ready', () => {
// start the server
server.listen(80)
})
ctx.on('dispose', () => {
// clean up the side effect
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:
// an example reusable plugin
function callback(ctx, config) {
console.log('outer', config.value)
ctx.on('fork', (ctx, config) => {
console.log('inner', config.value)
})
}
// outer foo
// inner foo
ctx.plugin(callback, { value: 'foo' })
// inner bar
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).
// an example reusable plugin
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)
// inner dispose
fork1.dispose()
// inner dispose
// outer 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):
// mark the callback as fork listener
export const reusable = true
export function apply(ctx) {
// do something
}
// equivalent to
export function apply(ctx) {
ctx.on('fork', (ctx) => {
// do something
})
}
For class plugins, simply use the static property:
export default class MyPlugin {
static reusable = true
constructor(ctx) {
// do something
}
}
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) {
// fetch data from the database
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', () => {
// check if the service is available
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 using
:
export const using = ['database']
export function apply(ctx) {
// fetch data from the database
ctx.database.get(table, id)
}
// for class plugins, simply use static property
export default class MyPlugin {
static using = ['database']
constructor(ctx) {
// fetch data from the database
ctx.database.get(table, id)
}
}
using
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.using()
:
ctx.using(['database'], (ctx) => {
ctx.database.get(table, id)
})
// equivalent to
ctx.plugin({
using: ['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() {
// do something
}
}
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)
}
// `ready` listener
start() {}
// `dispose` listener
stop() {}
// `fork` listener
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 a dispose function
return this.caller.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.caller.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 // undefined
// register the service with a plugin
const fork = ctx.plugin(CustomService)
ctx.custom // CustomService
// unload the service plugin
fork.dispose()
ctx.custom // undefined
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 // undefined
ctx2.foo // { value: 1 }
ctx1.bar = { value: 2 }
ctx.bar // { value: 2 }
ctx2.bar // undefined
ctx.isolate()
accepts a parameter keys
and returns a new context. Services included in keys
will be isolated in the new context, while services not included in keys
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 usereusable
property instead of listening tofork
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.
// declare an abstract service
Context.service('database')
function apply(ctx) {
// use the database service
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', {
// delegate `ctx.scope.collect()` to `ctx.collect()`
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(keys)
Note: this is an experimental API and may be changed in the future.
- keys:
string[]
service names - returns:
Context
Create a new context with the current context as the prototype. Services included in keys
will be isolated in the new context, while services not included in keys
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.using(names, callback)
- names:
string[]
service names - callback:
Function
plugin function
A syntax sugar of below code:
ctx.plugin({
using: names,
plugin: callback,
})
See: Use services
EffectScope
EffectScope
can be accessed via ctx.scope
or passed-in in some events.
scope.uid
- type:
number
An auto-incrementing unique identifier for the effect scope.
scope.runtime
- type:
MainScope
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
- type:
ForkScope[]
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.
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)
- ctx:
Context
- config:
any
The fork
event is triggered when the plugin is loaded. It is used to create reusable plugins.
See: Reusable plugins
internal/warning(...param)
- param:
any[]
internal/hook(name, listener, prepend)
- name:
string
- listener:
Function
- prepend:
boolean
- returns:
() => boolean
internal/service(name)
- name:
string
- oldValue:
any
internal/runtime(runtime)
- runtime:
MainScope
internal/fork(fork)
- fork:
ForkScope
internal/update(fork, config)
- fork:
ForkScope
- config:
any