@poppinss/hooks
A simple yet effective implementation for executing hooks around an event.
This package is a zero-dependency implementation for running lifecycle hooks around an event. Following are some of the notable features.
- Register and run lifecycle hooks.
- Hooks can return cleanup functions that are executed to perform the cleanup.
- Alongside "hooks as functions", you can also register hook providers, which encapsulate event handlers inside a class.
- Super lightweight
Setup
Install the package from the npm packages registry.
npm i @poppinss/hooks
yarn add @poppinss/hooks
And import the Hooks
class as follows.
import Hooks from '@poppinss/hooks'
const hooks = new Hooks()
hooks.add('saving', function () {
console.log('called')
})
await hooks.runner('saving').run()
Defining hooks
The hooks are defined using the hooks.add
method. The method accepts the event name and a callback function to execute.
const hooks = new Hooks()
hooks.add('saving', function () {
console.log('called')
})
Running hooks
You can execute hooks using the Hooks Runner. You can create a new runner instance by calling the hooks.runner
method and passing the event name for which you want to execute hooks.
const hooks = new Hooks()
const runner = hooks.runner('saving')
await runner.run()
Passing data to hooks
You can pass one or more arguments to the runner.run
method, which the runner will share with the hook callbacks. For example:
const hooks = new Hooks()
hooks.add('saving', function (model, transaction) {
})
const runner = hooks.runner('saving')
await runner.run(model, transaction)
Cleanup functions
Cleanup functions allow hooks to clean up after themselves after the main action finishes successfully or with an error. Let's consider a real-world example of saving a model to the database.
- You will first run the
saving
hooks. - Assume one of the
saving
hooks writes some files to the disk. - Next, you issue the insert query to the database, and the query fails.
- The hook that has written files to the disk would want to remove those files as the main operation got canceled with an error.
Following is how you can express that with cleanup functions.
hooks.add('saving', function () {
await fs.writeFile()
return (error) => {
if (error) {
await fs.unlink()
}
}
})
The code responsible for issuing the insert query should run hooks as follows.
const runner = hooks.runner('saving')
try {
await runner.run(model)
await model.save()
} catch (error) {
await runner.cleanup(error)
throw error
}
await runner.cleanup()
Note: The runner.cleanup
method is idempotent. Therefore you can call it multiple times, yet it will run the underlying cleanup methods only once.
Hook Providers
Hook providers are classes with the event lifecycle methods on them. Providers are great when you want to listen to multiple events to create a single cohesive feature. Again, taking the example of models, you can make a hook provider listen for all the hooks and manage a changelog of table columns.
class ChangeSetProvider {
created() {
}
updated() {
}
deleted() {
}
}
Next, register the provider as follows.
hooks.provider(ChangeSetProvider)
Run hooks
await hooks.runner('created').run()
await hooks.runner('updated').run()
await hooks.runner('deleted').run()
Run without hook handlers
You can exclude certain hook handlers from executing using the without
method.
In the following example, we run hooks without executing the generateDefaultAvatar
hook handler. As you can notice, you can specify the function name as a string.
hooks.add('saving', function hashPassword () {})
hooks.add('saving', function generateDefaultAvatar () {})
await hooks
.runner('saving')
.without(['generateDefaultAvatar'])
.run()
You can specify the provider class and the method name with hook providers.
class ChangeSetProvider {
created() {}
}
hooks.provider(ChangeSetProvider)
await hooks
.runner('created')
.without(['ChangeSetProvider.created'])
.run()
Custom executors
The hooks runner allows you to define custom executors for calling the hook callback functions or the provider lifecycle methods. They are helpful when you want to tweak how a method should run.
For example, AdonisJS uses the IoC container to call the provider lifecycle methods.
In the following example, the custom executor is responsible for calling the hook callback functions.
hooks.add('saving', function hashPassword () {})
hooks.add('saving', function generateDefaultAvatar () {})
hooks
.runner('saving')
.executor((handler, isCleanupFunction, ...data) => {
console.log(handler.name)
return handler(...data)
})
.run(model)
Similarly, you can also define a custom executor for the provider classes.
class ChangeSetProvider {
created() {}
}
hooks.provider(ChangeSetProvider)
await hooks
.runner('created')
.providerExecutor((Provider, event, ...data) => {
const provider = new Provider()
if (typeof provider[event] === 'function') {
return provider[event](...data)
}
})
.run(model)
Event types
You can also specify the types of supported events and their arguments well in advance as follows.
The first step is to define a type for all the events.
type Events = {
saving: [
[BaseModel],
[error: Error | null, BaseModel]
],
finding: [
[QueryBuilder],
[error: Error | null, QueryBuilder]
],
}
And then pass it as a generic to the Hooks
class.
const hooks = new Hooks<Events>()