diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/index.js b/index.js deleted file mode 100644 index ebd6c2d..0000000 --- a/index.js +++ /dev/null @@ -1,107 +0,0 @@ -export class TimeoutError extends Error { - constructor(message) { - super(message); - this.name = 'TimeoutError'; - } -} - -/** -An error to be thrown when the request is aborted by AbortController. -DOMException is thrown instead of this Error when DOMException is available. -*/ -export class AbortError extends Error { - constructor(message) { - super(); - this.name = 'AbortError'; - this.message = message; - } -} - -/** -TODO: Remove AbortError and just throw DOMException when targeting Node 18. -*/ -const getDOMException = errorMessage => globalThis.DOMException === undefined - ? new AbortError(errorMessage) - : new DOMException(errorMessage); - -/** -TODO: Remove below function and just 'reject(signal.reason)' when targeting Node 18. -*/ -const getAbortedReason = signal => { - const reason = signal.reason === undefined - ? getDOMException('This operation was aborted.') - : signal.reason; - - return reason instanceof Error ? reason : getDOMException(reason); -}; - -export default function pTimeout(promise, options) { - const { - milliseconds, - fallback, - message, - customTimers = {setTimeout, clearTimeout}, - } = options; - - let timer; - - const cancelablePromise = new Promise((resolve, reject) => { - if (typeof milliseconds !== 'number' || Math.sign(milliseconds) !== 1) { - throw new TypeError(`Expected \`milliseconds\` to be a positive number, got \`${milliseconds}\``); - } - - if (milliseconds === Number.POSITIVE_INFINITY) { - resolve(promise); - return; - } - - if (options.signal) { - const {signal} = options; - if (signal.aborted) { - reject(getAbortedReason(signal)); - } - - signal.addEventListener('abort', () => { - reject(getAbortedReason(signal)); - }); - } - - timer = customTimers.setTimeout.call(undefined, () => { - if (fallback) { - try { - resolve(fallback()); - } catch (error) { - reject(error); - } - - return; - } - - const errorMessage = typeof message === 'string' ? message : `Promise timed out after ${milliseconds} milliseconds`; - const timeoutError = message instanceof Error ? message : new TimeoutError(errorMessage); - - if (typeof promise.cancel === 'function') { - promise.cancel(); - } - - reject(timeoutError); - }, milliseconds); - - (async () => { - try { - resolve(await promise); - } catch (error) { - reject(error); - } finally { - customTimers.clearTimeout.call(undefined, timer); - } - })(); - }); - - cancelablePromise.clear = () => { - customTimers.clearTimeout.call(undefined, timer); - timer = undefined; - }; - - return cancelablePromise; -} diff --git a/package.json b/package.json index e8687aa..a8c4730 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "xo && ava && tsd" }, "files": [ - "index.js", + "src/index.js", "index.d.ts" ], "keywords": [ @@ -41,6 +41,7 @@ "delay": "^5.0.0", "in-range": "^3.0.0", "p-cancelable": "^4.0.1", + "prettier": "^2.8.1", "time-span": "^5.1.0", "tsd": "^0.22.0", "xo": "^0.51.0" diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b67623f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,254 @@ +import PCancelable from "p-cancelable"; + +export interface ClearablePromise extends Promise { + /** + Clear the timeout. + */ + clear: () => void; +} + +export type Options = { + /** + Milliseconds before timing out. + + Passing `Infinity` will cause it to never time out. + */ + milliseconds: number; + + /** + Do something other than rejecting with an error on timeout. + + You could for example retry: + + @example + ``` + import {setTimeout} from 'node:timers/promises'; + import pTimeout from 'p-timeout'; + + const delayedPromise = () => setTimeout(200); + + await pTimeout(delayedPromise(), { + milliseconds: 50, + fallback: () => { + return pTimeout(delayedPromise(), { + milliseconds: 300 + }); + }, + }); + ``` + */ + fallback?: () => ReturnType | Promise; + + /** + Specify a custom error message or error. + + If you do a custom error, it's recommended to sub-class `pTimeout.TimeoutError`. + */ + message?: string | Error; + + /** + Custom implementations for the `setTimeout` and `clearTimeout` functions. + + Useful for testing purposes, in particular to work around [`sinon.useFakeTimers()`](https://sinonjs.org/releases/latest/fake-timers/). + + @example + ``` + import pTimeout from 'p-timeout'; + import sinon from 'sinon'; + + const originalSetTimeout = setTimeout; + const originalClearTimeout = clearTimeout; + + sinon.useFakeTimers(); + + // Use `pTimeout` without being affected by `sinon.useFakeTimers()`: + await pTimeout(doSomething(), { + milliseconds: 2000, + customTimers: { + setTimeout: originalSetTimeout, + clearTimeout: originalClearTimeout + } + }); + ``` + */ + readonly customTimers?: { + setTimeout: typeof globalThis.setTimeout; + clearTimeout: typeof globalThis.clearTimeout; + }; + + /** + You can abort the promise using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). + + _Requires Node.js 16 or later._ + + @example + ``` + import pTimeout from 'p-timeout'; + import delay from 'delay'; + + const delayedPromise = delay(3000); + + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, 100); + + await pTimeout(delayedPromise, { + milliseconds: 2000, + signal: abortController.signal + }); + ``` + */ + signal?: globalThis.AbortSignal; +}; + +/** +Timeout a promise after a specified amount of time. + +If you pass in a cancelable promise, specifically a promise with a `.cancel()` method, that method will be called when the `pTimeout` promise times out. + +@param input - Promise to decorate. +@returns A decorated `input` that times out after `milliseconds` time. It has a `.clear()` method that clears the timeout. + +@example +``` +import {setTimeout} from 'node:timers/promises'; +import pTimeout from 'p-timeout'; + +const delayedPromise = () => setTimeout(200); + +await pTimeout(delayedPromise(), { + milliseconds: 50, + fallback: () => { + return pTimeout(delayedPromise(), 300); + } +}); +``` + */ + +export class TimeoutError extends Error { + readonly name: "TimeoutError"; + constructor(message?: string) { + super(message); + this.name = "TimeoutError"; + } +} + +/** +An error to be thrown when the request is aborted by AbortController. +DOMException is thrown instead of this Error when DOMException is available. +*/ +export class AbortError extends Error { + readonly name: "AbortError"; + constructor(message?: string) { + super(); + this.name = "AbortError"; + this.message = message as string; + } +} + +/** +TODO: Remove AbortError and just throw DOMException when targeting Node 18. +*/ +const getDOMException = (errorMessage: string | undefined) => + globalThis.DOMException === undefined + ? new AbortError(errorMessage) + : new DOMException(errorMessage); + +/** +TODO: Remove below function and just 'reject(signal.reason)' when targeting Node 18. +*/ +const getAbortedReason = (signal: AbortSignal) => { + const reason = + signal.reason === undefined + ? getDOMException("This operation was aborted.") + : signal.reason; + + return reason instanceof Error ? reason : getDOMException(reason); +}; + +export default function pTimeout( + promise: PromiseLike | PCancelable, + options: Options +): ClearablePromise { + const { + milliseconds, + fallback, + message, + customTimers = { setTimeout, clearTimeout }, + } = options; + + let timer: undefined | number; + + const cancelablePromise = new Promise((resolve, reject) => { + if (typeof milliseconds !== "number" || Math.sign(milliseconds) !== 1) { + throw new TypeError( + `Expected \`milliseconds\` to be a positive number, got \`${milliseconds}\`` + ); + } + + if (milliseconds === Number.POSITIVE_INFINITY) { + resolve(promise); + return; + } + + if (options.signal) { + const { signal } = options; + if (signal.aborted) { + reject(getAbortedReason(signal)); + } + + signal.addEventListener("abort", () => { + reject(getAbortedReason(signal)); + }); + } + + timer = customTimers.setTimeout.call( + undefined, + () => { + if (fallback) { + try { + resolve(fallback()); + } catch (error) { + reject(error); + } + + return; + } + + const errorMessage = + typeof message === "string" + ? message + : `Promise timed out after ${milliseconds} milliseconds`; + const timeoutError = + message instanceof Error ? message : new TimeoutError(errorMessage); + + if (typeof (promise as PCancelable)?.cancel === "function") { + (promise as PCancelable)?.cancel(); + } + + reject(timeoutError); + }, + milliseconds + ); + + (async () => { + try { + resolve(await promise); + } catch (error) { + reject(error); + } finally { + customTimers.clearTimeout.call(undefined, timer); + } + })(); + }); + + (cancelablePromise as ClearablePromise).clear = + () => { + customTimers.clearTimeout.call(undefined, timer); + timer = undefined; + }; + + return cancelablePromise as ClearablePromise; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a4d5cd4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "declaration": true, + "strict": true, + "module": "esnext", + "target": "esnext", + "esModuleInterop": true, + "sourceMap": false, + "rootDir": "src", + "outDir": "dist", + "typeRoots": [ + "node_modules/@types", + ], + "moduleResolution": "node", + "declarationMap": true, + "declarationDir": "dist", + "emitDeclarationOnly": false, + "allowJs": true + }, + "include": [ + "src" + ] +}