├── .eslintignore ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── pr-title.yml │ └── unit-test.yml ├── .gitignore ├── .mocharc.js ├── LICENSE ├── README.md ├── lib └── asyncbox.js ├── package.json ├── test ├── .eslintrc └── asyncbox-specs.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@appium/eslint-config-appium-ts"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: "chore" 11 | include: "scope" 12 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Conventional Commits 2 | on: 3 | pull_request: 4 | 5 | 6 | jobs: 7 | lint: 8 | name: https://www.conventionalcommits.org 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: beemojs/conventional-pr-action@v2 12 | with: 13 | config-preset: angular 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [pull_request, push] 4 | 5 | 6 | jobs: 7 | prepare_matrix: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | versions: ${{ steps.generate-matrix.outputs.versions }} 11 | steps: 12 | - name: Select 3 most recent LTS versions of Node.js 13 | id: generate-matrix 14 | run: echo "versions=$(curl -s https://endoflife.date/api/nodejs.json | jq -c '[[.[] | select(.lts != false)][:3] | .[].cycle | tonumber]')" >> "$GITHUB_OUTPUT" 15 | 16 | test: 17 | needs: 18 | - prepare_matrix 19 | strategy: 20 | matrix: 21 | node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm install --no-package-lock 29 | name: Install dev dependencies 30 | - run: npm run lint 31 | name: Run linter 32 | - run: npm run test 33 | name: Run unit tests 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | *.log 4 | package-lock.json* 5 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | require: ['ts-node/register'], 3 | forbidOnly: Boolean(process.env.CI) 4 | }; 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012-2018 JS Foundation and other contributors 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | 180 | APPENDIX: How to apply the Apache License to your work. 181 | 182 | To apply the Apache License to your work, attach the following 183 | boilerplate notice, with the fields enclosed by brackets "{}" 184 | replaced with your own identifying information. (Don't include 185 | the brackets!) The text should be enclosed in the appropriate 186 | comment syntax for the file format. We also recommend that a 187 | file or class name and description of purpose be included on the 188 | same "printed page" as the copyright notice for easier 189 | identification within third-party archives. 190 | 191 | Copyright 2012 JS Foundation and other contributors 192 | 193 | Licensed under the Apache License, Version 2.0 (the "License"); 194 | you may not use this file except in compliance with the License. 195 | You may obtain a copy of the License at 196 | 197 | http://www.apache.org/licenses/LICENSE-2.0 198 | 199 | Unless required by applicable law or agreed to in writing, software 200 | distributed under the License is distributed on an "AS IS" BASIS, 201 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 202 | See the License for the specific language governing permissions and 203 | limitations under the License. 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | asyncbox 2 | ======== 3 | 4 | A collection of ES7 async/await utilities. Install via NPM: 5 | 6 | ``` 7 | npm install asyncbox 8 | ``` 9 | 10 | Then, behold! 11 | 12 | ### Sleep 13 | 14 | An async/await version of setTimeout 15 | 16 | ```js 17 | import { sleep } from 'asyncbox'; 18 | 19 | async function myFn () { 20 | // do some stuff 21 | await sleep(1000); // wait one second 22 | // do some other stuff 23 | }; 24 | ``` 25 | 26 | ### Long Sleep 27 | 28 | Sometimes `Promise.delay` or `setTimeout` are inaccurate for large wait times. To safely wait for these long times (e.g. in the 5+ minute range), you can use `longSleep`: 29 | 30 | ```js 31 | import { longSleep } from 'asyncbox'; 32 | 33 | async function myFn () { 34 | await longSleep(10 * 60 * 1000); // wait for 10 mins 35 | await longSleep(5000, {thresholdMs: 10000}); // wait for 5s. Anything below the thresholdMs will use a single sleep 36 | await longSleep(5000, {intervalMs: 500}); // check the clock every 500ms to see if waiting should stop 37 | } 38 | ``` 39 | 40 | You can also pass a `progressCb` option which is a callback function that receives an object with the properties `elapsedMs`, `timeLeft`, and `progress`. This will be called on every wait interval so you can do your wait logging or whatever. 41 | 42 | ```js 43 | function progressCb({elapsedMs, timeLeft, progress}) { 44 | console.log(`We are {progress * 100}% complete waiting`); 45 | } 46 | await longSleep(10 * 60 * 1000, {progressCb}); 47 | ``` 48 | 49 | ### Retry 50 | 51 | An async/await way of running a method until it doesn't throw an error 52 | 53 | ```js 54 | import { sleep, retry } from 'asyncbox'; 55 | 56 | async function flakeyFunction (val1, val2) { 57 | if (val1 < 10) { 58 | throw new Error("this is a flakey value"); 59 | } 60 | await sleep(1000); 61 | return val1 + val2; 62 | } 63 | 64 | async function myFn () { 65 | let randVals = [Math.random() * 100, Math.random() * 100]; 66 | 67 | // run flakeyFunction up to 3 times until it succeeds. 68 | // if it doesn't, we'll get the error thrown in this context 69 | let randSum = await retry(3, flakeyFunction, ...randVals); 70 | } 71 | ``` 72 | 73 | You can also use `retryInterval` to add a sleep in between retries. This can be 74 | useful if you want to throttle how fast we retry: 75 | 76 | ```js 77 | await retryInterval(3, 1500, expensiveFunction, ...args); 78 | ``` 79 | 80 | ### Filter/Map 81 | 82 | Filter and map are pretty handy concepts, and now you can write filter and map 83 | functions that execute asynchronously! 84 | 85 | ```js 86 | import { asyncmap, asyncfilter } from 'asyncbox'; 87 | ``` 88 | 89 | Then in your async functions, you can do: 90 | 91 | ```js 92 | const items = [1, 2, 3, 4]; 93 | const slowSquare = async (n) => { await sleep(5); return n * 2; }; 94 | let newItems = await asyncmap(items, async (i) => { return await slowSquare(i); }); 95 | console.log(newItems); // [1, 4, 9, 16]; 96 | 97 | const slowEven = async (n) => { await sleep(5); return n % 2 === 0; }; 98 | newItems = await asyncfilter(items, async (i) => { return await slowEven(i); }); 99 | console.log(newItems); // [2, 4]; 100 | ``` 101 | 102 | By default, `asyncmap` and `asyncfilter` run their operations in parallel; you 103 | can pass `false` as a third argument to make sure it happens serially. 104 | 105 | ### Nodeify 106 | 107 | Export async functions (Promises) and import this with your ES5 code to use it 108 | with Node. 109 | 110 | ```js 111 | var asyncbox = require('asyncbox') 112 | , sleep = asyncbox.sleep 113 | , nodeify = asyncbox.nodeify; 114 | 115 | nodeify(sleep(1000), function (err, timer) { 116 | console.log(err); // null 117 | console.log(timer); // timer obj 118 | }); 119 | ``` 120 | 121 | ### nodeifyAll 122 | 123 | If you have a whole library you want to export nodeified versions of, it's pretty easy: 124 | 125 | ```js 126 | import { nodeifyAll } from 'asyncbox'; 127 | 128 | async function foo () { ... } 129 | async function bar () { ... } 130 | let cb = nodeifyAll({foo, bar}); 131 | export { foo, bar, cb }; 132 | ``` 133 | 134 | Then in my ES5 script I can do: 135 | 136 | ```js 137 | var myLib = require('mylib').cb; 138 | 139 | myLib.foo(function (err) { ... }); 140 | myLib.bar(function (err) { ... }); 141 | ``` 142 | 143 | ### waitForCondition 144 | 145 | Takes a condition (a function returning a boolean or boolean promise), 146 | and waits until the condition is true. 147 | 148 | Throws a `/Condition unmet/` error if the condition has not been 149 | satisfied within the allocated time, unless an error is provided in 150 | the options, as the `error` property, which is either thrown itself, or 151 | used as the message. 152 | 153 | The condition result is returned if it is not falsy. If the condition 154 | throws an error then this exception will be immediately passed through. 155 | 156 | The default options are: `{ waitMs: 5000, intervalMs: 500 }` 157 | 158 | ```js 159 | // define your own condition 160 | function condFn () { return Math.random()*1000 > 995; } 161 | 162 | // with default params 163 | await waitForCondition(condFn); 164 | 165 | // with options 166 | await waitForCondition(condFn, { 167 | waitMs: 300000, 168 | intervalMs: 10000 169 | }); 170 | 171 | // pass a logger to get extra debug info 172 | await waitForCondition(condFn, { 173 | waitMs: 300000, 174 | intervalMs: 10000 175 | logger: myLogger // expects a debug method 176 | }); 177 | 178 | // pass an error string to get that message in the resulting exception 179 | try { 180 | await waitForCondition(condFn, { 181 | error: 'Unable to satisfy condition' 182 | }); 183 | } catch (err) { 184 | // err.message === 'Unable to satisfy condition' 185 | } 186 | 187 | // pass an error instance to be thrown 188 | const error = new Error('Unable to satisfy condition'); 189 | try { 190 | await waitForCondition(condFn, { 191 | error: error 192 | }); 193 | } catch (err) { 194 | // err === error 195 | } 196 | ``` 197 | 198 | ### Run the tests 199 | 200 | ``` 201 | npm test 202 | ``` 203 | -------------------------------------------------------------------------------- /lib/asyncbox.js: -------------------------------------------------------------------------------- 1 | import B from 'bluebird'; 2 | import _ from 'lodash'; 3 | 4 | const LONG_SLEEP_THRESHOLD = 5000; // anything over 5000ms will turn into a spin 5 | 6 | /** 7 | * An async/await version of setTimeout 8 | * @param {number} ms 9 | * @returns {Promise} 10 | */ 11 | async function sleep (ms) { 12 | return await B.delay(ms); 13 | } 14 | 15 | /** 16 | * Sometimes `Promise.delay` or `setTimeout` are inaccurate for large wait 17 | * times. To safely wait for these long times (e.g. in the 5+ minute range), you 18 | * can use `longSleep`. 19 | * 20 | * sYou can also pass a `progressCb` option which is a callback function that 21 | * receives an object with the properties `elapsedMs`, `timeLeft`, and 22 | * `progress`. This will be called on every wait interval so you can do your 23 | * wait logging or whatever. 24 | * @param {number} ms 25 | * @param {LongSleepOptions} [opts] 26 | * @returns {Promise} 27 | */ 28 | async function longSleep (ms, { 29 | thresholdMs = LONG_SLEEP_THRESHOLD, 30 | intervalMs = 1000, 31 | progressCb = null, 32 | } = {}) { 33 | if (ms < thresholdMs) { 34 | return await sleep(ms); 35 | } 36 | const endAt = Date.now() + ms; 37 | let timeLeft; 38 | let elapsedMs = 0; 39 | do { 40 | const pre = Date.now(); 41 | await sleep(intervalMs); 42 | const post = Date.now(); 43 | timeLeft = endAt - post; 44 | elapsedMs = elapsedMs + (post - pre); 45 | if (_.isFunction(progressCb)) { 46 | progressCb({elapsedMs, timeLeft, progress: elapsedMs / ms}); 47 | } 48 | } while (timeLeft > 0); 49 | } 50 | 51 | /** 52 | * An async/await way of running a method until it doesn't throw an error 53 | * @template [T=any] 54 | * @param {number} times 55 | * @param {(...args: any[]) => Promise} fn 56 | * @param {...any} args 57 | * @returns {Promise} 58 | */ 59 | async function retry (times, fn, ...args) { 60 | let tries = 0; 61 | let done = false; 62 | let res = null; 63 | while (!done && tries < times) { 64 | tries++; 65 | try { 66 | res = await fn(...args); 67 | done = true; 68 | } catch (err) { 69 | if (tries >= times) { 70 | throw err; 71 | } 72 | } 73 | } 74 | return res; 75 | } 76 | 77 | /** 78 | * You can also use `retryInterval` to add a sleep in between retries. This can 79 | * be useful if you want to throttle how fast we retry. 80 | * @template [T=any] 81 | * @param {number} times 82 | * @param {number} sleepMs 83 | * @param {(...args: any[]) => Promise} fn 84 | * @param {...any} args 85 | * @returns {Promise} 86 | */ 87 | async function retryInterval (times, sleepMs, fn, ...args) { 88 | let count = 0; 89 | let wrapped = async () => { 90 | count++; 91 | let res; 92 | try { 93 | res = await fn(...args); 94 | } catch (e) { 95 | // do not pause when finished the last retry 96 | if (count !== times) { 97 | await sleep(sleepMs); 98 | } 99 | throw e; 100 | } 101 | return res; 102 | }; 103 | return await retry(times, wrapped); 104 | } 105 | 106 | const parallel = B.all; 107 | 108 | /** 109 | * Export async functions (Promises) and import this with your ES5 code to use 110 | * it with Node. 111 | * @template [R=any] 112 | * @param {any} promisey 113 | * @param {(err: any, value?: R) => void} cb 114 | * @returns {Promise} 115 | */ 116 | function nodeify (promisey, cb) { // eslint-disable-line promise/prefer-await-to-callbacks 117 | return B.resolve(promisey).nodeify(cb); 118 | } 119 | 120 | /** 121 | * Node-ify an entire object of `Promise`-returning functions 122 | * @param {Record any>} promiseyMap 123 | * @returns {Recordvoid>} 124 | */ 125 | function nodeifyAll (promiseyMap) { 126 | /** @type {Recordvoid>} */ 127 | let cbMap = {}; 128 | for (const [name, fn] of _.toPairs(promiseyMap)) { 129 | cbMap[name] = function (...args) { 130 | const _cb = args.slice(-1)[0]; 131 | args = args.slice(0, -1); 132 | nodeify(fn(...args), _cb); 133 | }; 134 | } 135 | return cbMap; 136 | } 137 | 138 | /** 139 | * @param {(...args: any[]) => any|Promise} fn 140 | * @param {...any} args 141 | */ 142 | function asyncify (fn, ...args) { 143 | B.resolve(fn(...args)).done(); 144 | } 145 | 146 | /** 147 | * Similar to `Array.prototype.map`; runs in serial 148 | * @param {any[]} coll 149 | * @param {(value: any) => any|Promise} mapper 150 | * @returns {Promise} 151 | */ 152 | async function asyncmap (coll, mapper, runInParallel = true) { 153 | if (runInParallel) { 154 | return parallel(coll.map(mapper)); 155 | } 156 | 157 | let newColl = []; 158 | for (let item of coll) { 159 | newColl.push(await mapper(item)); 160 | } 161 | return newColl; 162 | } 163 | 164 | /** 165 | * Similar to `Array.prototype.filter` 166 | * @param {any[]} coll 167 | * @param {(value: any) => any|Promise} filter 168 | * @param {boolean} runInParallel 169 | * @returns {Promise} 170 | */ 171 | async function asyncfilter (coll, filter, runInParallel = true) { 172 | let newColl = []; 173 | if (runInParallel) { 174 | let bools = await parallel(coll.map(filter)); 175 | for (let i = 0; i < coll.length; i++) { 176 | if (bools[i]) { 177 | newColl.push(coll[i]); 178 | } 179 | } 180 | } else { 181 | for (let item of coll) { 182 | if (await filter(item)) { 183 | newColl.push(item); 184 | } 185 | } 186 | } 187 | return newColl; 188 | } 189 | 190 | /** 191 | * Takes a condition (a function returning a boolean or boolean promise), and 192 | * waits until the condition is true. 193 | * 194 | * Throws a `/Condition unmet/` error if the condition has not been satisfied 195 | * within the allocated time, unless an error is provided in the options, as the 196 | * `error` property, which is either thrown itself, or used as the message. 197 | * 198 | * The condition result is returned if it is not falsy. If the condition throws an 199 | * error then this exception will be immediately passed through. 200 | * 201 | * The default options are: `{ waitMs: 5000, intervalMs: 500 }` 202 | * @template T 203 | * @param {() => Promise|T} condFn 204 | * @param {WaitForConditionOptions} [options] 205 | * @returns {Promise} 206 | */ 207 | async function waitForCondition (condFn, options = {}) { 208 | /** @type {WaitForConditionOptions & {waitMs: number, intervalMs: number}} */ 209 | const opts = _.defaults(options, { 210 | waitMs: 5000, 211 | intervalMs: 500, 212 | }); 213 | const debug = opts.logger ? opts.logger.debug.bind(opts.logger) : _.noop; 214 | const error = opts.error; 215 | const begunAt = Date.now(); 216 | const endAt = begunAt + opts.waitMs; 217 | /** @returns {Promise} */ 218 | const spin = async function spin () { 219 | const result = await condFn(); 220 | if (result) { 221 | return result; 222 | } 223 | const now = Date.now(); 224 | const waited = now - begunAt; 225 | const remainingTime = endAt - now; 226 | if (now < endAt) { 227 | debug(`Waited for ${waited} ms so far`); 228 | await B.delay(Math.min(opts.intervalMs, remainingTime)); 229 | return await spin(); 230 | } 231 | // if there is an error option, it is either a string message or an error itself 232 | throw error 233 | ? (_.isString(error) ? new Error(error) : error) 234 | : new Error(`Condition unmet after ${waited} ms. Timing out.`); 235 | }; 236 | return await spin(); 237 | } 238 | 239 | export { 240 | sleep, retry, nodeify, nodeifyAll, retryInterval, asyncify, parallel, 241 | asyncmap, asyncfilter, waitForCondition, longSleep, 242 | }; 243 | 244 | /** 245 | * Options for {@link waitForCondition} 246 | * @typedef WaitForConditionOptions 247 | * @property {number} [waitMs] 248 | * @property {number} [intervalMs] 249 | * @property {{debug: (...args: any[]) => void}} [logger] 250 | * @property {string|Error} [error] 251 | */ 252 | 253 | /** 254 | * Options for {@link longSleep} 255 | * @typedef LongSleepOptions 256 | * @property {number} [thresholdMs] 257 | * @property {number} [intervalMs] 258 | * @property {ProgressCallback?} [progressCb] 259 | */ 260 | 261 | /** 262 | * Parameter provided to a {@link ProgressCallback} 263 | * @typedef Progress 264 | * @property {number} elapsedMs 265 | * @property {number} timeLeft 266 | * @property {number} progress 267 | */ 268 | 269 | /** 270 | * Progress callback for {@link longSleep} 271 | * @callback ProgressCallback 272 | * @param {Progress} progress 273 | * @returns {void} 274 | */ 275 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asyncbox", 3 | "description": "A collection of small async/await utilities", 4 | "tags": [ 5 | "async/await", 6 | "es7", 7 | "async" 8 | ], 9 | "version": "2.9.3", 10 | "author": "jlipps@gmail.com", 11 | "license": "Apache-2.0", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/jlipps/asyncbox.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/jlipps/asyncbox/issues" 18 | }, 19 | "engines": { 20 | "node": ">=16" 21 | }, 22 | "main": "./build/lib/asyncbox.js", 23 | "bin": {}, 24 | "directories": { 25 | "lib": "./lib" 26 | }, 27 | "files": [ 28 | "lib/**/*.js", 29 | "build/lib/**/*" 30 | ], 31 | "dependencies": { 32 | "bluebird": "^3.5.1", 33 | "lodash": "^4.17.4", 34 | "source-map-support": "^0.x" 35 | }, 36 | "scripts": { 37 | "build": "tsc -b", 38 | "clean": "npm run build -- --clean", 39 | "rebuild": "npm run clean; npm run build", 40 | "dev": "npm run build -- --watch", 41 | "prepare": "npm run rebuild", 42 | "test": "mocha --exit --timeout 1m \"./test/**/*-specs.js\"", 43 | "lint": "eslint .", 44 | "watch": "npm run dev" 45 | }, 46 | "devDependencies": { 47 | "@appium/eslint-config-appium": "^8.0.4", 48 | "@appium/eslint-config-appium-ts": "^0.x", 49 | "@appium/tsconfig": "^0.x", 50 | "@types/bluebird": "^3.5.37", 51 | "@types/lodash": "^4.14.189", 52 | "@types/node": "^20.4.7", 53 | "@typescript-eslint/eslint-plugin": "^6.9.0", 54 | "@typescript-eslint/parser": "^6.9.0", 55 | "chai": "^4.2.0", 56 | "chai-as-promised": "^7.1.1", 57 | "eslint": "^8.46.0", 58 | "eslint-config-prettier": "^9.0.0", 59 | "eslint-import-resolver-typescript": "^3.5.5", 60 | "eslint-plugin-import": "^2.28.0", 61 | "eslint-plugin-mocha": "^10.1.0", 62 | "eslint-plugin-promise": "^6.1.1", 63 | "mocha": "^10.0.0", 64 | "sinon": "^16.0.0", 65 | "ts-node": "^10.9.1", 66 | "typescript": "^5.1.6" 67 | }, 68 | "types": "./build/lib/asyncbox.d.ts" 69 | } 70 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "func-names": 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/asyncbox-specs.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | import { sleep, longSleep, retry, retryInterval, nodeify, nodeifyAll, 4 | parallel, asyncmap, asyncfilter, waitForCondition } from '../lib/asyncbox'; 5 | import B from 'bluebird'; 6 | import sinon from 'sinon'; 7 | 8 | 9 | chai.use(chaiAsPromised); 10 | let should = chai.should(); 11 | 12 | describe('sleep', function () { 13 | it('should work like setTimeout', async function () { 14 | let now = Date.now(); 15 | await sleep(20); 16 | (Date.now() - now).should.be.above(19); 17 | }); 18 | }); 19 | 20 | describe('longSleep', function () { 21 | it('should work like sleep in general', async function () { 22 | let now = Date.now(); 23 | await longSleep(20); 24 | (Date.now() - now).should.be.above(19); 25 | }); 26 | it('should work like sleep with values less than threshold', async function () { 27 | const now = Date.now(); 28 | await longSleep(20, {thresholdMs: 100}); 29 | (Date.now() - now).should.be.above(19); 30 | }); 31 | it('should work like sleep with values above threshold, but quantized', async function () { 32 | const now = Date.now(); 33 | await longSleep(50, {thresholdMs: 20, intervalMs: 40}); 34 | (Date.now() - now).should.be.above(79); 35 | }); 36 | it('should trigger a progress callback if specified', async function () { 37 | let callCount = 0; 38 | let curElapsed = 0; 39 | let curTimeLeft = 10000; 40 | let curProgress = 0; 41 | const progressCb = function ({elapsedMs, timeLeft, progress}) { 42 | elapsedMs.should.be.above(curElapsed); 43 | timeLeft.should.be.below(curTimeLeft); 44 | progress.should.be.above(curProgress); 45 | curElapsed = elapsedMs; 46 | curTimeLeft = timeLeft; 47 | curProgress = progress; 48 | callCount += 1; 49 | }; 50 | const now = Date.now(); 51 | await longSleep(500, {thresholdMs: 1, intervalMs: 100, progressCb}); 52 | (Date.now() - now).should.be.above(49); 53 | callCount.should.be.above(3); 54 | (curProgress >= 1).should.be.true; 55 | (curTimeLeft <= 0).should.be.true; 56 | (curElapsed >= 50).should.be.true; 57 | }); 58 | }); 59 | 60 | describe('retry', function () { 61 | let okFnCalls = 0; 62 | let okFn = async function (val1, val2) { 63 | await sleep(15); 64 | okFnCalls++; 65 | return val1 * val2; 66 | }; 67 | let badFnCalls = 0; 68 | let badFn = async function () { 69 | await sleep(15); 70 | badFnCalls++; 71 | throw new Error('bad'); 72 | }; 73 | let eventuallyOkFnCalls = 0; 74 | let eventuallyOkFn = async function (times) { 75 | await sleep(15); 76 | eventuallyOkFnCalls++; 77 | if (eventuallyOkFnCalls < times) { 78 | throw new Error('not ok yet'); 79 | } 80 | return times * times; 81 | }; 82 | let eventuallyOkNoSleepFn = async function (times) { // eslint-disable-line require-await 83 | eventuallyOkFnCalls++; 84 | if (eventuallyOkFnCalls < times) { 85 | throw new Error('not ok yet'); 86 | } 87 | return times * times; 88 | }; 89 | it('should return the result of a passing function', async function () { 90 | let start = Date.now(); 91 | let res = await retry(3, okFn, 5, 4); 92 | res.should.equal(20); 93 | (Date.now() - start).should.be.above(14); 94 | okFnCalls.should.equal(1); 95 | }); 96 | it('should retry a failing function and eventually throw the same err', async function () { 97 | let err = null; 98 | let start = Date.now(); 99 | try { 100 | await retry(3, badFn); 101 | } catch (e) { 102 | err = e; 103 | } 104 | should.exist(err); 105 | err.message.should.equal('bad'); 106 | badFnCalls.should.equal(3); 107 | (Date.now() - start).should.be.above(44); 108 | }); 109 | it('should return the correct value with a function that eventually passes', async function () { 110 | let err = null; 111 | let start = Date.now(); 112 | try { 113 | await retry(3, eventuallyOkFn, 4); 114 | } catch (e) { 115 | err = e; 116 | } 117 | should.exist(err); 118 | err.message.should.equal('not ok yet'); 119 | eventuallyOkFnCalls.should.equal(3); 120 | (Date.now() - start).should.be.above(35); 121 | 122 | // rerun with ok number of calls 123 | start = Date.now(); 124 | eventuallyOkFnCalls = 0; 125 | let res = await retry(3, eventuallyOkFn, 3); 126 | eventuallyOkFnCalls.should.equal(3); 127 | res.should.equal(9); 128 | (Date.now() - start).should.be.above(35); 129 | }); 130 | describe('retryInterval', function () { 131 | it('should return the correct value with a function that eventually passes', async function () { 132 | eventuallyOkFnCalls = 0; 133 | let err = null; 134 | let start = Date.now(); 135 | try { 136 | await retryInterval(3, 15, eventuallyOkNoSleepFn, 4); 137 | } catch (e) { 138 | err = e; 139 | } 140 | should.exist(err); 141 | err.message.should.equal('not ok yet'); 142 | eventuallyOkFnCalls.should.equal(3); 143 | (Date.now() - start).should.be.above(30); 144 | 145 | // rerun with ok number of calls 146 | start = Date.now(); 147 | eventuallyOkFnCalls = 0; 148 | let res = await retryInterval(3, 15, eventuallyOkNoSleepFn, 3); 149 | eventuallyOkFnCalls.should.equal(3); 150 | res.should.equal(9); 151 | // XXX: flaky 152 | (Date.now() - start).should.be.least(30); 153 | }); 154 | it('should not wait on the final error', async function () { 155 | const start = Date.now(); 156 | try { 157 | await retryInterval(3, 2000, badFnCalls); 158 | } catch (err) { 159 | (Date.now() - start).should.be.below(4100); 160 | } 161 | }); 162 | }); 163 | }); 164 | 165 | describe('nodeifyAll', function () { 166 | let asyncFn = async function (val) { 167 | await sleep(15); 168 | return val; 169 | }; 170 | let asyncFn2 = async function (val) { 171 | await sleep(15); 172 | return [val, val + val]; 173 | }; 174 | let badAsyncFn = async function () { 175 | await sleep(15); 176 | throw new Error('boo'); 177 | }; 178 | let cbMap = nodeifyAll({asyncFn, asyncFn2, badAsyncFn}); 179 | it('should turn async functions into nodey things', function (done) { 180 | let start = Date.now(); 181 | nodeify(asyncFn('foo'), function (err, val, val2) { // eslint-disable-line promise/prefer-await-to-callbacks 182 | should.not.exist(err); 183 | should.not.exist(val2); 184 | val.should.equal('foo'); 185 | (Date.now() - start).should.be.least(14); 186 | done(); 187 | }); 188 | }); 189 | it('should turn async functions into nodey things via nodeifyAll', function (done) { 190 | let start = Date.now(); 191 | cbMap.asyncFn('foo', function (err, val, val2) { // eslint-disable-line promise/prefer-await-to-callbacks 192 | should.not.exist(err); 193 | should.not.exist(val2); 194 | val.should.equal('foo'); 195 | (Date.now() - start).should.be.least(14); 196 | done(); 197 | }); 198 | }); 199 | it('should turn async functions into nodey things with mult params', function (done) { 200 | let start = Date.now(); 201 | nodeify(asyncFn2('foo'), function (err, val) { // eslint-disable-line promise/prefer-await-to-callbacks 202 | should.not.exist(err); 203 | val.should.eql(['foo', 'foofoo']); 204 | (Date.now() - start).should.be.least(14); 205 | done(); 206 | }); 207 | }); 208 | it('should handle errors correctly', function (done) { 209 | let start = Date.now(); 210 | nodeify(badAsyncFn('foo'), function (err, val) { // eslint-disable-line promise/prefer-await-to-callbacks 211 | should.not.exist(val); 212 | err.message.should.equal('boo'); 213 | (Date.now() - start).should.be.least(14); 214 | done(); 215 | }); 216 | }); 217 | }); 218 | 219 | // describe('nodeifyAll', () => { 220 | // let asyncFn = async (val) => { 221 | // await sleep(15); 222 | // return val; 223 | // }; 224 | // let asyncFn2 = async (val) => { 225 | // await sleep(15); 226 | // return [val, val + val]; 227 | // }; 228 | // let badAsyncFn = async () => { 229 | // await sleep(15); 230 | // throw new Error('boo'); 231 | // }; 232 | // }); 233 | 234 | describe('parallel', function () { 235 | let asyncFn = async function (val) { 236 | await sleep(50); 237 | return val; 238 | }; 239 | let badAsyncFn = async function () { 240 | await sleep(20); 241 | throw new Error('boo'); 242 | }; 243 | it('should perform tasks in parallel and return results', async function () { 244 | let vals = [1, 2, 3]; 245 | let promises = []; 246 | let start = Date.now(); 247 | for (let v of vals) { 248 | promises.push(asyncFn(v)); 249 | } 250 | let res = await parallel(promises); 251 | (Date.now() - start).should.be.above(49); 252 | (Date.now() - start).should.be.below(59); 253 | res.sort().should.eql([1, 2, 3]); 254 | }); 255 | it('should error with first response', async function () { 256 | let vals = [1, 2, 3]; 257 | let promises = []; 258 | let start = Date.now(); 259 | for (let v of vals) { 260 | promises.push(asyncFn(v)); 261 | } 262 | promises.push(badAsyncFn()); 263 | let err = null; 264 | let res = []; 265 | try { 266 | res = await parallel(promises); 267 | } catch (e) { 268 | err = e; 269 | } 270 | (Date.now() - start).should.be.above(19); 271 | (Date.now() - start).should.be.below(49); 272 | should.exist(err); 273 | res.should.eql([]); 274 | }); 275 | 276 | describe('waitForCondition', function () { 277 | let requestSpy; 278 | beforeEach(function () { 279 | requestSpy = sinon.spy(B, 'delay'); 280 | }); 281 | afterEach(function () { 282 | B.delay.restore(); 283 | }); 284 | it('should wait and succeed', async function () { 285 | let ref = Date.now(); 286 | function condFn () { 287 | return Date.now() - ref > 200; 288 | } 289 | const result = await waitForCondition(condFn, {waitMs: 1000, intervalMs: 10}); 290 | let duration = Date.now() - ref; 291 | duration.should.be.above(200); 292 | duration.should.be.below(250); 293 | isNaN(result).should.be.false; 294 | }); 295 | it('should wait and fail', async function () { 296 | let ref = Date.now(); 297 | function condFn () { 298 | return Date.now() - ref > 200; 299 | } 300 | await (waitForCondition(condFn, {waitMs: 100, intervalMs: 10})) 301 | .should.be.rejectedWith(/Condition unmet/); 302 | }); 303 | it('should not exceed implicit wait timeout', async function () { 304 | let ref = Date.now(); 305 | function condFn () { 306 | return Date.now() - ref > 15; 307 | } 308 | await (waitForCondition(condFn, {waitMs: 20, intervalMs: 10})); 309 | let getLastCall = requestSpy.getCall(1); 310 | getLastCall.args[0].should.be.most(10); 311 | }); 312 | }); 313 | }); 314 | 315 | describe('asyncmap', function () { 316 | const mapper = async function (el) { 317 | await sleep(10); 318 | return el * 2; 319 | }; 320 | const coll = [1, 2, 3]; 321 | it('should map elements one at a time', async function () { 322 | let start = Date.now(); 323 | (await asyncmap(coll, mapper, false)).should.eql([2, 4, 6]); 324 | (Date.now() - start).should.be.least(30); 325 | }); 326 | it('should map elements in parallel', async function () { 327 | let start = Date.now(); 328 | (await asyncmap(coll, mapper)).should.eql([2, 4, 6]); 329 | (Date.now() - start).should.be.most(20); 330 | }); 331 | it('should handle an empty array', async function () { 332 | (await asyncmap([], mapper, false)).should.eql([]); 333 | }); 334 | it('should handle an empty array in parallel', async function () { 335 | (await asyncmap([], mapper)).should.eql([]); 336 | }); 337 | }); 338 | 339 | describe('asyncfilter', function () { 340 | const filter = async function (el) { 341 | await sleep(5); 342 | return el % 2 === 0; 343 | }; 344 | const coll = [1, 2, 3, 4, 5]; 345 | it('should filter elements one at a time', async function () { 346 | let start = Date.now(); 347 | (await asyncfilter(coll, filter, false)).should.eql([2, 4]); 348 | (Date.now() - start).should.be.above(19); 349 | }); 350 | it('should filter elements in parallel', async function () { 351 | let start = Date.now(); 352 | (await asyncfilter(coll, filter)).should.eql([2, 4]); 353 | (Date.now() - start).should.be.below(9); 354 | }); 355 | it('should handle an empty array', async function () { 356 | let start = Date.now(); 357 | (await asyncfilter([], filter, false)).should.eql([]); 358 | (Date.now() - start).should.be.below(9); 359 | }); 360 | it('should handle an empty array in parallel', async function () { 361 | let start = Date.now(); 362 | (await asyncfilter([], filter)).should.eql([]); 363 | (Date.now() - start).should.be.below(9); 364 | }); 365 | }); 366 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "@appium/tsconfig/tsconfig.json", 4 | "compilerOptions": { 5 | "esModuleInterop": true, 6 | "strict": false, 7 | "outDir": "build", 8 | "types": ["node"], 9 | "checkJs": true 10 | }, 11 | "include": [ 12 | "lib" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------