├── .github ├── SECURITY.md └── workflows │ ├── release.yml │ ├── test.yml │ └── update-prettier.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── examples ├── authentication-example.md ├── store-example.md └── test-mock-example.md ├── index.d.ts ├── index.js ├── index.test-d.ts ├── jsconfig.json ├── lib ├── add.js ├── register.js └── remove.js ├── package-lock.json ├── package.json └── test ├── after.test.js ├── before.test.js ├── constructor.test.js ├── error.test.js ├── hook.test.js ├── remove.test.js ├── singular-hook.test.js ├── testrunner.js ├── unit-register.test.js ├── unit-remove.test.js └── wrap.test.js /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | "on": 3 | push: 4 | branches: 5 | - "*.x" 6 | - main 7 | - next 8 | - beta 9 | jobs: 10 | release: 11 | name: release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: lts/* 18 | cache: npm 19 | - run: npm ci 20 | - run: npx semantic-release 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | test-deno: 11 | runs-on: ubuntu-latest 12 | needs: test-node 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: denoland/setup-deno@v2 16 | with: 17 | deno-version: v2.x # Run with latest stable Deno. 18 | - run: deno install 19 | - run: deno test --no-check 20 | 21 | test-bun: 22 | runs-on: ubuntu-latest 23 | needs: test-node 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: oven-sh/setup-bun@v2 27 | - run: bun install 28 | - run: bun test 29 | 30 | test-node: 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | node_version: 35 | - 20 36 | - 22 37 | - 24 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | - name: Use Node.js ${{ matrix.node_version }} 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: ${{ matrix.node_version }} 45 | cache: npm 46 | - run: npm ci 47 | - run: npm run test:code 48 | 49 | test: 50 | runs-on: ubuntu-latest 51 | needs: 52 | - test-bun 53 | - test-deno 54 | - test-node 55 | # prevent to be taken as pass when `test_matrix` was not picked up yet 56 | if: ${{ always() }} 57 | steps: 58 | - run: exit 1 59 | if: ${{ needs.test-bun.result != 'success' || needs.test-deno.result != 'success' || needs.test-node.result != 'success' }} 60 | - uses: actions/checkout@v3 61 | - uses: actions/setup-node@v3 62 | with: 63 | node-version: 24 64 | cache: npm 65 | - run: npm ci 66 | - run: npm run test:tsc 67 | - run: npm run test:tsd 68 | - run: npm run lint 69 | -------------------------------------------------------------------------------- /.github/workflows/update-prettier.yml: -------------------------------------------------------------------------------- 1 | name: Update Prettier 2 | "on": 3 | push: 4 | branches: 5 | - renovate/prettier-* 6 | workflow_dispatch: {} 7 | jobs: 8 | update_prettier: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | cache: npm 15 | node-version: lts/* 16 | - run: npm ci 17 | - run: npm run lint:fix 18 | - run: | 19 | git config user.name github-actions 20 | git config user.email github-actions@github.com 21 | git add . 22 | git commit -m "style: prettier" && git push || true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource+coc@martynus.net. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Gregor Martynus and other contributors. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # before-after-hook 2 | 3 | > asynchronous hooks for internal functionality 4 | 5 | [![npm downloads](https://img.shields.io/npm/dw/before-after-hook.svg)](https://www.npmjs.com/package/before-after-hook) 6 | [![Test](https://github.com/gr2m/before-after-hook/actions/workflows/test.yml/badge.svg)](https://github.com/gr2m/before-after-hook/actions/workflows/test.yml) 7 | 8 | ## Usage 9 | 10 | 11 | 12 | 24 | 35 | 36 |
13 | Browsers 14 | 15 | Load before-after-hook directly from cdn.skypack.dev 16 | 17 | ```html 18 | 21 | ``` 22 | 23 |
25 | Node 26 | 27 | 28 | Install with npm install before-after-hook 29 | 30 | ```js 31 | import Hook from "before-after-hook"; 32 | ``` 33 | 34 |
37 | 38 | ### Singular hook 39 | 40 | ```js 41 | // instantiate singular hook API 42 | const hook = new Hook.Singular(); 43 | 44 | // Create a hook 45 | async function getData(options) { 46 | try { 47 | const result = await hook(fetchFromDatabase, options); 48 | return handleData(result); 49 | } catch (error) { 50 | return handleGetError(error); 51 | } 52 | } 53 | 54 | // register before/error/after hooks. 55 | // The methods can be async or return a promise 56 | hook.before(beforeHook); 57 | hook.error(errorHook); 58 | hook.after(afterHook); 59 | 60 | getData({ id: 123 }); 61 | ``` 62 | 63 | ### Hook collection 64 | 65 | ```js 66 | // instantiate hook collection API 67 | const hookCollection = new Hook.Collection(); 68 | 69 | // Create a hook 70 | async function getData(options) { 71 | try { 72 | const result = await hookCollection("get", fetchFromDatabase, options); 73 | return handleData(result); 74 | } catch (error) { 75 | return handleGetError(error); 76 | } 77 | } 78 | 79 | // register before/error/after hooks. 80 | // The methods can be async or return a promise 81 | hookCollection.before("get", beforeHook); 82 | hookCollection.error("get", errorHook); 83 | hookCollection.after("get", afterHook); 84 | 85 | getData({ id: 123 }); 86 | ``` 87 | 88 | ### Hook.Singular vs Hook.Collection 89 | 90 | There's no fundamental difference between the `Hook.Singular` and `Hook.Collection` hooks except for the fact that a hook from a collection requires you to pass along the name. Therefore the following explanation applies to both code snippets as described above. 91 | 92 | The methods are executed in the following order 93 | 94 | 1. `beforeHook` 95 | 2. `fetchFromDatabase` 96 | 3. `afterHook` 97 | 4. `handleData` 98 | 99 | `beforeHook` can mutate `options` before it’s passed to `fetchFromDatabase`. 100 | 101 | If an error is thrown in `beforeHook` or `fetchFromDatabase` then `errorHook` is 102 | called next. 103 | 104 | If `afterHook` throws an error then `handleGetError` is called instead 105 | of `handleData`. 106 | 107 | If `errorHook` throws an error then `handleGetError` is called next, otherwise 108 | `afterHook` and `handleData`. 109 | 110 | You can also use `hook.wrap` to achieve the same thing as shown above (collection example): 111 | 112 | ```js 113 | hookCollection.wrap("get", async (getData, options) => { 114 | await beforeHook(options); 115 | 116 | try { 117 | const result = getData(options); 118 | } catch (error) { 119 | await errorHook(error, options); 120 | } 121 | 122 | await afterHook(result, options); 123 | }); 124 | ``` 125 | 126 | ## API 127 | 128 | - [Singular Hook Constructor](#singular-hook-api) 129 | - [Hook Collection Constructor](#hook-collection-api) 130 | 131 | ## Singular hook API 132 | 133 | - [Singular constructor](#singular-constructor) 134 | - [hook.api](#singular-api) 135 | - [hook()](#singular-api) 136 | - [hook.before()](#singular-api) 137 | - [hook.error()](#singular-api) 138 | - [hook.after()](#singular-api) 139 | - [hook.wrap()](#singular-api) 140 | - [hook.remove()](#singular-api) 141 | 142 | ### Singular constructor 143 | 144 | The `Hook.Singular` constructor has no options and returns a `hook` instance with the 145 | methods below: 146 | 147 | ```js 148 | const hook = new Hook.Singular(); 149 | ``` 150 | 151 | Using the singular hook is recommended for [TypeScript](#typescript) 152 | 153 | ### Singular API 154 | 155 | The singular hook is a reference to a single hook. This means that there's no need to pass along any identifier (such as a `name` as can be seen in the [Hook.Collection API](#hookcollectionapi)). 156 | 157 | The API of a singular hook is exactly the same as a collection hook and we therefore suggest you read the [Hook.Collection API](#hookcollectionapi) and leave out any use of the `name` argument. Just skip it like described in this example: 158 | 159 | ```js 160 | const hook = new Hook.Singular(); 161 | 162 | // good 163 | hook.before(beforeHook); 164 | hook.after(afterHook); 165 | hook(fetchFromDatabase, options); 166 | 167 | // bad 168 | hook.before("get", beforeHook); 169 | hook.after("get", afterHook); 170 | hook("get", fetchFromDatabase, options); 171 | ``` 172 | 173 | ## Hook collection API 174 | 175 | - [Collection constructor](#collection-constructor) 176 | - [hookCollection.api](#hookcollectionapi) 177 | - [hookCollection()](#hookcollection) 178 | - [hookCollection.before()](#hookcollectionbefore) 179 | - [hookCollection.error()](#hookcollectionerror) 180 | - [hookCollection.after()](#hookcollectionafter) 181 | - [hookCollection.wrap()](#hookcollectionwrap) 182 | - [hookCollection.remove()](#hookcollectionremove) 183 | 184 | ### Collection constructor 185 | 186 | The `Hook.Collection` constructor has no options and returns a `hookCollection` instance with the 187 | methods below 188 | 189 | ```js 190 | const hookCollection = new Hook.Collection(); 191 | ``` 192 | 193 | ### hookCollection.api 194 | 195 | Use the `api` property to return the public API: 196 | 197 | - [hookCollection.before()](#hookcollectionbefore) 198 | - [hookCollection.after()](#hookcollectionafter) 199 | - [hookCollection.error()](#hookcollectionerror) 200 | - [hookCollection.wrap()](#hookcollectionwrap) 201 | - [hookCollection.remove()](#hookcollectionremove) 202 | 203 | That way you don’t need to expose the [hookCollection()](#hookcollection) method to consumers of your library 204 | 205 | ### hookCollection() 206 | 207 | Invoke before and after hooks. Returns a promise. 208 | 209 | ```js 210 | hookCollection(nameOrNames, method /*, options */); 211 | ``` 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 |
ArgumentTypeDescriptionRequired
nameString or Array of StringsHook name, for example 'save'. Or an array of names, see example below.Yes
methodFunctionCallback to be executed after all before hooks finished execution successfully. options is passed as first argumentYes
optionsObjectWill be passed to all before hooks as reference, so they can mutate itNo, defaults to empty object ({})
241 | 242 | Resolves with whatever `method` returns or resolves with. 243 | Rejects with error that is thrown or rejected with by 244 | 245 | 1. Any of the before hooks, whichever rejects / throws first 246 | 2. `method` 247 | 3. Any of the after hooks, whichever rejects / throws first 248 | 249 | Simple Example 250 | 251 | ```js 252 | hookCollection( 253 | "save", 254 | (record) => { 255 | return store.save(record); 256 | }, 257 | record 258 | ); 259 | // shorter: hookCollection('save', store.save, record) 260 | 261 | hookCollection.before("save", function addTimestamps(record) { 262 | const now = new Date().toISOString(); 263 | if (record.createdAt) { 264 | record.updatedAt = now; 265 | } else { 266 | record.createdAt = now; 267 | } 268 | }); 269 | ``` 270 | 271 | Example defining multiple hooks at once. 272 | 273 | ```js 274 | hookCollection( 275 | ["add", "save"], 276 | (record) => { 277 | return store.save(record); 278 | }, 279 | record 280 | ); 281 | 282 | hookCollection.before("add", function addTimestamps(record) { 283 | if (!record.type) { 284 | throw new Error("type property is required"); 285 | } 286 | }); 287 | 288 | hookCollection.before("save", function addTimestamps(record) { 289 | if (!record.type) { 290 | throw new Error("type property is required"); 291 | } 292 | }); 293 | ``` 294 | 295 | Defining multiple hooks is helpful if you have similar methods for which you want to define separate hooks, but also an additional hook that gets called for all at once. The example above is equal to this: 296 | 297 | ```js 298 | hookCollection( 299 | "add", 300 | (record) => { 301 | return hookCollection( 302 | "save", 303 | (record) => { 304 | return store.save(record); 305 | }, 306 | record 307 | ); 308 | }, 309 | record 310 | ); 311 | ``` 312 | 313 | ### hookCollection.before() 314 | 315 | Add before hook for given name. 316 | 317 | ```js 318 | hookCollection.before(name, method); 319 | ``` 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 344 | 345 | 346 |
ArgumentTypeDescriptionRequired
nameStringHook name, for example 'save'Yes
methodFunction 340 | Executed before the wrapped method. Called with the hook’s 341 | options argument. Before hooks can mutate the passed options 342 | before they are passed to the wrapped method. 343 | Yes
347 | 348 | Example 349 | 350 | ```js 351 | hookCollection.before("save", function validate(record) { 352 | if (!record.name) { 353 | throw new Error("name property is required"); 354 | } 355 | }); 356 | ``` 357 | 358 | ### hookCollection.error() 359 | 360 | Add error hook for given name. 361 | 362 | ```js 363 | hookCollection.error(name, method); 364 | ``` 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 391 | 392 | 393 |
ArgumentTypeDescriptionRequired
nameStringHook name, for example 'save'Yes
methodFunction 385 | Executed when an error occurred in either the wrapped method or a 386 | before hook. Called with the thrown error 387 | and the hook’s options argument. The first method 388 | which does not throw an error will set the result that the after hook 389 | methods will receive. 390 | Yes
394 | 395 | Example 396 | 397 | ```js 398 | hookCollection.error("save", (error, options) => { 399 | if (error.ignore) return; 400 | throw error; 401 | }); 402 | ``` 403 | 404 | ### hookCollection.after() 405 | 406 | Add after hook for given name. 407 | 408 | ```js 409 | hookCollection.after(name, method); 410 | ``` 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 434 | 435 | 436 |
ArgumentTypeDescriptionRequired
nameStringHook name, for example 'save'Yes
methodFunction 431 | Executed after wrapped method. Called with what the wrapped method 432 | resolves with the hook’s options argument. 433 | Yes
437 | 438 | Example 439 | 440 | ```js 441 | hookCollection.after("save", (result, options) => { 442 | if (result.updatedAt) { 443 | app.emit("update", result); 444 | } else { 445 | app.emit("create", result); 446 | } 447 | }); 448 | ``` 449 | 450 | ### hookCollection.wrap() 451 | 452 | Add wrap hook for given name. 453 | 454 | ```js 455 | hookCollection.wrap(name, method); 456 | ``` 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 479 | 480 | 481 |
ArgumentTypeDescriptionRequired
nameStringHook name, for example 'save'Yes
methodFunction 477 | Receives both the wrapped method and the passed options as arguments so it can add logic before and after the wrapped method, it can handle errors and even replace the wrapped method altogether 478 | Yes
482 | 483 | Example 484 | 485 | ```js 486 | hookCollection.wrap("save", async (saveInDatabase, options) => { 487 | if (!record.name) { 488 | throw new Error("name property is required"); 489 | } 490 | 491 | try { 492 | const result = await saveInDatabase(options); 493 | 494 | if (result.updatedAt) { 495 | app.emit("update", result); 496 | } else { 497 | app.emit("create", result); 498 | } 499 | 500 | return result; 501 | } catch (error) { 502 | if (error.ignore) return; 503 | throw error; 504 | } 505 | }); 506 | ``` 507 | 508 | See also: [Test mock example](examples/test-mock-example.md) 509 | 510 | ### hookCollection.remove() 511 | 512 | Removes hook for given name. 513 | 514 | ```js 515 | hookCollection.remove(name, hookMethod); 516 | ``` 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 539 | 540 | 541 |
ArgumentTypeDescriptionRequired
nameStringHook name, for example 'save'Yes
beforeHookMethodFunction 537 | Same function that was previously passed to hookCollection.before(), hookCollection.error(), hookCollection.after() or hookCollection.wrap() 538 | Yes
542 | 543 | Example 544 | 545 | ```js 546 | hookCollection.remove("save", validateRecord); 547 | ``` 548 | 549 | ## TypeScript 550 | 551 | This library contains type definitions for TypeScript. 552 | 553 | ### Type support for `Singular`: 554 | 555 | ```ts 556 | import Hook from "before-after-hook"; 557 | 558 | type TOptions = { foo: string }; // type for options 559 | type TResult = { bar: number }; // type for result 560 | type TError = Error; // type for error 561 | 562 | const hook = new Hook.Singular(); 563 | 564 | hook.before((options) => { 565 | // `options.foo` has `string` type 566 | 567 | // not allowed 568 | options.foo = 42; 569 | 570 | // allowed 571 | options.foo = "Forty-Two"; 572 | }); 573 | 574 | const hookedMethod = hook( 575 | (options) => { 576 | // `options.foo` has `string` type 577 | 578 | // not allowed, because it does not satisfy the `R` type 579 | return { foo: 42 }; 580 | 581 | // allowed 582 | return { bar: 42 }; 583 | }, 584 | { foo: "Forty-Two" } 585 | ); 586 | ``` 587 | 588 | You can choose not to pass the types for options, result or error. So, these are completely valid: 589 | 590 | ```ts 591 | const hook = new Hook.Singular(); 592 | const hook = new Hook.Singular(); 593 | const hook = new Hook.Singular(); 594 | ``` 595 | 596 | In these cases, the omitted types will implicitly be `any`. 597 | 598 | ### Type support for `Collection`: 599 | 600 | `Collection` also has strict type support. You can use it like this: 601 | 602 | ```ts 603 | import { Hook } from "before-after-hook"; 604 | 605 | type HooksType = { 606 | add: { 607 | Options: { type: string }; 608 | Result: { id: number }; 609 | Error: Error; 610 | }; 611 | save: { 612 | Options: { type: string }; 613 | Result: { id: number }; 614 | }; 615 | read: { 616 | Options: { id: number; foo: number }; 617 | }; 618 | destroy: { 619 | Options: { id: number; foo: string }; 620 | }; 621 | }; 622 | 623 | const hooks = new Hook.Collection(); 624 | 625 | hooks.before("destroy", (options) => { 626 | // `options.id` has `number` type 627 | }); 628 | 629 | hooks.error("add", (err, options) => { 630 | // `options.type` has `string` type 631 | // `err` is `instanceof Error` 632 | }); 633 | 634 | hooks.error("save", (err, options) => { 635 | // `options.type` has `string` type 636 | // `err` has type `any` 637 | }); 638 | 639 | hooks.after("save", (result, options) => { 640 | // `options.type` has `string` type 641 | // `result.id` has `number` type 642 | }); 643 | ``` 644 | 645 | You can choose not to pass the types altogether. In that case, everything will implicitly be `any`: 646 | 647 | ```ts 648 | const hook = new Hook.Collection(); 649 | ``` 650 | 651 | Alternative imports: 652 | 653 | ```ts 654 | import { Singular, Collection } from "before-after-hook"; 655 | 656 | const hook = new Singular(); 657 | const hooks = new Collection(); 658 | ``` 659 | 660 | ## Upgrading to 1.4 661 | 662 | Since version 1.4 the `Hook` constructor has been deprecated in favor of returning `Hook.Singular` in an upcoming breaking release. 663 | 664 | Version 1.4 is still 100% backwards-compatible, but if you want to continue using hook collections, we recommend using the `Hook.Collection` constructor instead before the next release. 665 | 666 | For even more details, check out [the PR](https://github.com/gr2m/before-after-hook/pull/52). 667 | 668 | ## See also 669 | 670 | If `before-after-hook` is not for you, have a look at one of these alternatives: 671 | 672 | - https://github.com/keystonejs/grappling-hook 673 | - https://github.com/sebelga/promised-hooks 674 | - https://github.com/bnoguchi/hooks-js 675 | - https://github.com/cb1kenobi/hook-emitter 676 | 677 | ## License 678 | 679 | [Apache 2.0](LICENSE) 680 | -------------------------------------------------------------------------------- /examples/authentication-example.md: -------------------------------------------------------------------------------- 1 | # Authentication hook examples 2 | 3 | This example shows how an imaginary `authentication` API with methods to manage user accounts and sessions can expose hook APIs to implement 4 | 5 | - username validation 6 | - an email verification flow 7 | 8 | The authentication APIs to create a user account and a session look as follows 9 | 10 | ```js 11 | authentication.accounts.add(properties); 12 | authentication.sessions.add(credentials); 13 | ``` 14 | 15 | The exposed hooks could be 16 | 17 | - `registration` 18 | invoked when a user account gets created 19 | - `login` 20 | invoked when a user tries to sign in 21 | 22 | The implementation of the hooks could look like this: 23 | 24 | ```js 25 | const hook = new Hook.Collection(); 26 | 27 | function createUserAccount (properties) { 28 | return hook('registration', properties, (properties) => ( 29 | return accountDatabase.create(properties) 30 | )) 31 | } 32 | function createSession (account, credentials) { 33 | const options = {account: account, credentials: credentials} 34 | return hook('login', options, (options) => { 35 | if (!validateCredentials(options.account, options.credentials) { 36 | throw new Error('Unauthorized: username or password is incorrect') 37 | }) 38 | 39 | return { 40 | id: generateSessionId(options.account, options.credentials, secret), 41 | account: options.account 42 | } 43 | }) 44 | } 45 | ``` 46 | 47 | ## Validate username 48 | 49 | Say we want to enforce that username must be valid email addresses. 50 | 51 | ```js 52 | hook.before("registration", (properties) => { 53 | if (!isValidEmail(properties.username)) { 54 | throw new Error(properties.username + "is not a valid email address"); 55 | } 56 | 57 | properties.username = properties.username.toLowerCase(); 58 | }); 59 | ``` 60 | 61 | ## Implement email verification flow 62 | 63 | Say we do not want to allow users to sign in to their account until their email was verified. Once verified, we set a `verifiedAt` timestamp. 64 | 65 | ```js 66 | hook.before("login", (options) => { 67 | if (!options.account.verifiedAt) { 68 | throw new Error("You must verify your email address before signing in"); 69 | } 70 | }); 71 | ``` 72 | -------------------------------------------------------------------------------- /examples/store-example.md: -------------------------------------------------------------------------------- 1 | # Store hook examples 2 | 3 | This example shows how an imaginary `store` API with methods to add, update 4 | and remove methods can expose a hook API to intercept all of them. 5 | 6 | We will use the hooks to 7 | 8 | - add validation 9 | - add timestamps and user references 10 | - emit events 11 | 12 | The store APIs to change data look as follows, we ignore methods to find / query 13 | data for the sake of simplicity 14 | 15 | ```js 16 | store.add(document); 17 | store.update(id, changedProperties); 18 | store.remove(id); 19 | ``` 20 | 21 | The exposed hooks could be 22 | 23 | - `add` 24 | invoked when a document gets added 25 | - `update` 26 | invoked when a document gets updated 27 | - `remove` 28 | invoked when a document is removed 29 | - `save` 30 | invoked each time a document gets saved within the hooks listed above. 31 | 32 | The implementation of the hooks could look like this: 33 | 34 | ```js 35 | const hook = new Hook.Collection(); 36 | 37 | function storeAdd(newDoc) { 38 | return hook(["add", "save"], newDoc, (newDoc) => { 39 | return database.create(newDoc); 40 | }); 41 | } 42 | ``` 43 | 44 | This will invoke the following 45 | 46 | 1. `add` before hook 47 | 1. `save` before hook 48 | 1. run `database.create(newDoc)` 49 | 1. `save` after hook 50 | 1. `add` after hook 51 | 52 | ## Validation 53 | 54 | We want to build a todo application which has documents with `type` set to 55 | `item` and `list`. We don’t want to allow any other values for `type` or 56 | documents with a `type` property altogether. We also want to make sure that 57 | each `item` has a `listId` property 58 | 59 | ```js 60 | hook.before("save", (doc) => { 61 | if (!doc.type) { 62 | throw new Error("type property is required"); 63 | } 64 | if (doc.type !== "item" && doc.type !== "list") { 65 | throw new Error( 66 | "Invalid type value: " + doc.type + " (Allowed: item, list)" 67 | ); 68 | } 69 | 70 | if (doc.type === "item" && !doc.listId) { 71 | throw new Error("items need to set listId property"); 72 | } 73 | 74 | return doc; 75 | }); 76 | ``` 77 | 78 | This will prevent the app from saving invalid documents 79 | 80 | ```js 81 | store.add({ foo: "bar" }); 82 | // rejects with Error: type property is required 83 | ``` 84 | 85 | ```js 86 | store.add({ type: "item", listId: "id34567", note: "Remember the milk!" }); 87 | // resolves with value of database.create(newDoc) 88 | ``` 89 | 90 | ## timestamps and user reference 91 | 92 | ```js 93 | hook.before("add", (doc) => { 94 | doc.createdAt = new Date().toISOString(); 95 | doc.createdBy = app.currentUser.id; 96 | return doc; 97 | }); 98 | hook.before("update", (doc) => { 99 | doc.updatedAt = new Date().toISOString(); 100 | doc.updatedBy = app.currentUser.id; 101 | return doc; 102 | }); 103 | ``` 104 | 105 | ## events 106 | 107 | ```js 108 | hook.after("add", (doc) => { 109 | app.emit("data:" + doc.type + ":add", doc); 110 | }); 111 | hook.after("update", (doc) => { 112 | app.emit("data:" + doc.type + ":update", doc); 113 | }); 114 | hook.after("remove", (doc) => { 115 | app.emit("data:" + doc.type + ":remove", doc); 116 | }); 117 | ``` 118 | -------------------------------------------------------------------------------- /examples/test-mock-example.md: -------------------------------------------------------------------------------- 1 | # Test mock example 2 | 3 | This example shows how a REST API client can implement mocking for simple testing. 4 | 5 | Let’s say the client has methods to get/list/create/update/delete todos. 6 | 7 | ```js 8 | client.getTodo(id); 9 | client.listTodos(); 10 | client.createTodo(options); 11 | client.updateTodo(options); 12 | client.deleteTodo(id); 13 | ``` 14 | 15 | The exposed hook is called `request`. 16 | 17 | All methods would use an internal `request` method to which they pass options 18 | such as the http verb, the URL, query parameters and request body. 19 | 20 | The implementation of the hook could look like this: 21 | 22 | ```js 23 | function request (options) { 24 | return hook('request', options, (options) => ( 25 | const {url, ...fetchOptions} = options 26 | return fetch(url, fetchOptions) 27 | )) 28 | } 29 | ``` 30 | 31 | Now when writing a test for "getTodo", the wrap hook allows us to prevent from 32 | an HTTP request to be even sent, instead we test if the options passed `fetch` 33 | are what we expect. 34 | 35 | ```js 36 | test("client.getTodo(123)", () => { 37 | const client = getClient(); 38 | client.hook.wrap("request", (fetch, options) => { 39 | assert.equal(options.method, "GET"); 40 | assert.equal(options.url, "https://api.acme-inc.com/todos/123"); 41 | }); 42 | 43 | client.getTodo(123); 44 | }); 45 | ``` 46 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | type HookMethod = ( 2 | options: Options 3 | ) => Result | Promise; 4 | 5 | type BeforeHook = (options: Options) => void | Promise; 6 | type ErrorHook = ( 7 | error: Error, 8 | options: Options 9 | ) => unknown | Promise; 10 | type AfterHook = ( 11 | result: Result, 12 | options: Options 13 | ) => void | Promise; 14 | type WrapHook = ( 15 | hookMethod: HookMethod, 16 | options: Options 17 | ) => Result | Promise; 18 | 19 | type AnyHook = 20 | | BeforeHook 21 | | ErrorHook 22 | | AfterHook 23 | | WrapHook; 24 | 25 | type TypeStoreKey = "Options" | "Result" | "Error"; 26 | type TypeStore = { [key in TypeStoreKey]?: any }; 27 | type GetType< 28 | TStore extends TypeStore, 29 | TKey extends TypeStoreKey 30 | > = TKey extends keyof TStore ? TStore[TKey] : any; 31 | 32 | export interface HookCollection< 33 | HooksType extends Record = Record< 34 | string, 35 | { Options: any; Result: any; Error: any } 36 | >, 37 | HookName extends keyof HooksType = keyof HooksType 38 | > { 39 | /** 40 | * Invoke before and after hooks 41 | */ 42 | ( 43 | name: Name | Name[], 44 | hookMethod: HookMethod< 45 | GetType, 46 | GetType 47 | >, 48 | options?: GetType 49 | ): Promise>; 50 | /** 51 | * Add `before` hook for given `name` 52 | */ 53 | before( 54 | name: Name, 55 | beforeHook: BeforeHook> 56 | ): void; 57 | /** 58 | * Add `error` hook for given `name` 59 | */ 60 | error( 61 | name: Name, 62 | errorHook: ErrorHook< 63 | GetType, 64 | GetType 65 | > 66 | ): void; 67 | /** 68 | * Add `after` hook for given `name` 69 | */ 70 | after( 71 | name: Name, 72 | afterHook: AfterHook< 73 | GetType, 74 | GetType 75 | > 76 | ): void; 77 | /** 78 | * Add `wrap` hook for given `name` 79 | */ 80 | wrap( 81 | name: Name, 82 | wrapHook: WrapHook< 83 | GetType, 84 | GetType 85 | > 86 | ): void; 87 | /** 88 | * Remove added hook for given `name` 89 | */ 90 | remove( 91 | name: Name, 92 | hook: AnyHook< 93 | GetType, 94 | GetType, 95 | GetType 96 | > 97 | ): void; 98 | /** 99 | * Public API 100 | */ 101 | api: Pick< 102 | HookCollection, 103 | "before" | "error" | "after" | "wrap" | "remove" 104 | >; 105 | } 106 | 107 | export interface HookSingular { 108 | /** 109 | * Invoke before and after hooks 110 | */ 111 | (hookMethod: HookMethod, options?: Options): Promise; 112 | /** 113 | * Add `before` hook 114 | */ 115 | before(beforeHook: BeforeHook): void; 116 | /** 117 | * Add `error` hook 118 | */ 119 | error(errorHook: ErrorHook): void; 120 | /** 121 | * Add `after` hook 122 | */ 123 | after(afterHook: AfterHook): void; 124 | /** 125 | * Add `wrap` hook 126 | */ 127 | wrap(wrapHook: WrapHook): void; 128 | /** 129 | * Remove added hook 130 | */ 131 | remove(hook: AnyHook): void; 132 | /** 133 | * Public API 134 | */ 135 | api: Pick< 136 | HookSingular, 137 | "before" | "error" | "after" | "wrap" | "remove" 138 | >; 139 | } 140 | 141 | type Collection = new < 142 | HooksType extends Record = Record 143 | >() => HookCollection; 144 | type Singular = new < 145 | Options = unknown, 146 | Result = unknown, 147 | Error = unknown 148 | >() => HookSingular; 149 | 150 | interface Hook { 151 | /** 152 | * Creates a collection of hooks 153 | */ 154 | Collection: Collection; 155 | 156 | /** 157 | * Creates a nameless hook that supports strict typings 158 | */ 159 | Singular: Singular; 160 | } 161 | 162 | declare const Hook: { 163 | /** 164 | * Creates a collection of hooks 165 | */ 166 | Collection: Collection; 167 | 168 | /** 169 | * Creates a nameless hook that supports strict typings 170 | */ 171 | Singular: Singular; 172 | }; 173 | 174 | export default Hook; 175 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { register } from "./lib/register.js"; 4 | import { addHook } from "./lib/add.js"; 5 | import { removeHook } from "./lib/remove.js"; 6 | 7 | // bind with array of arguments: https://stackoverflow.com/a/21792913 8 | const bind = Function.bind; 9 | const bindable = bind.bind(bind); 10 | 11 | function bindApi(hook, state, name) { 12 | const removeHookRef = bindable(removeHook, null).apply( 13 | null, 14 | name ? [state, name] : [state] 15 | ); 16 | hook.api = { remove: removeHookRef }; 17 | hook.remove = removeHookRef; 18 | ["before", "error", "after", "wrap"].forEach((kind) => { 19 | const args = name ? [state, kind, name] : [state, kind]; 20 | hook[kind] = hook.api[kind] = bindable(addHook, null).apply(null, args); 21 | }); 22 | } 23 | 24 | function Singular() { 25 | const singularHookName = Symbol("Singular"); 26 | const singularHookState = { 27 | registry: {}, 28 | }; 29 | const singularHook = register.bind(null, singularHookState, singularHookName); 30 | bindApi(singularHook, singularHookState, singularHookName); 31 | return singularHook; 32 | } 33 | 34 | function Collection() { 35 | const state = { 36 | registry: {}, 37 | }; 38 | 39 | const hook = register.bind(null, state); 40 | bindApi(hook, state); 41 | 42 | return hook; 43 | } 44 | 45 | export default { Singular, Collection }; 46 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from "tsd"; 2 | import Hook from "./index.js"; 3 | 4 | export async function singularReadmeTest() { 5 | // instantiate singular hook API 6 | const hook = new Hook.Singular(); 7 | 8 | expectType>(hook); 9 | 10 | // Create a hook 11 | const method = () => {}; 12 | function getData(options: unknown) { 13 | return hook(method, options); 14 | } 15 | 16 | // register before/error/after hooks. 17 | // The methods can be async or return a promise 18 | hook.before(() => {}); 19 | hook.error(() => {}); 20 | hook.after(() => {}); 21 | 22 | expectType>(getData({ id: 123 })); 23 | } 24 | 25 | export function collectionReadmeTest() { 26 | // instantiate hook collection API 27 | const hookCollection = new Hook.Collection(); 28 | 29 | expectType>(hookCollection); 30 | 31 | // Create a hook 32 | const method = () => {}; 33 | function getData(options: unknown) { 34 | return hookCollection("get", method, options); 35 | } 36 | 37 | // register before/error/after hooks. 38 | // The methods can be async or return a promise 39 | hookCollection.before("get", () => {}); 40 | hookCollection.error("get", () => {}); 41 | hookCollection.after("get", () => {}); 42 | 43 | expectType>(getData({ id: 123 })); 44 | } 45 | 46 | // TODO: add assertions below 47 | 48 | type Options = { 49 | [key: string]: any; 50 | everything: number; 51 | }; 52 | 53 | const options: Options = { 54 | everything: 42, 55 | nothing: 0, 56 | }; 57 | 58 | const hookMethod = (options: Options): number => { 59 | const sumOfNumbers = Object.keys(options) 60 | .map((key) => options[key]) 61 | .filter((v): v is number => typeof v === "number") 62 | .reduce((sum, num) => sum + num, 0); 63 | 64 | return sumOfNumbers; 65 | }; 66 | 67 | const beforeHook = (_options: Options): void => {}; 68 | const afterHook = (_result: number, _options: Options): void => {}; 69 | const errorHook = (_error: any, _options: Options): void => {}; 70 | const errorCatchHook = (_error: any, _options: Options) => "ok"; 71 | const wrapHook = (hookMethod: any, options: Options): number => { 72 | beforeHook(options); 73 | const result = hookMethod(options); 74 | afterHook(result, options); 75 | return result; 76 | }; 77 | 78 | const hooks = new Hook.Collection(); 79 | 80 | hooks.before("life", beforeHook); 81 | hooks.after("life", afterHook); 82 | hooks.error("life", errorHook); 83 | hooks.error("life", errorCatchHook); 84 | hooks.wrap("life", wrapHook); 85 | 86 | hooks("life", hookMethod); 87 | hooks(["life"], hookMethod); 88 | 89 | const hook = new Hook.Singular(); 90 | 91 | hook.before(beforeHook); 92 | hook.after(afterHook); 93 | hook.error(errorHook); 94 | hook.error(errorCatchHook); 95 | hook.wrap(wrapHook); 96 | 97 | hook(hookMethod, options); 98 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/add.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | export function addHook(state, kind, name, hook) { 4 | const orig = hook; 5 | if (!state.registry[name]) { 6 | state.registry[name] = []; 7 | } 8 | 9 | if (kind === "before") { 10 | hook = (method, options) => { 11 | return Promise.resolve() 12 | .then(orig.bind(null, options)) 13 | .then(method.bind(null, options)); 14 | }; 15 | } 16 | 17 | if (kind === "after") { 18 | hook = (method, options) => { 19 | let result; 20 | return Promise.resolve() 21 | .then(method.bind(null, options)) 22 | .then((result_) => { 23 | result = result_; 24 | return orig(result, options); 25 | }) 26 | .then(() => { 27 | return result; 28 | }); 29 | }; 30 | } 31 | 32 | if (kind === "error") { 33 | hook = (method, options) => { 34 | return Promise.resolve() 35 | .then(method.bind(null, options)) 36 | .catch((error) => { 37 | return orig(error, options); 38 | }); 39 | }; 40 | } 41 | 42 | state.registry[name].push({ 43 | hook: hook, 44 | orig: orig, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /lib/register.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | export function register(state, name, method, options) { 4 | if (typeof method !== "function") { 5 | throw new Error("method for before hook must be a function"); 6 | } 7 | 8 | if (!options) { 9 | options = {}; 10 | } 11 | 12 | if (Array.isArray(name)) { 13 | return name.reverse().reduce((callback, name) => { 14 | return register.bind(null, state, name, callback, options); 15 | }, method)(); 16 | } 17 | 18 | return Promise.resolve().then(() => { 19 | if (!state.registry[name]) { 20 | return method(options); 21 | } 22 | 23 | return state.registry[name].reduce((method, registered) => { 24 | return registered.hook.bind(null, method, options); 25 | }, method)(); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /lib/remove.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | export function removeHook(state, name, method) { 4 | if (!state.registry[name]) { 5 | return; 6 | } 7 | 8 | const index = state.registry[name] 9 | .map((registered) => { 10 | return registered.orig; 11 | }) 12 | .indexOf(method); 13 | 14 | if (index === -1) { 15 | return; 16 | } 17 | 18 | state.registry[name].splice(index, 1); 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "before-after-hook", 3 | "type": "module", 4 | "version": "0.0.0-development", 5 | "description": "asynchronous before/error/after hooks for internal functionality", 6 | "exports": "./index.js", 7 | "types": "./index.d.ts", 8 | "files": [ 9 | "index.js", 10 | "index.d.ts", 11 | "lib" 12 | ], 13 | "scripts": { 14 | "test": "npm run test:code && npm run test:tsc && npm run test:tsd && npm run lint", 15 | "test:code": "c8 --100 --clean node --test", 16 | "test:deno": "deno test --no-check", 17 | "test:tsc": "tsc --allowJs --noEmit --esModuleInterop --skipLibCheck --lib es2020 index.js", 18 | "test:tsd": "tsd", 19 | "lint": "prettier --check \"*.{js,json,ts,md}\" \"test//*.{js,json,ts,md}\" \".github/**/*.yml\"", 20 | "lint:fix": "prettier --write \"*.{js,json,ts,md}\" \"test//*.{js,json,ts,md}\" \".github/**/*.yml\"", 21 | "postcoverage": "open-cli coverage/index.html" 22 | }, 23 | "repository": "github:gr2m/before-after-hook", 24 | "keywords": [ 25 | "hook", 26 | "hooks", 27 | "api" 28 | ], 29 | "author": "Gregor Martynus", 30 | "license": "Apache-2.0", 31 | "devDependencies": { 32 | "@types/node": "^22.15.17", 33 | "c8": "^10.1.3", 34 | "prettier": "^2.0.0", 35 | "tsd": "^0.24.1", 36 | "typescript": "^4.8.4" 37 | }, 38 | "release": { 39 | "branches": [ 40 | "+([0-9]).x", 41 | "main", 42 | "next", 43 | { 44 | "name": "beta", 45 | "prerelease": true 46 | } 47 | ] 48 | }, 49 | "renovate": { 50 | "extends": [ 51 | "github>gr2m/.github" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/after.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test('hook.after("test", afterCheck) order', async () => { 6 | const hook = new Hook.Collection(); 7 | const calls = []; 8 | 9 | hook.after("test", () => { 10 | calls.push("after"); 11 | }); 12 | 13 | await hook("test", () => { 14 | calls.push("afterCheck"); 15 | }); 16 | 17 | assert(calls.length === 2); 18 | assert(calls[0] === "afterCheck"); 19 | assert(calls[1] === "after"); 20 | }); 21 | 22 | test('hook.after("test", afterCheck) async afterCheck', async () => { 23 | const hook = new Hook.Collection(); 24 | const calls = []; 25 | 26 | hook.after("test", async () => { 27 | calls.push("after"); 28 | }); 29 | 30 | await hook("test", () => { 31 | calls.push("afterCheck"); 32 | }); 33 | 34 | assert(calls.length === 2); 35 | assert(calls[0] === "afterCheck"); 36 | assert(calls[1] === "after"); 37 | }); 38 | 39 | test('hook.after("test", afterCheck) throws error', async () => { 40 | const hook = new Hook.Collection(); 41 | const method = function method() {}; 42 | 43 | hook.after("test", () => { 44 | throw new Error("oops"); 45 | }); 46 | 47 | try { 48 | await hook("test", method); 49 | assert(false, "must not resolve"); 50 | } catch (error) { 51 | assert( 52 | error.message === "oops", 53 | "rejects with error message from afterCheck" 54 | ); 55 | } 56 | }); 57 | 58 | test('hook.after("test", afterCheck) rejected promise', async () => { 59 | const hook = new Hook.Collection(); 60 | const method = function method() {}; 61 | 62 | hook.after("test", () => { 63 | return Promise.reject(new Error("oops")); 64 | }); 65 | 66 | try { 67 | await hook("test", method); 68 | assert(false, "must not resolve"); 69 | } catch (error) { 70 | assert( 71 | error.message === "oops", 72 | "rejects with error message from afterCheck" 73 | ); 74 | } 75 | }); 76 | 77 | test('hook.after("test", afterCheck) result and options', async () => { 78 | const hook = new Hook.Collection(); 79 | 80 | hook.after("test", (result, options) => { 81 | assert(options.optionFoo === "bar", "passes options to after hook"); 82 | result.foo = "newbar"; 83 | }); 84 | hook.after("test", (result, options) => { 85 | result.baz = "ar"; 86 | }); 87 | 88 | const result = await hook( 89 | "test", 90 | () => { 91 | return { 92 | foo: "bar", 93 | otherFoo: "bar", 94 | }; 95 | }, 96 | { optionFoo: "bar" } 97 | ); 98 | 99 | assert(result.foo === "newbar", "after hook modifies result"); 100 | assert(result.otherFoo === "bar", "result is not modified by after hook"); 101 | }); 102 | 103 | test('hook.after("test", afterCheck) multiple after hooks get executed after method', async () => { 104 | const hook = new Hook.Collection(); 105 | const calls = []; 106 | 107 | hook.after("test", () => { 108 | calls.push("after1"); 109 | }); 110 | hook.after("test", () => { 111 | calls.push("after2"); 112 | }); 113 | 114 | await hook("test", () => { 115 | calls.push("afterCheck"); 116 | }); 117 | 118 | assert(calls.length === 3); 119 | assert(calls[0] === "afterCheck"); 120 | assert(calls[1] === "after1"); 121 | assert(calls[2] === "after2"); 122 | }); 123 | -------------------------------------------------------------------------------- /test/before.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test('hook.before("test", check) order', async () => { 6 | const hook = new Hook.Collection(); 7 | const calls = []; 8 | 9 | hook.before("test", () => { 10 | calls.push("before"); 11 | }); 12 | 13 | await hook("test", () => { 14 | calls.push("check"); 15 | }); 16 | 17 | assert(calls.length === 2); 18 | assert(calls[0] === "before"); 19 | assert(calls[1] === "check"); 20 | }); 21 | 22 | test('hook.before("test", check) async check', async () => { 23 | const hook = new Hook.Collection(); 24 | const calls = []; 25 | 26 | hook.before("test", async () => { 27 | calls.push("before"); 28 | }); 29 | 30 | await hook("test", () => { 31 | calls.push("check"); 32 | }); 33 | 34 | assert(calls.length === 2); 35 | assert(calls[0] === "before"); 36 | assert(calls[1] === "check"); 37 | }); 38 | 39 | test('hook.before("test", check) throws error', async () => { 40 | const hook = new Hook.Collection(); 41 | 42 | let methodCallCount = 0; 43 | const method = function method() { 44 | methodCallCount++; 45 | }; 46 | 47 | hook.before("test", () => { 48 | throw new Error("oops"); 49 | }); 50 | 51 | try { 52 | await hook("test", method); 53 | assert(false, "must not resolve"); 54 | } catch (error) { 55 | assert(error.message === "oops", "rejects with error message from check"); 56 | assert(methodCallCount === 0); 57 | } 58 | }); 59 | 60 | test('hook.before("test", check) rejected promise', async () => { 61 | const hook = new Hook.Collection(); 62 | 63 | let methodCallCount = 0; 64 | const method = function method() { 65 | methodCallCount++; 66 | }; 67 | 68 | hook.before("test", () => { 69 | return Promise.reject(new Error("oops")); 70 | }); 71 | 72 | try { 73 | await hook("test", method); 74 | assert(false, "must not resolve"); 75 | } catch (error) { 76 | assert(error.message === "oops", "rejects with error message from check"); 77 | assert(methodCallCount === 0); 78 | } 79 | }); 80 | 81 | test('hook.before("test", check) options', async () => { 82 | const hook = new Hook.Collection(); 83 | 84 | hook.before("test", (options) => { 85 | options.foo = "bar"; 86 | }); 87 | hook.before("test", (options) => { 88 | options.baz = "ar"; 89 | }); 90 | 91 | await hook( 92 | "test", 93 | (options) => { 94 | assert(options.foo === "bar", "passes options to before hook"); 95 | assert(options.baz === "ar", "passes options to before hook"); 96 | assert(options.otherbar === "baz", "passes options to before hook"); 97 | }, 98 | { foo: "notbar", otherbar: "baz" } 99 | ); 100 | }); 101 | 102 | test('before("test", check) multiple before hooks get executed before method', async () => { 103 | const hook = new Hook.Collection(); 104 | const calls = []; 105 | 106 | hook.before("test", () => { 107 | calls.push("before1"); 108 | }); 109 | hook.before("test", () => { 110 | calls.push("before2"); 111 | }); 112 | 113 | await hook("test", () => { 114 | calls.push("check"); 115 | }); 116 | 117 | assert(calls.length === 3); 118 | assert(calls[0] === "before2"); 119 | assert(calls[1] === "before1"); 120 | assert(calls[2] === "check"); 121 | }); 122 | -------------------------------------------------------------------------------- /test/constructor.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test("new Hook.Singular()", () => { 6 | const hook = new Hook.Singular(); 7 | 8 | assert(typeof hook === "function", "hook() is a function"); 9 | assert(typeof hook.before === "function", "hook.before() is function"); 10 | assert(typeof hook.after === "function", "hook.after() is function"); 11 | 12 | assert(typeof hook.api === "object", "hook.api is an object"); 13 | assert(typeof hook.api.before === "function", "hook.before() is function"); 14 | assert(typeof hook.api.after === "function", "hook.after() is function"); 15 | assert(typeof hook.api === "object", "does ont expose hook() method"); 16 | }); 17 | 18 | test("new Hook.Collection()", () => { 19 | const hook = new Hook.Collection(); 20 | 21 | assert(typeof hook, "function", "hook() is a function"); 22 | assert(typeof hook.before === "function", "hook.before() is function"); 23 | assert(typeof hook.after === "function", "hook.after() is function"); 24 | 25 | assert(typeof hook.api === "object", "hook.api is an object"); 26 | assert(typeof hook.api.before === "function", "hook.before() is function"); 27 | assert(typeof hook.api.after === "function", "hook.after() is function"); 28 | assert(typeof hook.api === "object", "does ont expose hook() method"); 29 | }); 30 | -------------------------------------------------------------------------------- /test/error.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test('hook.error("test", handleError) order', async () => { 6 | const hook = new Hook.Collection(); 7 | const calls = []; 8 | 9 | hook.error("test", () => { 10 | calls.push("errorHook"); 11 | }); 12 | hook.after("test", () => { 13 | calls.push("afterHook"); 14 | }); 15 | 16 | await hook("test", () => { 17 | calls.push("method"); 18 | throw new Error("oops"); 19 | }); 20 | 21 | assert(calls.length === 3); 22 | assert(calls[0] === "method"); 23 | assert(calls[1] === "errorHook"); 24 | assert(calls[2] === "afterHook"); 25 | }); 26 | 27 | test('hook.error("test", handleError) async order', async () => { 28 | const hook = new Hook.Collection(); 29 | const calls = []; 30 | 31 | hook.error("test", () => { 32 | calls.push("errorHook"); 33 | }); 34 | 35 | await hook("test", () => { 36 | return new Promise(() => { 37 | calls.push("method"); 38 | throw new Error("oops"); 39 | }); 40 | }); 41 | 42 | assert(calls.length === 2); 43 | assert(calls[0] === "method"); 44 | assert(calls[1] === "errorHook"); 45 | }); 46 | 47 | test('hook.error("test", handleError) can mutate error', async () => { 48 | const hook = new Hook.Collection(); 49 | const method = function method() { 50 | throw new Error("oops"); 51 | }; 52 | 53 | hook.error("test", (error) => { 54 | error.message = "error hook"; 55 | throw error; 56 | }); 57 | 58 | try { 59 | await hook("test", method); 60 | assert(false, "must not resolve"); 61 | } catch (error) { 62 | assert( 63 | error.message === "error hook", 64 | "rejects with error message from error hook" 65 | ); 66 | } 67 | }); 68 | 69 | test('hook.error("test", handleError) rejected promise', async () => { 70 | const hook = new Hook.Collection(); 71 | const method = function method() { 72 | throw new Error("oops"); 73 | }; 74 | 75 | hook.error("test", (error) => { 76 | error.message = "error hook"; 77 | return Promise.reject(error); 78 | }); 79 | 80 | try { 81 | await hook("test", method); 82 | assert(false, "must not resolve"); 83 | } catch (error) { 84 | assert( 85 | error.message, 86 | "error hook", 87 | "rejects with error message from error hook" 88 | ); 89 | } 90 | }); 91 | 92 | test('hook.error("test", handleError) can catch error', async () => { 93 | const hook = new Hook.Collection(); 94 | const method = function method() { 95 | throw new Error("oops"); 96 | }; 97 | 98 | hook.error("test", () => { 99 | return { ok: true }; 100 | }); 101 | 102 | const result = await hook("test", method); 103 | 104 | assert(result.ok === true); 105 | }); 106 | 107 | test('hook.error("test", handleError) receives options', async () => { 108 | const hook = new Hook.Collection(); 109 | const method = function method() { 110 | throw new Error("oops"); 111 | }; 112 | 113 | hook.error("test", (error, options) => { 114 | assert(options.optionFoo === "bar"); 115 | throw error; 116 | }); 117 | 118 | try { 119 | await hook("test", method, { optionFoo: "bar" }); 120 | assert(false, "must not resolve"); 121 | } catch (error) { 122 | assert(error.message === "oops"); 123 | } 124 | }); 125 | 126 | test('hook.error("test", handleError) multiple error hooks get executed after method', async () => { 127 | const hook = new Hook.Collection(); 128 | const callOrder = []; 129 | const method = function method() { 130 | callOrder.push(1); 131 | throw new Error("oops"); 132 | }; 133 | let errorHandlerCallCount = 0; 134 | const errorHandler = function errorHandler() { 135 | errorHandlerCallCount++; 136 | throw new Error("error handler oops"); 137 | }; 138 | 139 | hook.error("test", () => { 140 | callOrder.push(2); 141 | errorHandler(); 142 | }); 143 | hook.error("test", () => { 144 | callOrder.push(3); 145 | errorHandler(); 146 | }); 147 | 148 | try { 149 | await hook("test", method); 150 | assert(false, "should not resolve"); 151 | } catch (error) { 152 | assert(errorHandlerCallCount === 2); 153 | assert(callOrder.length === 3); 154 | assert(callOrder[0] === 1); 155 | assert(callOrder[1] === 2); 156 | assert(callOrder[2] === 3); 157 | assert(error.message === "error handler oops"); 158 | } 159 | }); 160 | -------------------------------------------------------------------------------- /test/hook.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test("hook(name, options, method) multiple names", async () => { 6 | const hook = new Hook.Collection(); 7 | const calls = []; 8 | 9 | hook.before("outer", () => { 10 | calls.push("beforeOuter"); 11 | }); 12 | hook.before("inner", () => { 13 | calls.push("beforeInner"); 14 | }); 15 | hook.after("inner", () => { 16 | calls.push("afterInner"); 17 | }); 18 | hook.after("outer", () => { 19 | calls.push("afterOuter"); 20 | }); 21 | 22 | await hook(["outer", "dafuq", "inner"], () => { 23 | calls.push("method"); 24 | }); 25 | 26 | assert(calls.length === 5); 27 | assert(calls[0] === "beforeOuter"); 28 | assert(calls[1] === "beforeInner"); 29 | assert(calls[2] === "method"); 30 | assert(calls[3] === "afterInner"); 31 | assert(calls[4] === "afterOuter"); 32 | }); 33 | 34 | test("hook(name, options, method) order", async () => { 35 | const hook = new Hook.Collection(); 36 | const calls = []; 37 | 38 | hook.before("test", () => { 39 | calls.push("before test 1"); 40 | }); 41 | hook.after("test", () => { 42 | calls.push("after test 1"); 43 | }); 44 | hook.before("test", () => { 45 | calls.push("before test 2"); 46 | }); 47 | hook.after("test", () => { 48 | calls.push("after test 2"); 49 | }); 50 | 51 | await hook("test", () => { 52 | calls.push("method"); 53 | }); 54 | 55 | assert(calls.length === 5); 56 | assert(calls[0] === "before test 2"); 57 | assert(calls[1] === "before test 1"); 58 | assert(calls[2] === "method"); 59 | assert(calls[3] === "after test 1"); 60 | assert(calls[4] === "after test 2"); 61 | }); 62 | 63 | test("hook(name, options, method) no handlers defined (#51)", async () => { 64 | const hook = new Hook.Collection(); 65 | const options = { foo: "bar" }; 66 | 67 | await hook( 68 | "test", 69 | (_options) => { 70 | assert(typeof _options === "object"); 71 | assert(Object.keys(_options).length === 1); 72 | assert(_options.foo === "bar"); 73 | }, 74 | options 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /test/remove.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test('hook.remove("test", check)', async () => { 6 | const hook = new Hook.Collection(); 7 | const calls = []; 8 | 9 | const afterCheck = () => { 10 | calls.push("after"); 11 | }; 12 | hook.after("test", afterCheck); 13 | hook.remove("test", afterCheck); 14 | 15 | await hook("test", () => { 16 | calls.push("check"); 17 | }); 18 | 19 | assert(calls.length === 1); 20 | assert(calls[0] === "check"); 21 | }); 22 | 23 | test('hook.remove("test", check) without "check" matching existing function', async () => { 24 | const hook = new Hook.Collection(); 25 | const calls = []; 26 | 27 | hook.before("test", () => calls.push("before")); 28 | hook.remove("test", () => { 29 | throw new Error("should not be called"); 30 | }); 31 | 32 | await hook("test", () => { 33 | calls.push("check"); 34 | }); 35 | 36 | assert(calls.length === 2); 37 | assert(calls[0] === "before"); 38 | assert(calls[1] === "check"); 39 | }); 40 | -------------------------------------------------------------------------------- /test/singular-hook.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test("Hook.Singular() multiple names", async () => { 6 | const hook = new Hook.Singular(); 7 | const calls = []; 8 | 9 | hook.before(() => { 10 | calls.push("beforeSecond"); 11 | }); 12 | hook.before(() => { 13 | calls.push("beforeFirst"); 14 | }); 15 | hook.after(() => { 16 | calls.push("afterFirst"); 17 | }); 18 | hook.after(() => { 19 | calls.push("afterSecond"); 20 | }); 21 | 22 | await hook(() => { 23 | calls.push("method"); 24 | }); 25 | 26 | assert(calls.length === 5); 27 | assert(calls[0] === "beforeFirst"); 28 | assert(calls[1] === "beforeSecond"); 29 | assert(calls[2] === "method"); 30 | assert(calls[3] === "afterFirst"); 31 | assert(calls[4] === "afterSecond"); 32 | }); 33 | 34 | test("Hook.Singular() order", async () => { 35 | const hook = new Hook.Singular(); 36 | const calls = []; 37 | 38 | hook.before(() => { 39 | calls.push("before 1"); 40 | }); 41 | hook.after(() => { 42 | calls.push("after 1"); 43 | }); 44 | hook.before(() => { 45 | calls.push("before 2"); 46 | }); 47 | hook.after(() => { 48 | calls.push("after 2"); 49 | }); 50 | 51 | await hook(() => { 52 | calls.push("method"); 53 | }); 54 | 55 | assert(calls.length === 5); 56 | assert(calls[0] === "before 2"); 57 | assert(calls[1] === "before 1"); 58 | assert(calls[2] === "method"); 59 | assert(calls[3] === "after 1"); 60 | assert(calls[4] === "after 2"); 61 | }); 62 | 63 | test("Hook.Singular() multiple unnamed hooks", async () => { 64 | const calls = []; 65 | 66 | const hook1 = new Hook.Singular(); 67 | hook1.before(() => { 68 | calls.push("before 1"); 69 | }); 70 | hook1.after(() => { 71 | calls.push("after 1"); 72 | }); 73 | const hook2 = new Hook.Singular(); 74 | hook2.before(() => { 75 | calls.push("before 2"); 76 | }); 77 | hook2.after(() => { 78 | calls.push("after 2"); 79 | }); 80 | 81 | await hook1(() => { 82 | calls.push("method 1"); 83 | }); 84 | 85 | await hook2(() => { 86 | calls.push("method 2"); 87 | }); 88 | 89 | assert(calls.length === 6); 90 | assert(calls[0] === "before 1"); 91 | assert(calls[1] === "method 1"); 92 | assert(calls[2] === "after 1"); 93 | assert(calls[3] === "before 2"); 94 | assert(calls[4] === "method 2"); 95 | assert(calls[5] === "after 2"); 96 | }); 97 | 98 | test("Hook.Singular() no handlers defined (#51)", async () => { 99 | const hook = new Hook.Singular(); 100 | const options = { foo: "bar" }; 101 | 102 | await hook((_options) => { 103 | assert(typeof _options === "object"); 104 | assert(Object.keys(_options).length === 1); 105 | assert(_options.foo === "bar", "passes options to method"); 106 | }, options); 107 | }); 108 | -------------------------------------------------------------------------------- /test/testrunner.js: -------------------------------------------------------------------------------- 1 | let test, assert; 2 | if ("Bun" in globalThis) { 3 | test = function test(name, fn) { 4 | return globalThis.Bun.jest(caller()).it(name, fn); 5 | }; 6 | assert = function assert(value, message) { 7 | return globalThis.Bun.jest(caller()).expect(value, message); 8 | }; 9 | /** Retrieve caller test file. */ 10 | function caller() { 11 | const Trace = Error; 12 | const _ = Trace.prepareStackTrace; 13 | Trace.prepareStackTrace = (_, stack) => stack; 14 | const { stack } = new Error(); 15 | Trace.prepareStackTrace = _; 16 | const caller = stack[2]; 17 | return caller.getFileName().replaceAll("\\", "/"); 18 | } 19 | } else if ("Deno" in globalThis) { 20 | const nodeTest = await import("node:test"); 21 | const nodeAssert = await import("node:assert"); 22 | 23 | test = nodeTest.test; 24 | assert = nodeAssert.strict; 25 | } else if (process.env.VITEST_WORKER_ID) { 26 | test = await import("vitest").then((module) => module.test); 27 | assert = await import("vitest").then((module) => module.assert); 28 | } else { 29 | const nodeTest = await import("node:test"); 30 | const nodeAssert = await import("node:assert"); 31 | 32 | test = nodeTest.test; 33 | assert = nodeAssert.strict; 34 | } 35 | 36 | export { test, assert }; 37 | -------------------------------------------------------------------------------- /test/unit-register.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import { register } from "../lib/register.js"; 4 | 5 | test('register("name", method) with empty registry and thrown error by method', async () => { 6 | try { 7 | await register( 8 | { 9 | registry: {}, 10 | }, 11 | "test", 12 | () => { 13 | throw new Error("foo"); 14 | } 15 | ); 16 | assert(false, "should not resolve"); 17 | } catch (error) { 18 | assert(error.message === "foo", "rejects with error message from method"); 19 | } 20 | }); 21 | 22 | test('register("name", undefined)', () => { 23 | try { 24 | register.bind(null, {}, "test", undefined)(); 25 | assert(false, "should throw"); 26 | } catch (error) { 27 | assert( 28 | error.message === "method for before hook must be a function", 29 | "rejects with error message from method" 30 | ); 31 | } 32 | }); 33 | 34 | test('register("name", undefined, method)', () => { 35 | try { 36 | register.bind(null, {}, "test", undefined, () => {})(); 37 | assert(false, "should throw"); 38 | } catch (error) { 39 | assert( 40 | error.message === "method for before hook must be a function", 41 | "rejects with error message from method" 42 | ); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /test/unit-remove.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import { removeHook } from "../lib/remove.js"; 4 | 5 | test('removeHook("before", "name", method) with empty registry', () => { 6 | const state = { 7 | registry: {}, 8 | }; 9 | removeHook(state, "before", "test", () => {}); 10 | 11 | assert(typeof state.registry === "object"); 12 | assert(Object.keys(state.registry).length === 0); 13 | }); 14 | 15 | test('removeHook("before", "name", method) with method that cannot be found', () => { 16 | const state = { 17 | registry: { 18 | test: [], 19 | }, 20 | }; 21 | removeHook(state, "before", "test", () => {}); 22 | assert(typeof state.registry === "object"); 23 | assert(Object.keys(state.registry).length === 1); 24 | assert(Array.isArray(state.registry.test)); 25 | assert(state.registry.test.length === 0); 26 | }); 27 | -------------------------------------------------------------------------------- /test/wrap.test.js: -------------------------------------------------------------------------------- 1 | import { test, assert } from "./testrunner.js"; 2 | 3 | import Hook from "../index.js"; 4 | 5 | test('hook.wrap("test", wrapMethod) before/after/error', async () => { 6 | const hook = new Hook.Collection(); 7 | const calls = []; 8 | 9 | hook.wrap("test", (method) => { 10 | calls.push("before"); 11 | try { 12 | method(); 13 | } catch (error) { 14 | calls.push("error"); 15 | } 16 | calls.push("after"); 17 | }); 18 | 19 | await hook("test", () => { 20 | calls.push("method"); 21 | throw new Error("ooops"); 22 | }); 23 | 24 | assert(calls.length === 4); 25 | assert(calls[0] === "before"); 26 | assert(calls[1] === "method"); 27 | assert(calls[2] === "error"); 28 | assert(calls[3] === "after"); 29 | }); 30 | 31 | test('hook.wrap("test", wrapMethod) async check', async () => { 32 | const hook = new Hook.Collection(); 33 | const calls = []; 34 | 35 | hook.wrap("test", (method) => { 36 | calls.push("before"); 37 | return method() 38 | .catch(() => calls.push("error")) 39 | .then(() => calls.push("after")); 40 | }); 41 | 42 | await hook("test", async () => { 43 | calls.push("method"); 44 | throw new Error("ooops"); 45 | }); 46 | 47 | assert(calls.length === 4); 48 | assert(calls[0] === "before"); 49 | assert(calls[1] === "method"); 50 | assert(calls[2] === "error"); 51 | assert(calls[3] === "after"); 52 | }); 53 | 54 | test('hook.wrap("test", wrapMethod) throws error', async () => { 55 | const hook = new Hook.Collection(); 56 | 57 | let methodCallCount = 0; 58 | const method = function method() { 59 | methodCallCount++; 60 | }; 61 | 62 | hook.wrap("test", () => { 63 | throw new Error("oops"); 64 | }); 65 | 66 | try { 67 | await hook("test", method); 68 | assert(false, "must not resolve"); 69 | } catch (error) { 70 | assert(error.message === "oops", "rejects with error message from check"); 71 | assert(methodCallCount === 0); 72 | } 73 | }); 74 | 75 | test('hook.wrap("test", wrapMethod) rejected promise', async () => { 76 | const hook = new Hook.Collection(); 77 | 78 | let methodCallCount = 0; 79 | const method = function method() { 80 | methodCallCount++; 81 | }; 82 | 83 | hook.wrap("test", () => { 84 | return Promise.reject(new Error("oops")); 85 | }); 86 | 87 | try { 88 | await hook("test", method); 89 | hook("test", method); 90 | assert(false, "must not resolve"); 91 | } catch (error) { 92 | assert(error.message, "oops", "rejects with error message from check"); 93 | assert(methodCallCount === 0); 94 | } 95 | }); 96 | 97 | test('hook.wrap("test", wrapMethod) options', async () => { 98 | const hook = new Hook.Collection(); 99 | 100 | hook.wrap("test", (method, options) => { 101 | options.foo = "bar"; 102 | return method(options); 103 | }); 104 | hook.wrap("test", (method, options) => { 105 | options.baz = "ar"; 106 | return method(options); 107 | }); 108 | 109 | await hook( 110 | "test", 111 | (options) => { 112 | assert(options.foo === "bar", "passes options to before hook"); 113 | assert(options.baz === "ar", "passes options to before hook"); 114 | assert(options.otherbar === "baz", "passes options to before hook"); 115 | }, 116 | { foo: "notbar", otherbar: "baz" } 117 | ); 118 | }); 119 | 120 | test('hook.wrap("test", wrapMethod) multiple wrap hooks', async () => { 121 | const hook = new Hook.Collection(); 122 | const calls = []; 123 | 124 | hook.wrap("test", (method) => { 125 | calls.push("wrap1"); 126 | method(); 127 | }); 128 | hook.wrap("test", (method) => { 129 | calls.push("wrap2"); 130 | method(); 131 | }); 132 | 133 | await await hook("test", () => { 134 | calls.push("method"); 135 | }); 136 | 137 | assert(calls.length === 3); 138 | assert(calls[0] === "wrap2"); 139 | assert(calls[1] === "wrap1"); 140 | assert(calls[2] === "method"); 141 | }); 142 | --------------------------------------------------------------------------------