Package Exports
- code-extend
- code-extend/dist/index.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (code-extend) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
Code-Extend
Code-Extend is a framework for enabling a codebase to integrate plugins, addons, and extensions.
By using a variety of customizable hook topologies, your architecture will become highly extensible and loosely coupled.
Code-Extend leverages Typescript, giving your hooks compile-time type checking and automatic code completion.
Motivation
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.
The Code-Extend library solves these issues, while also offering additional features to manage the topology and behavior of the event bus.
Inspiration
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.
Features
- Typesafe hooks and interceptors
- Custom error handling
- Call filtering
- Synchronous and asynchronous
- Variety of hook topologies
- Written from the ground up using ES6 and composable functions.
- Fully tested
- Lightning fast
- Tiny footprint
- No external dependencies
Usage
Hooks are created using createSyncHook(...)
and createAsyncHook(...)
Untyped hooks
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')
Typed arguments
No arguments
const syncHook = createSyncHook<[]>()
syncHook.tap(() => console.log('The hook was called'))
syncHook.call()
One unnamed argument
const syncHook = createSyncHook<[string]>()
syncHook.tap(message => console.log('The hook was called: ' + message))
syncHook.call('Hello')
One named argument
const syncHook = createSyncHook<[message:string]>()
syncHook.tap(message => console.log('The hook was called: ' + message))
syncHook.call('Hello')
Two named arguments
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 argument with return type (using ()=>return
instead of []
)
const syncHook = createSyncHook<(message:string)=>void>()
syncHook.tap(message => console.log('The hook was called: ' + message))
syncHook.call('Hello')
note: Hook return types are only applicable to waterfall and bail hooks.
Untyped, named arguments
This is the Tapable style, and not recommended.
const syncHook = createSyncHook(['message'])
syncHook.tap(message => console.log('The hook was called: ' + message))
syncHook.call('Hello')
Error handling
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: '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 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
Async hooks
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
asyncHook.tapPromise(async message => console.log('Hook3 was called'))
// tapAsync is the old-school callback API
asyncHook.tapAsync((message, callback) => {
console.log('Hook4 was called')
callback(null)
})
await asyncHook.call('Hello') // All taps called in series by default
Hook options
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'
Call filtering
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 third parameter (TAPDATA) of createHook.
createSyncHook<PARAMS, CONTENT, TAPDATA>()
test('Call filtering', () => {
const syncHook = createSyncHook<[string | number], void, 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]
})
})
Providing a context
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.
import {createSyncHook} from './hooks'
const syncHook = createSyncHook()
syncHook.tap({context: true}, (context, message) => console.log(context, message))
syncHook.call2({
context: {name: 'Woolly'},
args: ['Hello']
})
Untapping
Tapped hooks return an Untap Function
. Removing a tap
is called untapping
.
const syncHook = createSyncHook()
const untap1 = syncHook.tap(() => console.log('Hook 1'))
const untap2 = syncHook.tap(() => console.log('Hook 2'))
syncHook.call()
untap1()
syncHook.call()
untap2()
syncHook.call()
Typing hook.call
and hook.call2
There is currently an issue with Typescript that prevents Typescript from 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 types to be locked in.
Not a big deal, but now you know!
const hook = createSyncHook()
// result: any
const result = hook.call()
const hook = createSyncHook<() => string, void, any, 'bail'>()
// result: string
const result = hook.call()
const hook = createSyncHook<() => string, void, any, 'sync'>()
// result: void
const result = hook.call()
// This partial template param condition will not be as accurate
const hook = createSyncHook<()=>string>()
// result: string | void
const result = hook.call()
Overriding Defaults
If you would like to change the default behavior of a hook, you can simply override.
import {createAsyncHook as createAsyncHook2} from 'async_hooks'
import {CreateHookOptions} from './types'
export function createAsyncHook(opts: CreateHookOptions) {
return createAsyncHook2(Object.assign({type: 'waterfall', reverse: true}, opts))
}
Reference
Hook Types
series
Each hook is called one after another. Return values are ignored.
parallel
Each hook is called in parallel.
hook.call
will resolve after all taps resolve or reject.
Return values are ignored.
waterfall
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
.
bail
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.
loop
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
.
Differences From Tapable
If you are familiar with Tapable, you might find this section of interest.
- Taps are type-safe and use parameter types, return types, and context types.
- Taps can be removed (untapped).
- Tap callback order can be reversed.
- Untap interception was added.
- Hooks can be filtered when called.
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 instead of classes.
- Code generated is 1/10 to 1/7 the size for a .02% performance hit.
Implemented Functionality
Other Tapable
functionality may be included in a future release.
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 | May have issues with withOptions API |
Tapable Interop
We have included an interop library that allows using Code-Extend
as if it was Tapable
. This library uses ES6 proxies to wrap our
composable architecture inside class instances.
import {Interop} from 'code-extend'
const {SyncHook} = Interop
// Notice you are now 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 `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) })
Developer
Testing
One time
yarn test
Continuous
yarn testc
Building
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