Code-Extend is a framework for enabling a codebase to integrate plugins, addons, and extensions.
By using a variety of customizable hook topologies, and a comprehensive set of configurable options, your architecture will become highly extensible and loosely coupled.
Code-Extend leverages Typescript
, supercharging your development
with compile-time type checking and code completion.
This type safety includes all hooks, taps, calls, contexts,
tap data, and interceptors.
yarn add code-extend
npm install code-extend
With type safety
import {createSyncHook} from 'code-extend'
const hook = createSyncHook<[string]>()
// Typescript knows that the `tap` callback receives 1 string)
hook.tap(message => { console.log(message) })
// Typescript knows that the `call` method requires 1 string)
hook.call('hello')
Async with type safety
import {createAsyncHook} from 'code-extend'
const hook = createAsyncHook<[message:string]>()
hook.tap(async message => { console.log(message) })
await hook.call('hello')
Without type safety
import {createSyncHook} from 'code-extend'
const hook = createSyncHook()
hook.tap(message => { console.log(message) })
hook.call('hello')
A plugin system requires an event bus for communication between the host and extensions.
The standard Node EventEmitter
has a many limitations:
It is inherently synchronous, it is not typed, and it
disregards the return value of listeners.
Other libraries, such as Tapable
,
are limited in their functionality.
The Code-Extend library solves these issues, while also offering additional features to manage the topology and behavior of the system.
- Typesafe hooks and interceptors
- Custom error handling
- Call filtering
- Synchronous and asynchronous hooks using interchangeable API
- Variety of hook topologies
- Written from the ground up using ES6 and composable functions
- Fully tested
- Lightning fast
- Tiny footprint
- No external dependencies
- Interop with the
Tapable
library
Hooks are created using createSyncHook(...)
and createAsyncHook(...)
const syncHook = createSyncHook()
syncHook.tap(message => console.log('The hook was called:' + message))
syncHook.call('Hello')
const asyncHook = createAsyncHook()
asyncHook.tap(async message => console.log('The hook was called:' + message))
await asyncHook.call('Hello')
No callback parameters
const syncHook = createSyncHook<[]>()
syncHook.tap(() => console.log('The hook was called'))
syncHook.call()
One unnamed callback parameter
const syncHook = createSyncHook<[string]>()
syncHook.tap(message => console.log('The hook was called: ' + message))
syncHook.call('Hello')
Two named callback parameters
const syncHook = createSyncHook<[name:string, age:number]>()
syncHook.tap((name, age) => console.log('The hook was called: ' + message + ' ' + age))
syncHook.call('Woolly', 5)
One named callback parameter with a return type. When specifying return types,
use the function style
(arg1, arg2)=>returnValue
instead of the list style
[arg1, arg2]
.
const syncHook = createSyncHook<(message:string)=>string>({type: 'waterfall'})
syncHook.tap(message => { console.log('The hook was called: ' + message); return message })
const result = syncHook.call('Hello')
Note: Hook and call return types are only applicable to waterfall and bail hooks.
This uses the Tapable style, and not recommended.
const syncHook = createSyncHook(['message'])
syncHook.tap(message => console.log('The hook was called: ' + message))
syncHook.call('Hello')
There are three ways to handle errors that occur within a tap callback.
These errors are configured per-hook using the onError
configuration property.
Strategy | Description |
---|---|
log | Log the error / Ignore the return value / Continue to call the next tap |
ignore | Do not log the error / Ignore the return value / Continue to call the next tap |
throw | Throw an exception / Do not call any more taps |
const syncHook = createSyncHook<[message: string]>({onError: 'log'})
syncHook.tap((message) => {
throw new Error('No way!')
})
syncHook.tap((message) => console.log('The hook was called: ' + message))
syncHook.call('Hello') // Exception WILL be logged and second tap WILL be called
const syncHook = createSyncHook<[message: string]>({onError: 'ignore'})
syncHook.tap((message) => {
throw new Error('No way!')
})
syncHook.tap((message) => console.log('The hook was called: ' + message))
syncHook.call('Hello') // Exception will NOT be logged and second tap WILL be called
const syncHook = createSyncHook<[message: string]>({onError: 'throw'})
syncHook.tap((message) => {
throw new Error('No way!')
})
syncHook.tap((message) => console.log('The hook was called: ' + message))
syncHook.call('Hello') // This WILL throw and second tap will NOT be called
const asyncHook = createAsyncHook<(message: string) => void>()
asyncHook.tap(async message => console.log('Hook1 was called'))
asyncHook.tap(async message => console.log('Hook2 was called'))
// tapPromise is same as hook.tap for async hooks
// We suggest sticking with `tap`
asyncHook.tapPromise(async message => console.log('Hook3 was called'))
// tapAsync is the old-school callback API (error,result):void
// We suggest sticking with `tap`, and only using if you need it
asyncHook.tapAsync((message, callback) => {
console.log('Hook4 was called')
callback(null)
})
await asyncHook.call('Hello') // All taps called in series by default
const asyncHook = createAsyncHook<(string) => string>({
type: 'waterfall',
onError: 'ignore',
reverse: true
})
asyncHook.tap(packet => packet + '1')
asyncHook.tap(packet => {
throw new Error('Should be ignored')
})
asyncHook.tap(packet => packet + '2')
await asyncHook.call('3') // return value is '321'
Hooks can be filtered when called. The caller can inspect tap data that was provided when the hook was tapped.
Tap data is typed using the second parameter (TAPDATA) of createHook.
createSyncHook<PARAMS, TAPDATA, CONTEXT>()
test('Call filtering', () => {
const syncHook = createSyncHook<[string | number], string>()
syncHook.tap({data: 'string'}, (message) => console.log('withString', message))
syncHook.tap({data: 'number'}, (message) => console.log('withNumber', message))
syncHook.call2({
filter: (data => data === 'string'),
args: ['Hello']
})
syncHook.call2({
filter: (data => data === 'number'),
args: [12]
})
})
The default hook.call
method will create an empty object
as the context and provide it to the first hook.
Use the hook.call2
method to provide your own context.
Context is typed using the third parameter (CONTEXT) of createHook.
createSyncHook<PARAMS, TAPDATA, CONTEXT>()
A tap opts-in to receiving context as the first callback parameter by
setting the context
property in the tap options.
call2
may be used to explicitly set the context object.
When using call
, the object is initialed as an empty object.
import {createSyncHook} from './hooks'
const syncHook = createSyncHook<[string], {name:string}>()
syncHook.tap({context: true}, (context, message) => console.log(context, message))
syncHook.call2({
context: {name: 'Woolly'},
args: ['Hello']
})
Removing a tap
is called untapping
. Tapped hooks return an Untap Function
.
const syncHook = createSyncHook()
const untap1 = syncHook.tap(() => console.log('Hook 1'))
const untap2 = syncHook.tap(() => console.log('Hook 2'))
syncHook.call() // logs "Hook 1" and "Hook 2"
untap1()
syncHook.call() // logs "Hook 2"
untap2()
syncHook.call() // logs nothing
There is currently an issue with Typescript that prevents inferring the hook type unless provided with all or no template parameters. This means you need to be redundant when you want your waterfall hook or bail hook return values to be typed.
Not a big deal, but now you know!
const hook = createSyncHook()
// result: any
const result = hook.call()
const hook = createSyncHook<() => string, any, void, 'bail'>({type:'bail'})
// result: string
const result = hook.call()
const hook = createSyncHook<() => string, any, void, 'sync'>({type:'sync'})
// result: void
const result = hook.call()
// This partial template parameter condition will not be as accurate due to Typescript bug
const hook = createSyncHook<()=>string>({type: 'bail'})
// result: string | void
const result = hook.call()
There are four parameters that are used to indicate the Typescript types.
createSyncHook<PARAMS, TAPDATA, CONTEXT, HOOKTYPE>()
Parameter | Description | Default |
---|---|---|
PARAMS | Specifies the call and tap callback parameters. This can use function style or list style syntax. | any parameters / any return type |
TAPDATA | Each tap can store a custom data object used for filtering calls. | any type |
CONTEXT | The type to use when requesting a custom context to be provided in the tap callback. | any type |
HOOKTYPE | The type of hook; This should be the same value that is specified in the hook creation object. This redundant parameter is needed due to Typescript limitations. | 'series' |
Each hook is called one after another. Return values are ignored.
Each hook is called in parallel.
hook.call
will resolve after all taps resolve or reject.
Return values are ignored.
Each hook is called in series,
receiving the value that the previous hook returned.
hook.call
will resolve after all tap
with the final returned value,
after all taps resolve or reject.
This hook is essentially a pipeline
.
Each hook is called in series.
Once one hook returns a value other than undefined
,
hook.call
will return that value.
Any remaining hooks will not be called.
Any pending async hooks will be discarded.
Each hook is called in series.
If one hook returns a value other than undefined
,
the hook.call
will restart from the first hook.
This will continue until all hooks return undefined
.
The return not-undefined
value
that is used to initiate the loop
is not returned from hook.call
.
This project was inspired by the Tapable
project
that was contributed to the open-source community by the Webpack
team.
See https://github.com/webpack/tapable. The main superpower of Tapable
is its ability to create
various types of hooks. Although Code-Extend is not a fork of Tapable
,
it strives to maintain some API compatibility. If you already use
Tapable, Code-Extend includes features that will supercharge your
experience.
If you are familiar with Tapable, you might find this section of interest.
- Hooks are type-safe. This includes taps, calls, return types, context, filter data and interceptors.
- Taps can be removed (untapped).
- Tap callback order can be reversed.
- Untap interception was added.
-
call
andtap
can be used for both sync and async hooks. - Hooks can be filtered when called, using a typesafe hook filter object.
-
promises
are used for async functionality with same signature as sync. - The
name
parameter is optional when callinghook.tap
. - Initial
context
can be supplied to thehook.call
. - There are only 2 types of hooks: SyncHook and AsyncHook. All other types are specified using options (parallel, series, waterfall, bail, loop).
- Options include controlling how exceptions in callbacks are handled.
- Composable functions are used internally, instead of classes.
- Code generated is 1/10 to 1/7 the size of
Tapable
, for a .02% performance hit.
Feature | Implemented | Comment |
---|---|---|
tap | yes | When async, same as tapPromise
|
tapPromise | yes | Recommended to use tap instead |
tapAsync | yes | |
call | yes | Extended using call2
|
sync | yes | Recommended to use tap instead |
context | yes | Can be provided in hook.call
|
Interception | yes |
tap , untap , register , and loop
|
HookMap | yes | We recommend using call2({filter}) instead |
Multihook | yes | Please check for issues when using withOptions API |
We have included an interop library that allows using Code-Extend
with the Tapable
API. This library uses ES6 proxies to embed our
composable architecture inside class instances.
We have tested this library against the Tapable
unit tests.
import {Interop} from 'code-extend'
// Now use Interop as if it was 'tapable'
const {SyncHook} = Interop
// Notice you will be required to name your taps,
// as mandated by the `Tapable` API.
const hook = new SyncHook(['message'])
hook.tap('hook1', message => { console.log(message) })
// Notice async hooks do not have `tap` or `call` methods
// in the `Tapable` API
const hook2 = new AsyncSeriesHook(['message'])
await hook2.promise('hook1', message => { console.log(message) })
hook2.callAsync('hook1', (message, done) => { console.log(message); done() })
// This will not work
// await hook2.call('hook1', message => { console.log(message) })
One time
yarn test
Continuous
yarn testc
To build the ES6 and UMD distribution, use the following command.
All libraries and type definitions will be placed inside the ./dist
directory.
Note: This will first clean the ./dist
directory.
yarn build