import { isPlainObject } from "./is.ts" /** * @description Chain a promise with a fulfillment callback and return the transformed result. * * @example * ``` * // Expect: 6 * const example1 = await promiseThen(Promise.resolve(3), (value: number) => value * 2) * // Expect: "ok!" * const example2 = await promiseThen(Promise.resolve("ok"), (value: string) => `${value}!`) * ``` */ export const promiseThen = async ( target: Promise, doSomething: (value: V) => R | PromiseLike, ): Promise => { return await target.then((value) => doSomething(value)) } /** * @description Handle a rejected promise and convert it to a resolved fallback value. * * @example * ``` * // Expect: "fallback" * const example1 = await promiseCatch(Promise.reject(new Error("x")), () => "fallback") * // Expect: 3 * const example2 = await promiseCatch(Promise.resolve(3), () => 0) * ``` */ export const promiseCatch = async ( target: Promise, doSomething: (reason: unknown) => R | PromiseLike, ): Promise => { return await target.catch((reason: unknown) => doSomething(reason)) } /** * @description Run a callback after a promise settles and keep the original resolved value. * * @example * ``` * let cleaned = false * // Expect: 10 * const example1 = await promiseFinally(Promise.resolve(10), () => { cleaned = true }) * // Expect: true * const example2 = cleaned * ``` */ export const promiseFinally = async ( target: Promise, doSomething: () => void, ): Promise => { return await target.finally(() => doSomething()) } export interface PromiseDeferred { promise: Promise resolve: (value: V) => void reject: (reason: unknown) => void } /** * @description Create a deferred promise with external resolve and reject functions. * * @example * ``` * const deferred = promiseDeferred() * // Expect: typeof deferred.resolve === "function" * const example1 = typeof deferred.resolve * deferred.resolve(42) * const example2 = await deferred.promise * // Expect: 42 * ``` */ export const promiseDeferred = (): PromiseDeferred => { let externalResolve!: (value: V) => void let externalReject!: (reason: unknown) => void const promise = new Promise((resolve, reject) => { externalResolve = resolve externalReject = reject }) return { promise, resolve: externalResolve, reject: externalReject } } const INTERNAL_PROMISE_FAIL_RESULT_TYPE: unique symbol = Symbol("fail") type InternalPromiseFailResultType = typeof INTERNAL_PROMISE_FAIL_RESULT_TYPE /** * @description 表示标准化的 Promise 失败结果。 */ export interface PromiseFailResult { __type__: InternalPromiseFailResultType reason: unknown } /** * @description 表示带原始下标信息的 Promise 失败结果。 */ export interface PromiseIndexedFailResult extends PromiseFailResult { index: number } /** * @description 根据拒因构造标准化的 Promise 失败结果。 */ export const promiseCreateFailResult = (reason: unknown): PromiseFailResult => { return { __type__: INTERNAL_PROMISE_FAIL_RESULT_TYPE, reason } } /** * @description 判断目标值是否为标准化的 Promise 失败结果。 */ export const promiseIsFailResult = (target: unknown): target is PromiseFailResult => { if (isPlainObject(target) === false) { return false } if ("__type__" in target === false) { return false } if (target["__type__"] !== INTERNAL_PROMISE_FAIL_RESULT_TYPE) { return false } return true } /** * @description 用作 `Promise.catch` 的 `onrejected` 回调,并返回标准化失败结果。 */ export const promiseCatchToFailResult = (reason: unknown): PromiseFailResult => { return { __type__: INTERNAL_PROMISE_FAIL_RESULT_TYPE, reason } } /** * @description 从结果数组中过滤出非 `PromiseFailResult` 项。 */ export const promiseFilterSuccessResults = (results: Array): V[] => { const filtered = results.filter((result) => { return promiseIsFailResult(result) === false }) return filtered as V[] } /** * @description 从结果数组中过滤出失败项,并补充其原始下标。 */ export const promiseFilterFailResults = ( results: Array, ): PromiseIndexedFailResult[] => { const filtered = results.reduce( (accumulatedResults, currentResult, index) => { if (promiseIsFailResult(currentResult)) { accumulatedResults.push({ ...currentResult, index }) } return accumulatedResults }, [], ) return filtered } interface PromiseQueueFirstPromiseMakerContext { index: number hasPreviousResult: false state: S } interface PromiseQueueRestPromiseMakerContext { index: number hasPreviousResult: true previousResult: V | PromiseFailResult state: S } type PromiseQueueFirstPromiseMaker = ( context: PromiseQueueFirstPromiseMakerContext, ) => Promise type PromiseQueueRestPromiseMaker = ( context: PromiseQueueRestPromiseMakerContext, ) => Promise type PromiseQueuePromiseMakers = [ PromiseQueueFirstPromiseMaker, ...Array>, ] /** * @description `promiseQueue` 的配置项。 */ export interface PromiseQueueOptions { /** * @default 0 */ breakTime?: number } /** * @description Execute promise makers in sequence, passing the previous result to the next maker. * * @example * ``` * // Expect: [1, 2] * const example1 = await promiseQueue([ * async () => 1, * async ({ previousResult }) => (promiseIsFailResult(previousResult) ? 0 : previousResult + 1), * ]) * ``` */ export const promiseQueue = async ( promiseMakers: PromiseQueuePromiseMakers, options?: PromiseQueueOptions | undefined, ): Promise> => { const { breakTime = 0 } = options ?? {} const results: Array = [] let context: PromiseQueueFirstPromiseMakerContext | PromiseQueueRestPromiseMakerContext = { index: 0, hasPreviousResult: false, state: undefined as unknown as S, } const [firstPromiseMaker, ...restPromiseMakers] = promiseMakers const lastPromise = restPromiseMakers.reduce( async (accumulatedPromise, currentPromiseMaker, index) => { return await accumulatedPromise.catch(promiseCatchToFailResult).then(async (result) => { results.push(result) return await new Promise((resolve) => { setTimeout(() => { context = { ...context, index: index + 1, hasPreviousResult: true, previousResult: result, } resolve(currentPromiseMaker(context)) }, breakTime) }) }) }, firstPromiseMaker(context), ) await lastPromise.catch(promiseCatchToFailResult).then((result) => { results.push(result) }) return results } interface PromiseRetryFirstPromiseMakerContext { index: number hasPreviousResult: false state: S } interface PromiseRetryRestPromiseMakerContext { index: number hasPreviousResult: true previousResult: T | PromiseFailResult state: S } type PromiseRetryPromiseMaker = ( context: PromiseRetryFirstPromiseMakerContext | PromiseRetryRestPromiseMakerContext, ) => Promise /** * @description `promiseRetryWhile` 与 `promiseRetryUntil` 的配置项。 */ export interface PromiseRetryOptions { /** * @default 0 */ breakTime?: number /** * @description `0` 表示仅执行首次尝试,不进行额外重试。 * * @default Infinity */ maxTryIndex?: number | undefined } /** * @description Retry while the predicate indicates the result is not acceptable. * * @example * ``` * let counter = 0 * // Expect: 3 * const example1 = await promiseRetryWhile( * (value) => value < 3, * async () => { * counter = counter + 1 * return counter * }, * ) * ``` * * @see {@link promiseRetryUntil} */ export const promiseRetryWhile = async ( predicate: (value: T) => boolean | Promise, promiseMaker: PromiseRetryPromiseMaker, options?: PromiseRetryOptions | undefined, ): Promise => { const { breakTime = 0, maxTryIndex = Infinity } = options ?? {} if (maxTryIndex < 0) { throw new Error("`maxTryIndex` must be greater than or equal to 0.") } let index = 0 // oxlint-disable-next-line no-accumulating-spread let context: PromiseRetryFirstPromiseMakerContext | PromiseRetryRestPromiseMakerContext = { index, hasPreviousResult: false, state: undefined as unknown as S, } let result = await promiseMaker(context).catch(promiseCatchToFailResult) while ((promiseIsFailResult(result) || (await predicate(result))) && index < maxTryIndex) { index = index + 1 // oxlint-disable-next-line no-loop-func result = await new Promise((resolve) => { setTimeout(() => { context = { ...context, index, hasPreviousResult: true, previousResult: result, } resolve(promiseMaker(context)) }, breakTime) }).catch(promiseCatchToFailResult) } return result } /** * @description Retry until the predicate returns true for the current result. * * @example * ``` * let counter = 0 * // Expect: 2 * const example1 = await promiseRetryUntil( * (value) => value >= 2, * async () => { * counter = counter + 1 * return counter * }, * ) * ``` * * @see {@link promiseRetryWhile} */ export const promiseRetryUntil = async ( predicate: (value: T, index: number) => boolean | Promise, promiseMaker: PromiseRetryPromiseMaker, options?: PromiseRetryOptions | undefined, ): Promise => { const { breakTime = 0, maxTryIndex = Infinity } = options ?? {} if (maxTryIndex < 0) { throw new Error("`maxTryIndex` must be greater than or equal to 0.") } let index = 0 // oxlint-disable-next-line no-accumulating-spread let context: PromiseRetryFirstPromiseMakerContext | PromiseRetryRestPromiseMakerContext = { index, hasPreviousResult: false, state: undefined as unknown as S, } let result = await promiseMaker(context).catch(promiseCatchToFailResult) while ( (promiseIsFailResult(result) || !(await predicate(result, index))) && index < maxTryIndex ) { index = index + 1 // oxlint-disable-next-line no-loop-func result = await new Promise((resolve) => { setTimeout(() => { context = { ...context, index, hasPreviousResult: true, previousResult: result, } resolve(promiseMaker(context)) }, breakTime) }).catch(promiseCatchToFailResult) } return result } /** * @description Start periodic asynchronous execution and return a function to stop it. * Rejected runs are logged and ignored to avoid unhandled promise rejections. * * @example * ``` * const records: number[] = [] * const stop = promiseInterval(10, async (index) => { * records.push(index) * return index * }) * // Later: stop() * // Expect: typeof stop === "function" * const example1 = typeof stop === "function" * ``` */ export const promiseInterval = ( interval: number, promiseMaker: (index: number) => Promise, ): (() => void) => { let index = 0 const intervalID = setInterval(() => { void promiseMaker(index).catch((error: unknown) => { console.error("[promiseInterval] unexpected error occurred:", error) }) index = index + 1 }, interval) return () => { clearInterval(intervalID) } } interface PromiseForeverFirstPromiseMakerContext { index: number hasPreviousResult: false state: S } interface PromiseForeverRestPromiseMakerContext { index: number hasPreviousResult: true previousResult: T | PromiseFailResult state: S } type PromiseForeverPromiseMaker = ( context: PromiseForeverFirstPromiseMakerContext | PromiseForeverRestPromiseMakerContext, ) => Promise /** * @description `promiseForever` 的配置项。 */ export interface PromiseForeverOptions { /** * @default 0 */ breakTime?: number | undefined onRejected?: ((index: number, result: PromiseFailResult) => void) | undefined } export interface PromiseForeverResult { index: number isStopped: boolean stop: () => void } /** * @description Continuously execute an async maker forever with optional delay and rejection hook. * * - 至少会执行一次,`stop()` 可以用来提前停止后续执行。 * - `index` 从 `0` 开始,表示当前执行轮次。 * - 如果某一轮结束后已经排入了下一轮的定时器,调用 `stop()` 不会取消那一轮; 它只会阻止后续继续排入新的轮次。 * * @example * ``` * let times = 0 * const controller = promiseForever(async () => { * times = times + 1 * return times * }, { breakTime: 0 }) * controller.stop() * // Expect: typeof controller.stop === "function" * const example1 = typeof controller.stop * ``` */ export const promiseForever = ( promiseMaker: PromiseForeverPromiseMaker, options?: PromiseForeverOptions | undefined, ): PromiseForeverResult => { const { breakTime = 0, onRejected } = options ?? {} let index = 0 let context: | PromiseForeverFirstPromiseMakerContext | PromiseForeverRestPromiseMakerContext = { index, hasPreviousResult: false, state: undefined as unknown as S, } const foreverResult: PromiseForeverResult = { index, isStopped: false, stop: () => { foreverResult.isStopped = true }, } const handlePromise = (result: T | PromiseFailResult): void => { setTimeout(() => { index = index + 1 context = { ...context, index, hasPreviousResult: true, previousResult: result, } foreverResult.index = index runPromise(context) }, breakTime) } const runPromise = ( context: | PromiseForeverFirstPromiseMakerContext | PromiseForeverRestPromiseMakerContext, ): void => { void promiseMaker(context) .catch((error: unknown) => { const failedResult = promiseCatchToFailResult(error) if (onRejected !== undefined) { try { onRejected(index, failedResult) } catch { // omit exceptions from execution of `onRejected` } } else { console.log("❌ [promiseForever] unexcepted error occured:", failedResult) } return failedResult }) .then((result) => { if (foreverResult.isStopped === false) { handlePromise(result) } }) } runPromise(context) return foreverResult }