From 42858a5ec6bf9aa8de8e62c75799cbb79d7f586c Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sat, 14 Oct 2023 06:33:33 +0530 Subject: [PATCH] feat: merge next to develop * feat: rewrite without support for IoC container and before,after lifecycle Removed the dependency on AdoniJS application. Also removed the concept of before and after hooks. After hooks can be represented as cleanup functions now BREAKING CHANGE: The API is completely different from version 4.0.0 * docs(readme): update documentation for the rewrite * docs(readme): update docs * chore(package): set tag to next * docs: update installation command to use @next tag * refactor: remove application typings reference from tsconfig file * chore(release): 6.0.0-0 * chore: update dependencies * refactor: set isCleanupPending flag to false by default The flag should be set to true only when the run method has been called * chore(release): 6.0.1-0 * chore: update dependencies * refactor: run cleanup functions in reverse order * chore(release): 6.0.2-0 * refactor: rewrite in TypeScript * feat: add support to register hook providers * docs(README): initial docs * refactor: improve events generic type * docs: add documentation * docs(README): grammatical improvements * ci: fix import url to be cross platform * chore: ignore package-lock file * chore(release): 7.0.0-0 * refactor: remove providers and executor in favor of object based hooks The object based hooks can also work as a provider and executor both by wrapping the underlying method inside another function * chore(release): 7.1.0-0 * chore: update dependencies * docs(README): fix badge url for github workflow * chore(release): 7.1.1-0 * chore: update dependencies * chore: update dependencies * chore(release): 7.1.1-1 * chore: update dependencies * chore(release): 7.1.1-2 * chore: update dependencies * chore: migrate to adonisjs tooling config * chore: use prettier config from adonisjs tooling and format source * chore: use tsconfig from adonisjs tooling config * feat: add helper types * chore: add engines to package.json file * chore(release): 7.1.1-3 * chore: update dependencies * chore: upgrade japa to v3 * ci: add linting and typechecking in ci * chore(release): 7.1.1-4 * chore: update dependencies * docs: update README * chore(release): 7.1.1-5 * chore: update dependencies * feat: allow running hooks in reverse order * test: add test for passing multiple arguments to hooks * chore(release): 7.1.1-6 * chore: update dependencies * chore: use tsup for bundling --- .bin/test.js | 7 - .github/COMMIT_CONVENTION.md | 70 ------ .github/CONTRIBUTING.md | 38 ---- .github/ISSUE_TEMPLATE.md | 23 -- .github/PULL_REQUEST_TEMPLATE.md | 28 --- .github/labels.json | 170 ++++++++++++++ .github/stale.yml | 4 +- .github/workflows/checks.yml | 14 ++ .github/workflows/test.yml | 22 -- .husky/commit-msg | 3 +- .husky/pre-commit | 4 - .npmrc | 1 + .prettierignore | 7 +- README.md | 199 ++++++++++------- bin/test.ts | 32 +++ index.ts | 4 +- package.json | 169 ++++++-------- src/Hooks/index.ts | 169 -------------- src/hooks.ts | 134 +++++++++++ src/runner.ts | 150 +++++++++++++ src/types.ts | 45 ++++ test/hooks.spec.ts | 369 ------------------------------- tests/hooks.spec.ts | 198 +++++++++++++++++ tests/runner.spec.ts | 265 ++++++++++++++++++++++ tests/runner_cleanup.spec.ts | 131 +++++++++++ tsconfig.json | 11 +- 26 files changed, 1345 insertions(+), 922 deletions(-) delete mode 100644 .bin/test.js delete mode 100644 .github/COMMIT_CONVENTION.md delete mode 100644 .github/CONTRIBUTING.md delete mode 100644 .github/ISSUE_TEMPLATE.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/labels.json create mode 100644 .github/workflows/checks.yml delete mode 100644 .github/workflows/test.yml delete mode 100755 .husky/pre-commit create mode 100644 .npmrc create mode 100644 bin/test.ts delete mode 100644 src/Hooks/index.ts create mode 100644 src/hooks.ts create mode 100644 src/runner.ts create mode 100644 src/types.ts delete mode 100644 test/hooks.spec.ts create mode 100644 tests/hooks.spec.ts create mode 100644 tests/runner.spec.ts create mode 100644 tests/runner_cleanup.spec.ts diff --git a/.bin/test.js b/.bin/test.js deleted file mode 100644 index e20c2dd..0000000 --- a/.bin/test.js +++ /dev/null @@ -1,7 +0,0 @@ -require('@adonisjs/require-ts/build/register') - -const { configure } = require('japa') - -configure({ - files: ['test/**/*.spec.ts'], -}) diff --git a/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md deleted file mode 100644 index fc852af..0000000 --- a/.github/COMMIT_CONVENTION.md +++ /dev/null @@ -1,70 +0,0 @@ -## Git Commit Message Convention - -> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). - -Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. - -``` js -/^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ -``` - -## Commit Message Format -A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: - -> The **scope** is optional - -``` -feat(router): add support for prefix - -Prefix makes it easier to append a path to a group of routes -``` - -1. `feat` is type. -2. `router` is scope and is optional -3. `add support for prefix` is the subject -4. The **body** is followed by a blank line. -5. The optional **footer** can be added after the body, followed by a blank line. - -## Types -Only one type can be used at a time and only following types are allowed. - -- feat -- fix -- docs -- style -- refactor -- perf -- test -- workflow -- ci -- chore -- types -- build - -If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. - -### Revert -If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. - -## Scope -The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. - -## Subject -The subject contains succinct description of the change: - -- use the imperative, present tense: "change" not "changed" nor "changes". -- don't capitalize first letter -- no dot (.) at the end - -## Body - -Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". -The body should include the motivation for the change and contrast this with previous behavior. - -## Footer - -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. - -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. - diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index c119b32..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,38 +0,0 @@ -# Contributing - -We love pull requests. And following this guidelines will make your pull request easier to merge - -## Prerequisites - -- Install [EditorConfig](http://editorconfig.org/) plugin for your code editor to make sure it uses correct settings. -- Fork the repository and clone your fork. -- Install dependencies: `npm install`. - -## Coding style - -We make use of [standard](https://standardjs.com/) to lint our code. Standard does not need a config file and comes with set of non-configurable rules. - -## Development work-flow - -Always make sure to lint and test your code before pushing it to the GitHub. - -```bash -npm test -``` - -Just lint the code - -```bash -npm run lint -``` - -**Make sure you add sufficient tests for the change**. - -## Other notes - -- Do not change version number inside the `package.json` file. -- Do not update `CHANGELOG.md` file. - -## Need help? - -Feel free to ask. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index b77924c..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,23 +0,0 @@ - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -- Ensure the issue isn't already reported. -- Ensure you are reporting the bug in the correct repo. - -*Delete the above section and the instructions in the sections below before submitting* - -## Description - -If this is a feature request, explain why it should be added. Specific use-cases are best. - -For bug reports, please provide as much *relevant* info as possible. - -## Package version - - -## Error Message & Stack Trace - -## Relevant Information diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 1741cb9..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ - - -## Proposed changes - -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. - -## Types of changes - -What types of changes does your code introduce? - -_Put an `x` in the boxes that apply_ - -- [ ] Bugfix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - -## Checklist - -_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ - -- [ ] I have read the [CONTRIBUTING](https://github.com/poppinss/hooks/blob/master/.github/CONTRIBUTING.md) doc -- [ ] Lint and unit tests pass locally with my changes -- [ ] I have added tests that prove my fix is effective or that my feature works. -- [ ] I have added necessary documentation (if appropriate) - -## Further comments - -If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..ba001c6 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,170 @@ +[ + { + "name": "Priority: Critical", + "color": "ea0056", + "description": "The issue needs urgent attention", + "aliases": [] + }, + { + "name": "Priority: High", + "color": "5666ed", + "description": "Look into this issue before picking up any new work", + "aliases": [] + }, + { + "name": "Priority: Medium", + "color": "f4ff61", + "description": "Try to fix the issue for the next patch/minor release", + "aliases": [] + }, + { + "name": "Priority: Low", + "color": "87dfd6", + "description": "Something worth considering, but not a top priority for the team", + "aliases": [] + }, + { + "name": "Semver: Alpha", + "color": "008480", + "description": "Will make it's way to the next alpha version of the package", + "aliases": [] + }, + { + "name": "Semver: Major", + "color": "ea0056", + "description": "Has breaking changes", + "aliases": [] + }, + { + "name": "Semver: Minor", + "color": "fbe555", + "description": "Mainly new features and improvements", + "aliases": [] + }, + { + "name": "Semver: Next", + "color": "5666ed", + "description": "Will make it's way to the bleeding edge version of the package", + "aliases": [] + }, + { + "name": "Semver: Patch", + "color": "87dfd6", + "description": "A bug fix", + "aliases": [] + }, + { + "name": "Status: Abandoned", + "color": "ffffff", + "description": "Dropped and not into consideration", + "aliases": ["wontfix"] + }, + { + "name": "Status: Accepted", + "color": "e5fbf2", + "description": "The proposal or the feature has been accepted for the future versions", + "aliases": [] + }, + { + "name": "Status: Blocked", + "color": "ea0056", + "description": "The work on the issue or the PR is blocked. Check comments for reasoning", + "aliases": [] + }, + { + "name": "Status: Completed", + "color": "008672", + "description": "The work has been completed, but not released yet", + "aliases": [] + }, + { + "name": "Status: In Progress", + "color": "73dbc4", + "description": "Still banging the keyboard", + "aliases": ["in progress"] + }, + { + "name": "Status: On Hold", + "color": "f4ff61", + "description": "The work was started earlier, but is on hold now. Check comments for reasoning", + "aliases": ["On Hold"] + }, + { + "name": "Status: Review Needed", + "color": "fbe555", + "description": "Review from the core team is required before moving forward", + "aliases": [] + }, + { + "name": "Status: Awaiting More Information", + "color": "89f8ce", + "description": "Waiting on the issue reporter or PR author to provide more information", + "aliases": [] + }, + { + "name": "Status: Need Contributors", + "color": "7057ff", + "description": "Looking for contributors to help us move forward with this issue or PR", + "aliases": [] + }, + { + "name": "Type: Bug", + "color": "ea0056", + "description": "The issue has indentified a bug", + "aliases": ["bug"] + }, + { + "name": "Type: Security", + "color": "ea0056", + "description": "Spotted security vulnerability and is a top priority for the core team", + "aliases": [] + }, + { + "name": "Type: Duplicate", + "color": "00837e", + "description": "Already answered or fixed previously", + "aliases": ["duplicate"] + }, + { + "name": "Type: Enhancement", + "color": "89f8ce", + "description": "Improving an existing feature", + "aliases": ["enhancement"] + }, + { + "name": "Type: Feature Request", + "color": "483add", + "description": "Request to add a new feature to the package", + "aliases": [] + }, + { + "name": "Type: Invalid", + "color": "dbdbdb", + "description": "Doesn't really belong here. Maybe use discussion threads?", + "aliases": ["invalid"] + }, + { + "name": "Type: Question", + "color": "eceafc", + "description": "Needs clarification", + "aliases": ["help wanted", "question"] + }, + { + "name": "Type: Documentation Change", + "color": "7057ff", + "description": "Documentation needs some improvements", + "aliases": ["documentation"] + }, + { + "name": "Type: Dependencies Update", + "color": "00837e", + "description": "Bump dependencies", + "aliases": ["dependencies"] + }, + { + "name": "Good First Issue", + "color": "008480", + "description": "Want to contribute? Just filter by this label", + "aliases": ["good first issue"] + } +] diff --git a/.github/stale.yml b/.github/stale.yml index 7a6a571..f767674 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -6,10 +6,10 @@ daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - - "Type: Security" + - 'Type: Security' # Label to use when marking an issue as stale -staleLabel: "Status: Abandoned" +staleLabel: 'Status: Abandoned' # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..3536d96 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,14 @@ +name: checks +on: + - push + - pull_request + +jobs: + test: + uses: poppinss/.github/.github/workflows/test.yml@main + + lint: + uses: poppinss/.github/.github/workflows/lint.yml@main + + typecheck: + uses: poppinss/.github/.github/workflows/typecheck.yml@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 80de3d1..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: test -on: - - push - - pull_request -jobs: - linux: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: - - 16.13.1 - - 17.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test diff --git a/.husky/commit-msg b/.husky/commit-msg index 4654c12..7fed485 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,3 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js + +npx --no -- commitlint --edit diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 54532b0..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" -npx doctoc README.md --title='## Table of contents' -git add README.md diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierignore b/.prettierignore index 28b961f..17edef5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,8 +1,3 @@ build docs -config.json -.eslintrc.json -package.json -*.html -*.md -*.txt +coverage diff --git a/README.md b/README.md index cea6b94..e13c4b7 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,184 @@ -
+# @poppinss/hooks -# Hooks +> A simple yet effective implementation for executing hooks around an event. -> A no brainer module to execute lifecycle hooks in sequence. +[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] -[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] [![synk-image]][synk-url] +This package is a zero-dependency implementation for running lifecycle hooks around an event. Following are some of the notable features. -I find myself re-writing the code for hooks in multiple packages, so decided to extract it to it's own module, that can be re-used by other modules of AdonisJS. +- Register and run lifecycle hooks. +- Hooks can return cleanup functions that are executed to perform the cleanup. +- Super lightweight - - -## Table of contents +## Setup -- [How it works?](#how-it-works) -- [Installation](#installation) -- [Usage](#usage) -- [API](#api) - - [add(lifecycle: 'before' | 'after', action: string, handler: Function | string)](#addlifecycle-before--after-action-string-handler-function--string) - - [exec(lifecycle: 'before' | 'after', action: string, ...data: any[])](#execlifecycle-before--after-action-string-data-any) - - [remove (lifecycle: 'before' | 'after', action: string, handler: HooksHandler | string)](#remove-lifecycle-before--after-action-string-handler-hookshandler--string) - - [clear(lifecycle: 'before' | 'after', action?: string)](#clearlifecycle-before--after-action-string) - - [merge (hooks: Hooks): void](#merge-hooks-hooks-void) +Install the package from the npm packages registry. - +```sh +npm i @poppinss/hooks -## How it works? +# yarn lovers +yarn add @poppinss/hooks +``` -The hooks class exposes the API to `register`, `remove` and `exec` lifecycle hooks for any number of actions or events. The class API is meant to be used internally and not by the user facing code and this gives you the chance to improve the hooks DX. +And import the `Hooks` class as follows. -For example: The Lucid models uses this class internally and expose `before` and `after` methods on the model itself. Doing this, Lucid can control the autocomplete, type checking for the `before` and `after` methods itself, without relying on this package to expose the generics API. +```ts +import Hooks from '@poppinss/hooks' -> Also generics increases the number of types Typescript has to generate and it's better to avoid them whenever possible. +const hooks = new Hooks() -## Installation +hooks.add('saving', function () { + console.log('called') +}) -Install the package from npm registry as follows: +// Execute hooks using runner +await hooks.runner('saving').run() +``` -```sh -npm i @poppinss/hooks +## Defining hooks -# yarn -yarn add @poppinss/hooks -``` +The hooks are defined using the `hooks.add` method. The method accepts the event name and a callback function to execute. -## Usage +```ts +const hooks = new Hooks() -Use it as follows +hooks.add('saving', function () { + console.log('called') +}) +``` + +You can also define hook as an object with the `name` and the `handle` method property. This is usually helpful when you want to specify a custom name for the hook, or re-use the same handle method multiple times. ```ts -import { Hooks } from '@poppinss/hooks' const hooks = new Hooks() -hooks.add('before', 'save', function () {}) +function handleSave() {} -// Later invoke before save hooks -await hooks.exec('before', 'save', { id: 1 }) +hooks.add('saving', { name: 'beforeSave', handle: handleSave }) +hooks.add('creating', { name: 'beforeCreate', handle: handleSave }) ``` -If you want the end user to define IoC container bindings as the hook handler, then you need to pass the `IoC` container resolver to the Hooks constructor. Following is the snippet from Lucid models. +The `handle` method receives the first argument as the event name, followed by the rest of the arguments supplied during runtime. + +## 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. ```ts -import { Ioc } from '@adonisjs/fold' -const ioc = new Ioc() -const resolver = ioc.getResolver(undefined, 'modelHooks', 'App/Models/Hooks') +const hooks = new Hooks() -const hooks = new Hooks(resolver) +const runner = hooks.runner('saving') +await runner.run() ``` -The resolver allows the end user to pass the hook reference as string and hooks must live inside `App/Models/Hooks` folder. +To run hooks in the reverse order, you can use the `runner.runReverse` method. ```ts -hooks.add('before', 'save', 'User.encryptPassword') -``` +const hooks = new Hooks() -## API +const runner = hooks.runner('saving') +await runner.runReverse() +``` -#### add(lifecycle: 'before' | 'after', action: string, handler: Function | string) +### Passing data to hooks -Add a new hook handler. +You can pass one or more arguments to the `runner.run` method, which the runner will share with the hook callbacks. For example: ```ts -hooks.add('before', 'save', (data) => { - console.log(data) -}) +const hooks = new Hooks() + +hooks.add('saving', function (model, transaction) {}) + +const runner = hooks.runner('saving') +await runner.run(model, transaction) ``` -#### exec(lifecycle: 'before' | 'after', action: string, ...data: any[]) +## 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. -Execute a given hook for a selected lifecycle. +Following is how you can express that with cleanup functions. ```ts -hooks.exec('before', 'save', { username: 'virk' }) +hooks.add('saving', function () { + await fs.writeFile() + + // Return the cleanup function + return (error) => { + // In case of an error, remove the file + if (error) { + await fs.unlink() + } + } +}) ``` -#### remove (lifecycle: 'before' | 'after', action: string, handler: HooksHandler | string) - -Remove an earlier registered hook. If you are using the IoC container bindings, then passing the binding string is enough, otherwise you need to store the reference of the function. +The code responsible for issuing the insert query should run hooks as follows. ```ts -function onSave() {} +const runner = hooks.runner('saving') + +try { + await runner.run(model) + await model.save() +} catch (error) { + // Perform cleanup and pass error + await runner.cleanup(error) + throw error +} + +// Perform cleanup in case of success as well +await runner.cleanup() +``` -hooks.add('before', 'save', onSave) +> **Note**: The `runner.cleanup` method is idempotent. Therefore you can call it multiple times, yet it will run the underlying cleanup methods only once. -// Later remove it -hooks.remove('before', 'save', onSave) -``` +## Run without hook handlers -#### clear(lifecycle: 'before' | 'after', action?: string) +You can exclude certain hook handlers from executing using the `without` method. -Clear all hooks for a given lifecycle and optionally an action. +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. ```ts -hooks.clear('before') +hooks.add('saving', function hashPassword() {}) +hooks.add('saving', function generateDefaultAvatar() {}) -// Clear just for the save action -hooks.clear('before', 'save') +await hooks.runner('saving').without(['generateDefaultAvatar']).run() ``` -#### merge (hooks: Hooks): void +## Event types + +You can also specify the types of supported events and their arguments well in advance as follows. -Merge hooks from an existing hooks instance. Useful during class inheritance. +The first step is to define a type for all the events. ```ts -const hooks = new Hooks() -hooks.add('before', 'save', function () {}) +type Events = { + saving: [ + [BaseModel], // for hook handler + [error: Error | null, BaseModel] // for cleanup function + ] + finding: [ + [QueryBuilder], // for hook handler + [error: Error | null, QueryBuilder] // for cleanup function + ] +} +``` -const hooks1 = new Hooks() -hooks1.merge(hooks) +And then pass it as a generic to the `Hooks` class. -await hooks1.exec('before', 'save', []) +```ts +const hooks = new Hooks() ``` -[gh-workflow-image]: https://img.shields.io/github/workflow/status/poppinss/hooks/test?style=for-the-badge -[gh-workflow-url]: https://github.com/poppinss/hooks/actions/workflows/test.yml "Github action" +[gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/poppinss/hooks/checks.yml?style=for-the-badge +[gh-workflow-url]: https://github.com/poppinss/hooks/actions/workflows/checks.yml 'Github action' [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript [typescript-url]: "typescript" @@ -141,7 +187,4 @@ await hooks1.exec('before', 'save', []) [npm-url]: https://npmjs.org/package/@poppinss/hooks 'npm' [license-image]: https://img.shields.io/npm/l/@poppinss/hooks?color=blueviolet&style=for-the-badge -[license-url]: LICENSE.md 'license' - -[synk-image]: https://img.shields.io/snyk/vulnerabilities/github/poppinss/hooks?label=Synk%20Vulnerabilities&style=for-the-badge -[synk-url]: https://snyk.io/test/github/poppinss/hooks?targetFile=package.json 'synk' +[license-url]: LICENSE.md 'license.' diff --git a/bin/test.ts b/bin/test.ts new file mode 100644 index 0000000..9b38db0 --- /dev/null +++ b/bin/test.ts @@ -0,0 +1,32 @@ +import { assert } from '@japa/assert' +import { expectTypeOf } from '@japa/expect-type' +import { processCLIArgs, configure, run } from '@japa/runner' + +/* +|-------------------------------------------------------------------------- +| Configure tests +|-------------------------------------------------------------------------- +| +| The configure method accepts the configuration to configure the Japa +| tests runner. +| +| The first method call "processCliArgs" process the command line arguments +| and turns them into a config object. Using this method is not mandatory. +| +| Please consult japa.dev/runner-config for the config docs. +*/ +processCLIArgs(process.argv.splice(2)) +configure({ + files: ['tests/**/*.spec.ts'], + plugins: [assert(), expectTypeOf()], +}) + +/* +|-------------------------------------------------------------------------- +| Run tests +|-------------------------------------------------------------------------- +| +| The following "run" method is required to execute all the tests. +| +*/ +run() diff --git a/index.ts b/index.ts index 3e82ecf..66e2c63 100644 --- a/index.ts +++ b/index.ts @@ -1,10 +1,10 @@ /* * @poppinss/hooks * - * (c) Harminder Virk + * (c) Poppinss * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -export { Hooks } from './src/Hooks' +export { Hooks as default } from './src/hooks.js' diff --git a/package.json b/package.json index 9c3e7a7..46c7148 100644 --- a/package.json +++ b/package.json @@ -1,60 +1,33 @@ { "name": "@poppinss/hooks", - "version": "5.0.3", + "version": "7.1.1-6", "description": "A no brainer hooks module for execute before/after lifecycle hooks", "main": "build/index.js", + "type": "module", "files": [ - "build/src", - "build/index.d.ts", - "build/index.js" + "build" ], + "engines": { + "node": ">=18.16.0" + }, + "exports": { + ".": "./build/index.js", + "./types": "./build/src/types.js" + }, "scripts": { - "mrm": "mrm --preset=@adonisjs/mrm-preset", "pretest": "npm run lint", - "test": "node .bin/test.js", - "clean": "del build", - "compile": "npm run lint && npm run clean && tsc", + "test": "c8 npm run quick:test", + "clean": "del-cli build", + "typecheck": "tsc --noEmit", + "compile": "npm run lint && npm run clean && tsup-node", "build": "npm run compile", - "commit": "git-cz", - "release": "np --message=\"chore(release): %s\"", + "release": "np", "version": "npm run build", "format": "prettier --write .", "prepublishOnly": "npm run build", "lint": "eslint . --ext=.ts", - "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json poppinss/hooks" - }, - "peerDependencies": { - "@adonisjs/application": ">=4.0.0" - }, - "peerDependenciesMeta": { - "@adonisjs/application": { - "optional": true - } - }, - "devDependencies": { - "@adonisjs/application": "^5.2.5", - "@adonisjs/mrm-preset": "^5.0.3", - "@adonisjs/require-ts": "^2.0.12", - "@types/node": "^18.0.0", - "commitizen": "^4.2.4", - "cz-conventional-changelog": "^3.3.0", - "del-cli": "^4.0.1", - "doctoc": "^2.2.0", - "eslint": "^8.18.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-adonis": "^2.1.0", - "eslint-plugin-prettier": "^4.0.0", - "github-label-sync": "^2.2.0", - "husky": "^8.0.1", - "japa": "^4.0.0", - "mrm": "^4.0.0", - "np": "^7.6.2", - "prettier": "^2.7.1", - "typescript": "^4.7.4" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/poppinss/hooks.git" + "sync-labels": "github-label-sync --labels .github/labels.json poppinss/hooks", + "quick:test": "node --loader=ts-node/esm bin/test.ts" }, "keywords": [ "hooks", @@ -62,73 +35,73 @@ ], "author": "virk,poppinss", "license": "MIT", + "devDependencies": { + "@adonisjs/eslint-config": "^1.1.8", + "@adonisjs/prettier-config": "^1.1.8", + "@adonisjs/tsconfig": "^1.1.8", + "@commitlint/cli": "^17.7.2", + "@commitlint/config-conventional": "^17.7.0", + "@japa/assert": "^2.0.0", + "@japa/expect-type": "^2.0.0", + "@japa/runner": "^3.0.1", + "@swc/core": "1.3.82", + "@types/node": "^20.8.6", + "c8": "^8.0.1", + "del-cli": "^5.1.0", + "eslint": "^8.51.0", + "github-label-sync": "^2.3.1", + "husky": "^8.0.3", + "np": "^8.0.4", + "prettier": "^3.0.3", + "ts-node": "^10.9.1", + "tsup": "^7.2.0", + "typescript": "^5.2.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/poppinss/hooks.git" + }, "bugs": { "url": "https://github.com/poppinss/hooks/issues" }, "homepage": "https://github.com/poppinss/hooks#readme", - "nyc": { - "exclude": [ - "test" - ], - "extension": [ - ".ts" + "commitlint": { + "extends": [ + "@commitlint/config-conventional" ] }, - "husky": { - "hooks": { - "commit-msg": "node ./node_modules/@adonisjs/mrm-preset/validateCommit/conventional/validate.js" - } - }, - "config": { - "commitizen": { - "path": "cz-conventional-changelog" - } + "publishConfig": { + "access": "public", + "tag": "next" }, "np": { - "contents": ".", + "message": "chore(release): %s", + "tag": "next", + "branch": "main", "anyBranch": false }, - "mrmConfig": { - "core": false, - "license": "MIT", - "services": [ - "github-actions" + "c8": { + "reporter": [ + "text", + "html" ], - "minNodeVersion": "16.13.1", - "probotApps": [ - "stale", - "lock" - ], - "runGhActionsOnWindows": false + "exclude": [ + "tests/**" + ] }, "eslintConfig": { - "extends": [ - "plugin:adonis/typescriptPackage", - "prettier" - ], - "plugins": [ - "prettier" - ], - "rules": { - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ] - } + "extends": "@adonisjs/eslint-config/package" }, - "eslintIgnore": [ - "build" - ], - "prettier": { - "trailingComma": "es5", - "semi": false, - "singleQuote": true, - "useTabs": false, - "quoteProps": "consistent", - "bracketSpacing": true, - "arrowParens": "always", - "printWidth": 100 + "prettier": "@adonisjs/prettier-config", + "tsup": { + "entry": [ + "./index.ts", + "./src/types.ts" + ], + "outDir": "./build", + "clean": true, + "format": "esm", + "dts": true, + "target": "esnext" } } diff --git a/src/Hooks/index.ts b/src/Hooks/index.ts deleted file mode 100644 index a730f86..0000000 --- a/src/Hooks/index.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* - * @poppinss/hooks - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { ApplicationContract, IocResolverLookupNode } from '@ioc:Adonis/Core/Application' - -type HooksHandler = (...args: any[]) => void | Promise - -/** - * Exposes the API to register before/after lifecycle hooks for a given action - * with option to resolve handlers from the IoC container. - * - * The hooks class doesn't provide autocomplete for actions and the arguments - * the handler will receive, since we expect this class to be used internally - * for user facing objects. - */ -export class Hooks { - private hooks: { - before: Map>> - after: Map>> - } = { - before: new Map(), - after: new Map(), - } - - constructor(private resolver?: ReturnType) {} - - /** - * Raise exceptins when resolver is not defined - */ - private ensureResolver() { - if (!this.resolver) { - throw new Error('IoC container resolver is required to register string based hooks handlers') - } - } - - /** - * Resolves the hook handler using the resolver when it is defined as string - * or returns the function reference back - */ - private resolveHandler( - handler: HooksHandler | string - ): HooksHandler | IocResolverLookupNode { - if (typeof handler === 'string') { - this.ensureResolver() - return this.resolver!.resolve(handler) - } - - return handler - } - - /** - * Returns handlers set for a given action or undefined - */ - private getActionHandlers(lifecycle: 'before' | 'after', action: string) { - return this.hooks[lifecycle].get(action) - } - - /** - * Adds the resolved handler to the actions set - */ - private addResolvedHandler( - lifecycle: 'before' | 'after', - action: string, - handler: HooksHandler | IocResolverLookupNode - ) { - const handlers = this.getActionHandlers(lifecycle, action) - - if (handlers) { - handlers.add(handler) - } else { - this.hooks[lifecycle].set(action, new Set([handler])) - } - } - - /** - * Returns a boolean whether a handler has been already registered or not - */ - public has( - lifecycle: 'before' | 'after', - action: string, - handler: HooksHandler | string - ): boolean { - const handlers = this.getActionHandlers(lifecycle, action) - if (!handlers) { - return false - } - - return handlers.has(this.resolveHandler(handler)) - } - - /** - * Register hook handler for a given event and lifecycle - */ - public add(lifecycle: 'before' | 'after', action: string, handler: HooksHandler | string): this { - this.addResolvedHandler(lifecycle, action, this.resolveHandler(handler)) - return this - } - - /** - * Remove a pre-registered handler - */ - public remove( - lifecycle: 'before' | 'after', - action: string, - handler: HooksHandler | string - ): void { - const handlers = this.getActionHandlers(lifecycle, action) - if (!handlers) { - return - } - - handlers.delete(this.resolveHandler(handler)) - } - - /** - * Remove all handlers for a given action or lifecycle. If action is not - * defined, then all actions for that given lifecycle are removed - */ - public clear(lifecycle: 'before' | 'after', action?: string): void { - if (!action) { - this.hooks[lifecycle].clear() - return - } - - this.hooks[lifecycle].delete(action) - } - - /** - * Merges hooks of a given hook instance. To merge from more than - * one instance, you can call the merge method for multiple times - */ - public merge(hooks: Hooks) { - hooks.hooks.before.forEach((actionHooks, action) => { - actionHooks.forEach((handler) => { - this.addResolvedHandler('before', action, handler) - }) - }) - - hooks.hooks.after.forEach((actionHooks, action) => { - actionHooks.forEach((handler) => { - this.addResolvedHandler('after', action, handler) - }) - }) - } - - /** - * Executes the hook handler for a given action and lifecycle - */ - public async exec(lifecycle: 'before' | 'after', action: string, ...data: any[]): Promise { - const handlers = this.getActionHandlers(lifecycle, action) - if (!handlers) { - return - } - - for (let handler of handlers) { - if (typeof handler === 'function') { - await handler(...data) - } else { - await this.resolver!.call(handler, undefined, data) - } - } - } -} diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..0380347 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,134 @@ +/* + * @poppinss/hooks + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Runner } from './runner.js' +import { HookHandler, HookHandlerProvider } from './types.js' + +/** + * Quite simple implementation register lifecycle hooks around specific events. + * + * ```ts + * const hooks = new Hooks() + * + * hooks.add('saving', function hashPassword(entity) { + * }) + * ``` + */ +export class Hooks> { + /** + * A collection of registered hooks + */ + #hooks: { + [Event in keyof Events]: Map< + Event, + Set< + | HookHandler + | HookHandlerProvider + > + > + }[keyof Events] = new Map() + + /** + * Get access to all the registered hooks. The return value is + * a map of the event name and a set of handlers. + */ + all() { + return this.#hooks + } + + /** + * Find if a handler for a given event exists. + */ + has( + event: Event, + handler: + | HookHandler + | HookHandlerProvider + ): boolean { + const handlers = this.#hooks.get(event) + if (!handlers) { + return false + } + + return handlers.has(handler) + } + + /** + * Add a hook handler for a given event. Adding the same handler twice will + * result in a noop. + */ + add( + event: Event, + handler: + | HookHandler + | HookHandlerProvider + ): this { + const handlers = this.#hooks.get(event) + + /** + * Instantiate handlers + */ + if (!handlers) { + this.#hooks.set(event, new Set()) + } + + this.#hooks.get(event)!.add(handler) + return this + } + + /** + * Remove hook handler for a given event. + */ + remove( + event: Event, + handler: + | HookHandler + | HookHandlerProvider + ): boolean { + const handlers = this.#hooks.get(event) + if (!handlers) { + return false + } + + return handlers.delete(handler) + } + + /** + * Clear all the hooks for a specific event or all the + * events. + */ + clear(event?: keyof Events): void { + if (!event) { + this.#hooks.clear() + return + } + + this.#hooks.delete(event) + } + + /** + * Merge hooks from an existing hooks instance. + */ + merge(hooks: Hooks) { + hooks.all().forEach((actionHooks, action) => { + actionHooks.forEach((handler) => { + this.add(action, handler) + }) + }) + } + + /** + * Returns an instance of the runner to run hooks + */ + runner>( + action: Event + ): Runner { + return new Runner(action, this.#hooks.get(action)) + } +} diff --git a/src/runner.ts b/src/runner.ts new file mode 100644 index 0000000..8e6e50d --- /dev/null +++ b/src/runner.ts @@ -0,0 +1,150 @@ +/* + * @poppinss/hooks + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { HookHandler, CleanupHandler, HookHandlerProvider } from './types.js' + +/** + * Runner allows running a set of specific hook handlers for a given + * event. You can grab the instance of the runner using the "hook.runner" method. + * + * ```ts + * const hooks = new Hooks() + * + * await hooks.runner('saving').run() + * ``` + */ +export class Runner { + /** + * A collection of registered hook handlers + */ + #hookHandlers: Set< + HookHandler | HookHandlerProvider + > + + /** + * Cleanup handlers should always be an array of functions. Using a set will + * discard duplicates and it is very much possible for two hooks to return + * a shared cleanup handler. + */ + #cleanupHandlers: CleanupHandler[] = [] + + /** + * State to perform the cleanup + */ + #state: 'idle' | 'cleanup_pending' | 'cleanup_initiated' | 'cleanup_completed' = 'idle' + + /** + * A collection of handlers to ignore when executed them + */ + #handlersToIgnore: string[] = [] + + /** + * Whether or not to skip all the hooks + */ + #skipAllHooks: boolean = false + + /** + * Find if cleanup is pending or not + */ + get isCleanupPending() { + return this.#state === 'cleanup_pending' + } + + constructor( + public action: string, + hookHandlers?: Set< + HookHandler | HookHandlerProvider + > + ) { + this.#hookHandlers = hookHandlers || new Set() + } + + /** + * Filter to check if we should run the handler + */ + #filter(handlerName: string): boolean { + return !this.#handlersToIgnore.includes(handlerName) + } + + /** + * Ignore specific or all hook handlers. Calling this + * method multiple times will result in overwriting + * the existing state. + */ + without(handlersToIgnore?: string[]): this { + if (!handlersToIgnore) { + this.#skipAllHooks = true + } else { + this.#skipAllHooks = false + this.#handlersToIgnore = handlersToIgnore + } + + return this + } + + /** + * Executing hooks + */ + async #exec(reverse: boolean, data: HookArgs) { + if (this.#state !== 'idle') { + return + } + + this.#state = 'cleanup_pending' + if (this.#skipAllHooks) { + return + } + + const handlers = reverse ? Array.from(this.#hookHandlers).reverse() : this.#hookHandlers + for (let handler of handlers) { + if (this.#filter(handler.name)) { + const result = await (typeof handler === 'function' + ? handler(...data) + : handler.handle(this.action, ...data)) + + if (typeof result === 'function') { + this.#cleanupHandlers.push(result) + } + } + } + } + + /** + * Execute handlers + */ + async run(...data: HookArgs): Promise { + return this.#exec(false, data) + } + + /** + * Execute handlers in reverse order + */ + async runReverse(...data: HookArgs): Promise { + return this.#exec(true, data) + } + + /** + * Execute cleanup actions + */ + async cleanup(...data: CleanUpArgs) { + if (!this.isCleanupPending) { + return + } + + this.#state = 'cleanup_initiated' + + let startIndex = this.#cleanupHandlers.length + while (startIndex--) { + await this.#cleanupHandlers[startIndex](...data) + } + + this.#state = 'cleanup_completed' + this.#cleanupHandlers = [] + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..adcac2b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,45 @@ +/* + * @poppinss/hooks + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Runner } from './runner.js' + +/** + * Exporting hooks runner as a type + */ +export { Runner } + +/** + * Shape of the cleanup handler + */ +export type CleanupHandler = (...args: Args) => void | Promise + +/** + * Shape of the hook handler + */ +export type HookHandler = ( + ...args: Args +) => void | CleanupHandler | Promise | Promise> + +/** + * Extracts args from a hook handler type + */ +export type ExtractHookHandlerArgs = Handler extends HookHandler + ? [A, B] + : never + +/** + * Hook represented as an object with handle method + */ +export type HookHandlerProvider = { + name: string + handle( + event: string, + ...args: Args + ): void | CleanupHandler | Promise | Promise> +} diff --git a/test/hooks.spec.ts b/test/hooks.spec.ts deleted file mode 100644 index 189040a..0000000 --- a/test/hooks.spec.ts +++ /dev/null @@ -1,369 +0,0 @@ -/* - * @poppinss/hooks - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import test from 'japa' -import { Application } from '@adonisjs/application' -import { Hooks } from '../src/Hooks' - -test.group('Hooks', () => { - test('add hooks for a given lifecycle', (assert) => { - const hooks = new Hooks() - - function beforeSave() {} - hooks.add('before', 'save', beforeSave) - - assert.deepEqual(hooks['hooks'].before.get('save'), new Set([beforeSave])) - }) - - test('execute added hooks in sequence', async (assert) => { - const stack: string[] = [] - const hooks = new Hooks() - - function beforeSave(): Promise { - return new Promise((resolve) => { - stack.push('one') - setTimeout(resolve, 100) - }) - } - - function beforeSaveOne() { - stack.push('two') - } - - hooks.add('before', 'save', beforeSave) - hooks.add('before', 'save', beforeSaveOne) - - await hooks.exec('before', 'save', 'foo') - assert.deepEqual(stack, ['one', 'two']) - }) - - test('pass one or more arguments to the hook handler', async (assert) => { - const stack: string[] = [] - const hooks = new Hooks() - - function beforeSave(arg1, arg2): Promise { - return new Promise((resolve) => { - stack.push(arg1) - stack.push(arg2) - setTimeout(resolve, 100) - }) - } - - function beforeSaveOne(arg1, arg2) { - stack.push(arg1) - stack.push(arg2) - } - - hooks.add('before', 'save', beforeSave) - hooks.add('before', 'save', beforeSaveOne) - - await hooks.exec('before', 'save', 'foo', 'bar') - assert.deepEqual(stack, ['foo', 'bar', 'foo', 'bar']) - }) - - test('pass array to hook handler', async (assert) => { - let stack: string[] = [] - const hooks = new Hooks() - - function beforeSave(arg1): Promise { - return new Promise((resolve) => { - stack = stack.concat(arg1) - setTimeout(resolve, 100) - }) - } - - function beforeSaveOne(arg1) { - stack = stack.concat(arg1) - } - - hooks.add('before', 'save', beforeSave) - hooks.add('before', 'save', beforeSaveOne) - - await hooks.exec('before', 'save', ['foo', 'bar']) - assert.deepEqual(stack, ['foo', 'bar', 'foo', 'bar']) - }) - - test('remove hook handler by reference', async (assert) => { - let stack: string[] = [] - const hooks = new Hooks() - - function beforeSave(arg1): Promise { - return new Promise((resolve) => { - stack = stack.concat(arg1) - setTimeout(resolve, 100) - }) - } - - function beforeSaveOne(arg1) { - stack = stack.concat(arg1) - } - - hooks.add('before', 'save', beforeSave) - hooks.add('before', 'save', beforeSaveOne) - hooks.remove('before', 'save', beforeSaveOne) - - await hooks.exec('before', 'save', ['foo', 'bar']) - assert.deepEqual(stack, ['foo', 'bar']) - }) - - test('clear all hooks for a given action', async (assert) => { - let stack: string[] = [] - const hooks = new Hooks() - - function beforeSave(arg1): Promise { - return new Promise((resolve) => { - stack = stack.concat(arg1) - setTimeout(resolve, 100) - }) - } - - function beforeSaveOne(arg1) { - stack = stack.concat(arg1) - } - - hooks.add('before', 'save', beforeSave) - hooks.add('before', 'save', beforeSaveOne) - hooks.clear('before', 'save') - - await hooks.exec('before', 'save', ['foo', 'bar']) - assert.deepEqual(stack, []) - }) - - test('clear all hooks for a given event', async (assert) => { - let stack: string[] = [] - const hooks = new Hooks() - - function beforeSave(arg1): Promise { - return new Promise((resolve) => { - stack = stack.concat(arg1) - setTimeout(resolve, 100) - }) - } - - function beforeSaveOne(arg1) { - stack = stack.concat(arg1) - } - - hooks.add('before', 'save', beforeSave) - hooks.add('before', 'save', beforeSaveOne) - hooks.clear('before') - - await hooks.exec('before', 'save', ['foo', 'bar']) - assert.deepEqual(stack, []) - }) - - test('find if a hook already exists', async (assert) => { - const stack: string[] = [] - const hooks = new Hooks() - - function beforeSave(): Promise { - return new Promise((resolve) => { - stack.push('one') - setTimeout(resolve, 100) - }) - } - - hooks.add('before', 'save', beforeSave) - assert.isTrue(hooks.has('before', 'save', beforeSave)) - }) - - test('merge hooks from one hooks instance', (assert) => { - const hooks = new Hooks() - - function beforeSave() {} - hooks.add('before', 'save', beforeSave) - - const hooks1 = new Hooks() - hooks1.merge(hooks) - - assert.deepEqual(hooks1['hooks'].before, new Map([['save', new Set([beforeSave])]])) - assert.deepEqual(hooks1['hooks'].after, new Map()) - }) - - test('merge hooks over existing hooks', (assert) => { - const hooks = new Hooks() - - function beforeSave() {} - hooks.add('before', 'save', beforeSave) - - const hooks1 = new Hooks() - - function beforeCreate() {} - hooks1.add('before', 'create', beforeCreate) - hooks1.merge(hooks) - - assert.deepEqual( - hooks1['hooks'].before, - new Map([ - ['save', new Set([beforeSave])], - ['create', new Set([beforeCreate])], - ]) - ) - - assert.deepEqual(hooks['hooks'].before, new Map([['save', new Set([beforeSave])]])) - - assert.deepEqual(hooks1['hooks'].after, new Map()) - assert.deepEqual(hooks['hooks'].after, new Map()) - }) -}) - -test.group('Hooks | Ioc Resolver', () => { - test('raise error when passing string as a reference with ioc resolver', (assert) => { - const hooks = new Hooks() - - const fn = () => hooks.add('before', 'save', 'User.beforeSave') - assert.throw(fn, 'IoC container resolver is required to register string based hooks handlers') - }) - - test('register ioc container references as hooks', async (assert) => { - const stack: string[] = [] - const app = new Application(__dirname, 'web', {}) - - app.container.bind('User', () => { - return { - save() { - stack.push(String(stack.length + 1)) - }, - } - }) - - const hooks = new Hooks(app.container.getResolver()) - - hooks.add('before', 'save', 'User.save') - await hooks.exec('before', 'save', 'foo') - assert.deepEqual(stack, ['1']) - }) - - test('pass one or more arguments to the hook handler', async (assert) => { - const stack: string[] = [] - - const app = new Application(__dirname, 'web', {}) - app.container.bind('User', () => { - return { - save(arg1, arg2) { - stack.push(arg1) - stack.push(arg2) - }, - } - }) - - const hooks = new Hooks(app.container.getResolver()) - hooks.add('before', 'save', 'User.save') - - await hooks.exec('before', 'save', 'foo', 'bar') - assert.deepEqual(stack, ['foo', 'bar']) - }) - - test('pass array to hook handler', async (assert) => { - let stack: string[] = [] - - const app = new Application(__dirname, 'web', {}) - app.container.bind('User', () => { - return { - save(arg1) { - stack = stack.concat(arg1) - }, - } - }) - - const hooks = new Hooks(app.container.getResolver()) - hooks.add('before', 'save', 'User.save') - - await hooks.exec('before', 'save', ['foo', 'bar']) - assert.deepEqual(stack, ['foo', 'bar']) - }) - - test('remove hook handler by reference', async (assert) => { - let stack: string[] = [] - const app = new Application(__dirname, 'web', {}) - - app.container.bind('User', () => { - return { - save(arg1) { - stack = stack.concat(arg1) - }, - } - }) - - const hooks = new Hooks(app.container.getResolver()) - hooks.add('before', 'save', 'User.save') - hooks.remove('before', 'save', 'User.save') - - await hooks.exec('before', 'save', ['foo', 'bar']) - assert.deepEqual(stack, []) - }) - - test('find if ioc container reference hook already exists', async (assert) => { - const stack: string[] = [] - - const app = new Application(__dirname, 'web', {}) - app.container.bind('User', () => { - return { - save() { - stack.push(String(stack.length + 1)) - }, - } - }) - - const hooks = new Hooks(app.container.getResolver()) - - hooks.add('before', 'save', 'User.save') - assert.isTrue(hooks.has('before', 'save', 'User.save')) - }) - - test('merge hooks from one hooks instance', async (assert) => { - const stack: string[] = [] - - const app = new Application(__dirname, 'web', {}) - app.container.bind('User', () => { - return { - save() { - stack.push(String(stack.length + 1)) - }, - } - }) - - const hooks = new Hooks(app.container.getResolver()) - hooks.add('before', 'save', 'User.save') - - const hooks1 = new Hooks(app.container.getResolver()) - hooks1.merge(hooks) - - await hooks1.exec('before', 'save', 'foo') - await hooks.exec('before', 'save', 'foo') - assert.deepEqual(stack, ['1', '2']) - }) - - test('merge hooks over existing hooks', async (assert) => { - const stack: string[] = [] - - const app = new Application(__dirname, 'web', {}) - app.container.bind('User', () => { - return { - save() { - stack.push(String(stack.length + 1)) - }, - } - }) - - const hooks = new Hooks(app.container.getResolver()) - hooks.add('before', 'save', 'User.save') - - const hooks1 = new Hooks(app.container.getResolver()) - hooks1.add('before', 'create', 'User.save') - hooks1.merge(hooks) - - await hooks1.exec('before', 'save', 'foo') - await hooks1.exec('before', 'create', 'foo') - - await hooks.exec('before', 'save', 'foo') - await hooks.exec('before', 'create', 'foo') - assert.deepEqual(stack, ['1', '2', '3']) - }) -}) diff --git a/tests/hooks.spec.ts b/tests/hooks.spec.ts new file mode 100644 index 0000000..9c765ca --- /dev/null +++ b/tests/hooks.spec.ts @@ -0,0 +1,198 @@ +/* + * @poppinss/hooks + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Hooks } from '../src/hooks.js' + +test.group('Hooks', () => { + test('add hook for a given event', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.add('save', beforeSave) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave])]])) + assert.isTrue(hooks.has('save', beforeSave)) + }) + + test('add hook as an object with handle method', ({ assert }) => { + const hooks = new Hooks() + + const beforeSave = { + name: 'beforeSave', + handle() {}, + } + hooks.add('save', beforeSave) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave])]])) + assert.isTrue(hooks.has('save', beforeSave)) + }) + + test('add multiple hooks for a given event', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.add('save', beforeSave) + + function beforeSave1() {} + hooks.add('save', beforeSave1) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave, beforeSave1])]])) + assert.isTrue(hooks.has('save', beforeSave)) + assert.isTrue(hooks.has('save', beforeSave1)) + }) + + test('attempt to remove hook handler without registering it', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.remove('save', beforeSave) + assert.deepEqual(hooks.all(), new Map([])) + }) + + test('remove a specific hook handler', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.add('save', beforeSave) + + function beforeSave1() {} + hooks.add('save', beforeSave1) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave, beforeSave1])]])) + assert.isTrue(hooks.has('save', beforeSave)) + assert.isTrue(hooks.has('save', beforeSave1)) + + hooks.remove('save', beforeSave) + + assert.isFalse(hooks.has('save', beforeSave)) + assert.isTrue(hooks.has('save', beforeSave1)) + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave1])]])) + }) + + test('remove object based hooks', ({ assert }) => { + const hooks = new Hooks() + + const beforeSave = { + name: 'beforeSave', + handle() {}, + } + hooks.add('save', beforeSave) + + const beforeSave1 = { + name: 'beforeSave1', + handle() {}, + } + hooks.add('save', beforeSave1) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave, beforeSave1])]])) + assert.isTrue(hooks.has('save', beforeSave)) + assert.isTrue(hooks.has('save', beforeSave1)) + + hooks.remove('save', beforeSave) + + assert.isFalse(hooks.has('save', beforeSave)) + assert.isTrue(hooks.has('save', beforeSave1)) + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave1])]])) + }) + + test('clear hook handlers for a specific event', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.add('save', beforeSave) + + function beforeSave1() {} + hooks.add('save', beforeSave1) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave, beforeSave1])]])) + assert.isTrue(hooks.has('save', beforeSave)) + assert.isTrue(hooks.has('save', beforeSave1)) + + hooks.clear('save') + + assert.isFalse(hooks.has('save', beforeSave)) + assert.isFalse(hooks.has('save', beforeSave1)) + assert.deepEqual(hooks.all(), new Map([])) + }) + + test('clear hook handlers for all events', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.add('save', beforeSave) + + function beforeCreate() {} + hooks.add('create', beforeCreate) + + assert.deepEqual( + hooks.all(), + new Map([ + ['save', new Set([beforeSave])], + ['create', new Set([beforeCreate])], + ]) + ) + + assert.isTrue(hooks.has('save', beforeSave)) + assert.isTrue(hooks.has('create', beforeCreate)) + + hooks.clear() + + assert.isFalse(hooks.has('save', beforeSave)) + assert.isFalse(hooks.has('create', beforeCreate)) + assert.deepEqual(hooks.all(), new Map([])) + }) + + test('merge hooks from one hooks instance', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.add('save', beforeSave) + + const hooks1 = new Hooks() + hooks1.merge(hooks) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave])]])) + assert.deepEqual(hooks1.all(), new Map([['save', new Set([beforeSave])]])) + }) + + test('merge hooks over existing hooks', ({ assert }) => { + const hooks = new Hooks() + + function beforeSave() {} + hooks.add('save', beforeSave) + + const hooks1 = new Hooks() + + function beforeCreate() {} + hooks1.add('create', beforeCreate) + hooks1.merge(hooks) + + assert.deepEqual(hooks.all(), new Map([['save', new Set([beforeSave])]])) + assert.deepEqual( + hooks1.all(), + new Map([ + ['create', new Set([beforeCreate])], + ['save', new Set([beforeSave])], + ]) + ) + }) + + test('assert hook handler types', () => { + const hooks = new Hooks<{ + save: [[string, number], []] + }>() + + // @ts-expect-error + hooks.add('save', (_: string, __: string) => {}) + + // @ts-expect-error + hooks.add('create', () => {}) + }) +}) diff --git a/tests/runner.spec.ts b/tests/runner.spec.ts new file mode 100644 index 0000000..bb74af4 --- /dev/null +++ b/tests/runner.spec.ts @@ -0,0 +1,265 @@ +/* + * @poppinss/hooks + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Hooks } from '../src/hooks.js' + +test.group('Runner', () => { + test('execute hooks handlers', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.run() + + assert.deepEqual(stack, ['before save', 'before save 1']) + }) + + test('ensure runner.run is idempotent', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.run() + await runner.run() + await runner.run() + await runner.run() + + assert.deepEqual(stack, ['before save', 'before save 1']) + }) + + test('execute async hooks in sequence', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + return new Promise((resolve) => { + setTimeout(() => { + stack.push('before save') + resolve() + }, 100) + }) + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.run() + + assert.deepEqual(stack, ['before save', 'before save 1']) + }) + + test('execute arrow functions defined as hook handlers', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + hooks.add('save', () => { + stack.push('before save') + }) + hooks.add('save', () => { + stack.push('before save 1') + }) + + const runner = hooks.runner('save') + await runner.run() + + assert.deepEqual(stack, ['before save', 'before save 1']) + }) + + test('pass data to hook handlers', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + hooks.add('save', (message: string) => { + stack.push(message) + }) + hooks.add('save', (message: string) => { + stack.push(message) + }) + + const runner = hooks.runner('save') + await runner.run('hello world') + + assert.deepEqual(stack, ['hello world', 'hello world']) + }) + + test('pass multiple arguments to hook handlers', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + hooks.add('save', (message: string, message1: string) => { + stack.push(message) + stack.push(message1) + }) + hooks.add('save', (message: string, message1: string) => { + stack.push(message) + stack.push(message1) + }) + + const runner = hooks.runner('save') + await runner.run('hi world', 'hello world') + + assert.deepEqual(stack, ['hi world', 'hello world', 'hi world', 'hello world']) + }) + + test('filter hooks by name', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.without(['beforeSave1']).run() + + assert.deepEqual(stack, ['before save']) + }) + + test('skip all hooks', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.without().run() + + assert.deepEqual(stack, []) + }) + + test('run object based hooks', async ({ assert }) => { + const hooks = new Hooks() + let stack: string[] = [] + + function beforeSave(...args: string[]) { + stack = stack.concat(args) + } + hooks.add('save', { + name: 'beforeSave', + handle(_, ...args: string[]) { + return beforeSave(...args.concat(['via executor'])) + }, + }) + + function beforeSave1(...args: string[]) { + stack = stack.concat(args) + } + hooks.add('save', { + name: 'beforeSave1', + handle(_, ...args: string[]) { + return beforeSave1(...args.concat(['via executor'])) + }, + }) + + const runner = hooks.runner('save') + await runner.run('before save') + + assert.deepEqual(stack, ['before save', 'via executor', 'before save', 'via executor']) + }) + + test('filter hooks by explicit name', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + const beforeSave = { + name: 'models.beforeSave', + handle() { + stack.push('before save') + }, + } + hooks.add('save', beforeSave) + + const beforeSave1 = { + name: 'models.beforeSave1', + handle() { + stack.push('before save 1') + }, + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.without(['models.beforeSave1']).run() + + assert.deepEqual(stack, ['before save']) + }) + + test('work fine when there are no hook handlers', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + const runner = hooks.runner('save') + await runner.run() + + assert.deepEqual(stack, []) + }) + + test('execute async hooks in reverse order', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + return new Promise((resolve) => { + setTimeout(() => { + stack.push('before save') + resolve() + }, 100) + }) + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.runReverse() + + assert.deepEqual(stack, ['before save 1', 'before save']) + }) +}) diff --git a/tests/runner_cleanup.spec.ts b/tests/runner_cleanup.spec.ts new file mode 100644 index 0000000..812b614 --- /dev/null +++ b/tests/runner_cleanup.spec.ts @@ -0,0 +1,131 @@ +/* + * @poppinss/hooks + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Hooks } from '../src/hooks.js' + +test.group('Runner Cleanup', () => { + test('call cleanup functions in reverse', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + return () => { + stack.push('cleanup save') + } + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + return () => { + stack.push('cleanup save 1') + } + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.run() + assert.isTrue(runner.isCleanupPending) + + await runner.cleanup() + + assert.isFalse(runner.isCleanupPending) + assert.deepEqual(stack, ['before save', 'before save 1', 'cleanup save 1', 'cleanup save']) + }) + + test('call cleanup functions during error', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + return () => { + stack.push('cleanup save') + } + } + hooks.add('save', beforeSave) + + function beforeSave1() { + throw new Error('Failed') + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await assert.rejects(() => runner.run()) + assert.isTrue(runner.isCleanupPending) + await runner.cleanup() + + assert.isFalse(runner.isCleanupPending) + assert.deepEqual(stack, ['before save', 'cleanup save']) + }) + + test('ensure cleanup is idempotent', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + return () => { + stack.push('cleanup save') + } + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + return () => { + stack.push('cleanup save 1') + } + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.run() + assert.isTrue(runner.isCleanupPending) + + await runner.cleanup() + await runner.cleanup() + await runner.cleanup() + + assert.isFalse(runner.isCleanupPending) + assert.deepEqual(stack, ['before save', 'before save 1', 'cleanup save 1', 'cleanup save']) + }) + + test('pass data to cleanup handlers', async ({ assert }) => { + const hooks = new Hooks() + const stack: string[] = [] + + function beforeSave() { + stack.push('before save') + return (message: string) => { + stack.push(message) + } + } + hooks.add('save', beforeSave) + + function beforeSave1() { + stack.push('before save 1') + return (message: string) => { + stack.push(message) + } + } + hooks.add('save', beforeSave1) + + const runner = hooks.runner('save') + await runner.run() + assert.isTrue(runner.isCleanupPending) + + await runner.cleanup('cleanup') + + assert.isFalse(runner.isCleanupPending) + assert.deepEqual(stack, ['before save', 'before save 1', 'cleanup', 'cleanup']) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 7a759c9..2039043 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { - "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", - "files": [ - "./node_modules/@adonisjs/application/build/adonis-typings/index.d.ts" - ] -} + "extends": "@adonisjs/tsconfig/tsconfig.package.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "./build" + } +} \ No newline at end of file