diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c1870cf..1ed55d5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,12 +10,12 @@ jobs: fail-fast: false matrix: node-version: - - 14 - - 12 - - 10 + - 20 + - 18 + - 16 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/index.d.ts b/index.d.ts index d3d404b..fcd5447 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,107 +1,125 @@ -declare namespace delay { - interface ClearablePromise extends Promise { - /** - Clears the delay and settles the promise. - */ - clear(): void; - } - +export type Options = { /** - Minimal subset of `AbortSignal` that delay will use if passed. - This avoids a dependency on dom.d.ts. - The dom.d.ts `AbortSignal` is compatible with this one. - */ - interface AbortSignal { - readonly aborted: boolean; - addEventListener( - type: 'abort', - listener: () => void, - options?: {once?: boolean} - ): void; - removeEventListener(type: 'abort', listener: () => void): void; - } + A value to resolve in the returned promise. - interface Options { - /** - An optional AbortSignal to abort the delay. - If aborted, the Promise will be rejected with an AbortError. - */ - signal?: AbortSignal; - } -} + @example + ``` + import delay from 'delay'; -type Delay = { - /** - Create a promise which resolves after the specified `milliseconds`. + const result = await delay(100, {value: '🦄'}); - @param milliseconds - Milliseconds to delay the promise. - @returns A promise which resolves after the specified `milliseconds`. + // Executed after 100 milliseconds + console.log(result); + //=> '🦄' + ``` */ - (milliseconds: number, options?: delay.Options): delay.ClearablePromise; + value?: T; /** - Create a promise which resolves after the specified `milliseconds`. + An `AbortSignal` to abort the delay. - @param milliseconds - Milliseconds to delay the promise. - @returns A promise which resolves after the specified `milliseconds`. - */ - ( - milliseconds: number, - options?: delay.Options & { - /** - Value to resolve in the returned promise. - */ - value: T; - } - ): delay.ClearablePromise; + The returned promise will be rejected with an `AbortError` if the signal is aborted. - /** - Create a promise which resolves after a random amount of milliseconds between `minimum` and `maximum` has passed. + @example + ``` + import delay from 'delay'; - Useful for tests and web scraping since they can have unpredictable performance. For example, if you have a test that asserts a method should not take longer than a certain amount of time, and then run it on a CI, it could take longer. So with `.range()`, you could give it a threshold instead. + const abortController = new AbortController(); - @param minimum - Minimum amount of milliseconds to delay the promise. - @param maximum - Maximum amount of milliseconds to delay the promise. - @returns A promise which resolves after a random amount of milliseconds between `maximum` and `maximum` has passed. - */ - range( - minimum: number, - maximum: number, - options?: delay.Options & { - /** - Value to resolve in the returned promise. - */ - value: T; - } - ): delay.ClearablePromise; - - // TODO: Allow providing value type after https://github.com/Microsoft/TypeScript/issues/5413 is resolved. - /** - Create a promise which rejects after the specified `milliseconds`. + setTimeout(() => { + abortController.abort(); + }, 500); - @param milliseconds - Milliseconds to delay the promise. - @returns A promise which rejects after the specified `milliseconds`. + try { + await delay(1000, {signal: abortController.signal}); + } catch (error) { + // 500 milliseconds later + console.log(error.name) + //=> 'AbortError' + } + ``` */ - reject( - milliseconds: number, - options?: delay.Options & { - /** - Value to reject in the returned promise. - */ - value?: unknown; - } - ): delay.ClearablePromise; + signal?: AbortSignal; }; -declare const delay: Delay & { - // The types are intentionally loose to make it work with both Node.js and browser versions of these methods. - createWithTimers(timers: { - clearTimeout: (timeoutId: any) => void; - setTimeout: (callback: (...args: any[]) => void, milliseconds: number, ...args: any[]) => unknown; - }): Delay; +/** +Create a promise which resolves after the specified `milliseconds`. - // TODO: Remove this for the next major release. - default: typeof delay; -}; +@param milliseconds - Milliseconds to delay the promise. +@returns A promise which resolves after the specified `milliseconds`. + +@example +``` +import delay from 'delay'; + +bar(); + +await delay(100); + +// Executed 100 milliseconds later +baz(); +``` +*/ +export default function delay( + milliseconds: number, + options?: Options +): Promise; + +/** +Create a promise which resolves after a random amount of milliseconds between `minimum` and `maximum` has passed. + +Useful for tests and web scraping since they can have unpredictable performance. For example, if you have a test that asserts a method should not take longer than a certain amount of time, and then run it on a CI, it could take longer. So with this method, you could give it a threshold instead. + +@param minimum - Minimum amount of milliseconds to delay the promise. +@param maximum - Maximum amount of milliseconds to delay the promise. +@returns A promise which resolves after a random amount of milliseconds between `maximum` and `maximum` has passed. +*/ +export function rangeDelay( + minimum: number, + maximum: number, + options?: Options +): Promise; + +/** +Clears the delay and settles the promise. + +If you pass in a promise that is already cleared or a promise coming from somewhere else, it does nothing. + +@example +``` +import delay, {clearDelay} from 'delay'; + +const delayedPromise = delay(1000, {value: 'Done'}); + +setTimeout(() => { + clearDelay(delayedPromise); +}, 500); + +// 500 milliseconds later +console.log(await delayedPromise); +//=> 'Done' +``` +*/ +export function clearDelay(delayPromise: Promise): void; + +// The types are intentionally loose to make it work with both Node.js and browser versions of these methods. +/** +Creates a new `delay` instance using the provided functions for clearing and setting timeouts. Useful if you're about to stub timers globally, but you still want to use `delay` to manage your tests. + +@example +``` +import {createDelay} from 'delay'; + +const customDelay = createDelay({clearTimeout, setTimeout}); + +const result = await customDelay(100, {value: '🦄'}); -export = delay; +// Executed after 100 milliseconds +console.log(result); +//=> '🦄' +``` +*/ +export function createDelay(timers: { + clearTimeout: (timeoutId: any) => void; + setTimeout: (callback: (...args: any[]) => void, milliseconds: number, ...args: any[]) => unknown; +}): typeof delay; diff --git a/index.js b/index.js index 7dd2309..f0bcc2e 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,3 @@ -'use strict'; - // From https://github.com/sindresorhus/random-int/blob/c37741b56f76b9160b0b63dae4e9c64875128146/index.js#L13-L15 const randomInteger = (minimum, maximum) => Math.floor((Math.random() * (maximum - minimum + 1)) + minimum); @@ -9,64 +7,64 @@ const createAbortError = () => { return error; }; -const createDelay = ({clearTimeout: defaultClear, setTimeout: set, willResolve}) => (ms, {value, signal} = {}) => { - if (signal && signal.aborted) { - return Promise.reject(createAbortError()); - } +const clearMethods = new WeakMap(); - let timeoutId; - let settle; - let rejectFn; - const clear = defaultClear || clearTimeout; +export function createDelay({clearTimeout: defaultClear, setTimeout: defaultSet} = {}) { + // We cannot use `async` here as we need the promise identity. + return (milliseconds, {value, signal} = {}) => { + // TODO: Use `signal?.throwIfAborted()` when targeting Node.js 18. + if (signal?.aborted) { + return Promise.reject(createAbortError()); + } - const signalListener = () => { - clear(timeoutId); - rejectFn(createAbortError()); - }; + let timeoutId; + let settle; + let rejectFunction; + const clear = defaultClear ?? clearTimeout; - const cleanup = () => { - if (signal) { - signal.removeEventListener('abort', signalListener); - } - }; + const signalListener = () => { + clear(timeoutId); + rejectFunction(createAbortError()); + }; - const delayPromise = new Promise((resolve, reject) => { - settle = () => { - cleanup(); - if (willResolve) { - resolve(value); - } else { - reject(value); + const cleanup = () => { + if (signal) { + signal.removeEventListener('abort', signalListener); } }; - rejectFn = reject; - timeoutId = (set || setTimeout)(settle, ms); - }); + const delayPromise = new Promise((resolve, reject) => { + settle = () => { + cleanup(); + resolve(value); + }; + + rejectFunction = reject; + timeoutId = (defaultSet ?? setTimeout)(settle, milliseconds); + }); - if (signal) { - signal.addEventListener('abort', signalListener, {once: true}); - } + if (signal) { + signal.addEventListener('abort', signalListener, {once: true}); + } - delayPromise.clear = () => { - clear(timeoutId); - timeoutId = null; - settle(); + clearMethods.set(delayPromise, () => { + clear(timeoutId); + timeoutId = null; + settle(); + }); + + return delayPromise; }; +} - return delayPromise; -}; +const delay = createDelay(); -const createWithTimers = clearAndSet => { - const delay = createDelay({...clearAndSet, willResolve: true}); - delay.reject = createDelay({...clearAndSet, willResolve: false}); - delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options); - return delay; -}; +export default delay; -const delay = createWithTimers(); -delay.createWithTimers = createWithTimers; +export async function rangeDelay(minimum, maximum, options = {}) { + return delay(randomInteger(minimum, maximum), options); +} -module.exports = delay; -// TODO: Remove this for the next major release -module.exports.default = delay; +export function clearDelay(promise) { + clearMethods.get(promise)?.(); +} diff --git a/index.test-d.ts b/index.test-d.ts index c7cfbd9..ec1cc93 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,33 +1,25 @@ -/// import {expectType} from 'tsd'; -import delay = require('.'); -import {ClearablePromise} from '.'; +import delay, {rangeDelay, createDelay} from './index.js'; -expectType>(delay(200)); +expectType>(delay(200)); -expectType>(delay(200, {value: '🦄'})); -expectType>(delay(200, {value: 0})); -expectType>( - delay(200, {signal: new AbortController().signal}) +expectType>(delay(200, {value: '🦄'})); +expectType>(delay(200, {value: 0})); +expectType>( + delay(200, {signal: new AbortController().signal}), ); -expectType>(delay.range(50, 200, {value: 0})); +expectType>(rangeDelay(50, 200, {value: 0})); -expectType>(delay.reject(200, {value: '🦄'})); -expectType>(delay.reject(200, {value: 0})); +const customDelay = createDelay({clearTimeout, setTimeout}); +expectType>(customDelay(200)); -const customDelay = delay.createWithTimers({clearTimeout, setTimeout}); -expectType>(customDelay(200)); +expectType>(customDelay(200, {value: '🦄'})); +expectType>(customDelay(200, {value: 0})); -expectType>(customDelay(200, {value: '🦄'})); -expectType>(customDelay(200, {value: 0})); - -expectType>(customDelay.reject(200, {value: '🦄'})); -expectType>(customDelay.reject(200, {value: 0})); - -const unrefDelay = delay.createWithTimers({ +const unrefDelay = createDelay({ clearTimeout, - setTimeout(...args) { - return setTimeout(...args).unref() + setTimeout(...arguments_) { + return setTimeout(...arguments_).unref(); }, }); diff --git a/package.json b/package.json index c5d45ea..85182b8 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,13 @@ "email": "sindresorhus@gmail.com", "url": "https://sindresorhus.com" }, + "type": "module", + "exports": { + "types": "./index.d.ts", + "default": "./index.js" + }, "engines": { - "node": ">=10" + "node": ">=16" }, "scripts": { "test": "xo && ava && tsd" @@ -43,12 +48,13 @@ "random" ], "devDependencies": { - "abort-controller": "^3.0.0", - "ava": "1.4.1", - "currently-unhandled": "^0.4.1", - "in-range": "^1.0.0", - "time-span": "^3.0.0", - "tsd": "^0.7.1", - "xo": "^0.24.0" + "ava": "5.2.0", + "in-range": "^3.0.0", + "time-span": "^5.1.0", + "tsd": "^0.28.1", + "xo": "^0.54.2" + }, + "ava": { + "serial": true } } diff --git a/readme.md b/readme.md index 18ecf2d..0f1b465 100644 --- a/readme.md +++ b/readme.md @@ -2,44 +2,38 @@ > Delay a promise a specified amount of time -*If you target [Node.js 15](https://medium.com/@nodejs/node-js-v15-0-0-is-here-deb00750f278) or later, you can do `await require('timers/promises').setTimeout(1000)` instead.* +*If you target Node.js 16 or later, you can use `import {setTimeout} from 'node:timers/promises'; await setTimeout(1000);` instead. This package can still be useful if you need browser support or the extra features.* ## Install -``` -$ npm install delay +```sh +npm install delay ``` ## Usage ```js -const delay = require('delay'); +import delay from 'delay'; -(async () => { - bar(); +bar(); - await delay(100); +await delay(100); - // Executed 100 milliseconds later - baz(); -})(); +// Executed 100 milliseconds later +baz(); ``` ## API -### delay(milliseconds, options?) +### delay(milliseconds, options?) default import Create a promise which resolves after the specified `milliseconds`. -### delay.reject(milliseconds, options?) - -Create a promise which rejects after the specified `milliseconds`. - -### delay.range(minimum, maximum, options?) +### rangeDelay(minimum, maximum, options?) Create a promise which resolves after a random amount of milliseconds between `minimum` and `maximum` has passed. -Useful for tests and web scraping since they can have unpredictable performance. For example, if you have a test that asserts a method should not take longer than a certain amount of time, and then run it on a CI, it could take longer. So with `.range()`, you could give it a threshold instead. +Useful for tests and web scraping since they can have unpredictable performance. For example, if you have a test that asserts a method should not take longer than a certain amount of time, and then run it on a CI, it could take longer. So with this method, you could give it a threshold instead. #### milliseconds #### mininum @@ -57,110 +51,76 @@ Type: `object` Type: `unknown` -Optional value to resolve or reject in the returned promise. - -##### signal - -Type: [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) - -The returned promise will be rejected with an AbortError if the signal is aborted. AbortSignal is available in all modern browsers and there is a [ponyfill for Node.js](https://github.com/mysticatea/abort-controller). +A value to resolve in the returned promise. -### delayPromise.clear() +```js +import delay from 'delay'; -Clears the delay and settles the promise. +const result = await delay(100, {value: '🦄'}); -### delay.createWithTimers({clearTimeout, setTimeout}) +// Executed after 100 milliseconds +console.log(result); +//=> '🦄' +``` -Creates a new `delay` instance using the provided functions for clearing and setting timeouts. Useful if you're about to stub timers globally, but you still want to use `delay` to manage your tests. +##### signal -## Advanced usage +Type: [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) -Passing a value: +The returned promise will be rejected with an `AbortError` if the signal is aborted. ```js -const delay = require('delay'); +import delay from 'delay'; + +const abortController = new AbortController(); -(async() => { - const result = await delay(100, {value: '🦄'}); +setTimeout(() => { + abortController.abort(); +}, 500); - // Executed after 100 milliseconds - console.log(result); - //=> '🦄' -})(); +try { + await delay(1000, {signal: abortController.signal}); +} catch (error) { + // 500 milliseconds later + console.log(error.name) + //=> 'AbortError' +} ``` -Using `delay.reject()`, which optionally accepts a value and rejects it `ms` later: +### clearDelay(delayPromise) -```js -const delay = require('delay'); - -(async () => { - try { - await delay.reject(100, {value: new Error('🦄')}); - - console.log('This is never executed'); - } catch (error) { - // 100 milliseconds later - console.log(error); - //=> [Error: 🦄] - } -})(); -``` +Clears the delay and settles the promise. -You can settle the delay early by calling `.clear()`: +If you pass in a promise that is already cleared or a promise coming from somewhere else, it does nothing. ```js -const delay = require('delay'); +import delay, {clearDelay} from 'delay'; -(async () => { - const delayedPromise = delay(1000, {value: 'Done'}); +const delayedPromise = delay(1000, {value: 'Done'}); - setTimeout(() => { - delayedPromise.clear(); - }, 500); +setTimeout(() => { + clearDelay(delayedPromise); +}, 500); - // 500 milliseconds later - console.log(await delayedPromise); - //=> 'Done' -})(); +// 500 milliseconds later +console.log(await delayedPromise); +//=> 'Done' ``` -You can abort the delay with an AbortSignal: - -```js -const delay = require('delay'); - -(async () => { - const abortController = new AbortController(); - - setTimeout(() => { - abortController.abort(); - }, 500); - - try { - await delay(1000, {signal: abortController.signal}); - } catch (error) { - // 500 milliseconds later - console.log(error.name) - //=> 'AbortError' - } -})(); -``` +### createDelay({clearTimeout, setTimeout}) -Create a new instance that is unaffected by libraries such as [lolex](https://github.com/sinonjs/lolex/): +Creates a new `delay` instance using the provided functions for clearing and setting timeouts. Useful if you're about to stub timers globally, but you still want to use `delay` to manage your tests. ```js -const delay = require('delay'); +import {createDelay} from 'delay'; -const customDelay = delay.createWithTimers({clearTimeout, setTimeout}); +const customDelay = createDelay({clearTimeout, setTimeout}); -(async() => { - const result = await customDelay(100, {value: '🦄'}); +const result = await customDelay(100, {value: '🦄'}); - // Executed after 100 milliseconds - console.log(result); - //=> '🦄' -})(); +// Executed after 100 milliseconds +console.log(result); +//=> '🦄' ``` ## Related diff --git a/test.js b/test.js index a8f28d6..c83dd28 100644 --- a/test.js +++ b/test.js @@ -1,140 +1,95 @@ -import {serial as test} from 'ava'; +import test from 'ava'; import timeSpan from 'time-span'; import inRange from 'in-range'; -import currentlyUnhandled from 'currently-unhandled'; -import {AbortController} from 'abort-controller'; -import delay from '.'; - -const getCurrentlyUnhandled = currentlyUnhandled(); +import delay, {clearDelay, rangeDelay, createDelay} from './index.js'; test('returns a resolved promise', async t => { const end = timeSpan(); await delay(50); - t.true(inRange(end(), 30, 70), 'is delayed'); -}); - -test('returns a rejected promise', async t => { - const end = timeSpan(); - await t.throwsAsync( - delay.reject(50, {value: new Error('foo')}), - 'foo' - ); - t.true(inRange(end(), 30, 70), 'is delayed'); + t.true(inRange(end(), {start: 30, end: 70}), 'is delayed'); }); test('able to resolve a falsy value', async t => { t.is( await delay(50, {value: 0}), - 0 + 0, ); }); -test('able to reject a falsy value', async t => { - t.plan(1); - try { - await delay.reject(50, {value: false}); - } catch (error) { - t.is(error, false); - } -}); - test('delay defaults to 0 ms', async t => { const end = timeSpan(); await delay(); t.true(end() < 30); }); -test('reject will cause an unhandledRejection if not caught', async t => { - const reason = new Error('foo'); - const promise = delay.reject(0, {value: reason}); - - await delay(10); - - t.deepEqual(getCurrentlyUnhandled(), [{ - reason, - promise - }], 'Promise should be unhandled'); - - promise.catch(() => {}); - await delay(10); - - t.deepEqual(getCurrentlyUnhandled(), [], 'no unhandled rejections now'); -}); - test('can clear a delayed resolution', async t => { const end = timeSpan(); const delayPromise = delay(1000, {value: 'success!'}); - delayPromise.clear(); + clearDelay(delayPromise); const success = await delayPromise; t.true(end() < 30); t.is(success, 'success!'); }); -test('can clear a delayed rejection', async t => { - const end = timeSpan(); - const delayPromise = delay.reject(1000, {value: new Error('error!')}); - delayPromise.clear(); - - await t.throwsAsync(delayPromise, /error!/); - t.true(end() < 30); -}); - test('resolution can be aborted with an AbortSignal', async t => { const end = timeSpan(); const abortController = new AbortController(); - setTimeout(() => abortController.abort(), 1); + + setTimeout(() => { + abortController.abort(); + }, 1); + await t.throwsAsync( delay(1000, {signal: abortController.signal}), - {name: 'AbortError'} + {name: 'AbortError'}, ); + t.true(end() < 30); }); test('resolution can be aborted with an AbortSignal if a value is passed', async t => { const end = timeSpan(); const abortController = new AbortController(); - setTimeout(() => abortController.abort(), 1); + + setTimeout(() => { + abortController.abort(); + }, 1); + await t.throwsAsync( delay(1000, {value: 123, signal: abortController.signal}), - {name: 'AbortError'} + {name: 'AbortError'}, ); - t.true(end() < 30); -}); -test('rejection can be aborted with an AbortSignal if a value is passed', async t => { - const end = timeSpan(); - const abortController = new AbortController(); - setTimeout(() => abortController.abort(), 1); - await t.throwsAsync( - delay.reject(1000, {value: new Error(), signal: abortController.signal}), - {name: 'AbortError'} - ); t.true(end() < 30); }); test('rejects with AbortError if AbortSignal is already aborted', async t => { const end = timeSpan(); + const abortController = new AbortController(); abortController.abort(); + await t.throwsAsync( delay(1000, {signal: abortController.signal}), - {name: 'AbortError'} + {name: 'AbortError'}, ); + t.true(end() < 30); }); test('returns a promise that is resolved in a random range of time', async t => { const end = timeSpan(); - await delay.range(50, 150); - t.true(inRange(end(), 30, 170), 'is delayed'); + await rangeDelay(50, 150); + t.true(inRange(end(), {start: 30, end: 170}), 'is delayed'); }); test('can create a new instance with fixed timeout methods', async t => { const cleared = []; const callbacks = []; - const custom = delay.createWithTimers({ + + const custom = createDelay({ clearTimeout(handle) { cleared.push(handle); }, @@ -143,7 +98,7 @@ test('can create a new instance with fixed timeout methods', async t => { const handle = Symbol('handle'); callbacks.push({callback, handle, ms}); return handle; - } + }, }); const first = custom(50, {value: 'first'}); @@ -152,26 +107,16 @@ test('can create a new instance with fixed timeout methods', async t => { callbacks[0].callback(); t.is(await first, 'first'); - const second = custom.reject(40, {value: 'second'}); + const second = custom(40, {value: 'second'}); t.is(callbacks.length, 2); t.is(callbacks[1].ms, 40); callbacks[1].callback(); - try { - await second; - } catch (error) { - t.is(error, 'second'); - } + t.is(await second, 'second'); const third = custom(60); t.is(callbacks.length, 3); t.is(callbacks[2].ms, 60); - third.clear(); + clearDelay(third); t.is(cleared.length, 1); t.is(cleared[0], callbacks[2].handle); - - const fourth = custom.range(50, 150, {value: 'fourth'}); - t.is(callbacks.length, 4); - t.true(inRange(callbacks[2].ms, 50, 150)); - callbacks[3].callback(); - t.is(await fourth, 'fourth'); });