import { Future } from "./future.js"; import { isPromise } from "./is-promise.js"; import { sleep } from "./promise-sleep.js"; export interface IsTimeouted { isSuccess(): this is TimeoutResultSuccess; isTimeout(): this is TimeoutResultTimeout; isAborted(): this is TimeoutResultAborted; isError(): this is TimeoutResultError; } function isTimeoutedMixin>(obj: R): R & IsTimeouted { return Object.assign(obj, { isSuccess: () => isSuccess(obj), isTimeout: () => isTimeout(obj), isAborted: () => isAborted(obj), isError: () => isError(obj), }) as R & IsTimeouted; } export interface PurTimeoutState { readonly state: "success" | "timeout" | "aborted" | "error"; } export interface PurTimeoutResultSuccess extends PurTimeoutState { readonly state: "success"; readonly value: T; } export interface PurTimeoutResultTimeout extends PurTimeoutState { readonly state: "timeout"; } export interface PurTimeoutResultAborted extends PurTimeoutState { readonly state: "aborted"; readonly reason: unknown; } export interface PurTimeoutResultError extends PurTimeoutState { readonly state: "error"; readonly error: Error; } export interface TimeoutState { readonly duration: number; readonly ctx: CTX; } type PurTimeoutResult = PurTimeoutResultSuccess | PurTimeoutResultTimeout | PurTimeoutResultAborted | PurTimeoutResultError; export type TimeoutResultSuccess = PurTimeoutResultSuccess & TimeoutState & IsTimeouted; export type TimeoutResultTimeout = PurTimeoutResultTimeout & TimeoutState & IsTimeouted; export type TimeoutResultAborted = PurTimeoutResultAborted & TimeoutState & IsTimeouted; export type TimeoutResultError = PurTimeoutResultError & TimeoutState & IsTimeouted; export type TimeoutResult = | TimeoutResultSuccess | TimeoutResultTimeout | TimeoutResultAborted | TimeoutResultError; export function createTimeoutResult(t: PurTimeoutResult & TimeoutState): TimeoutResult { return { ...isTimeoutedMixin(t), ...t } as TimeoutResult; } export type ActionFunc = (controller: AbortController) => Promise; // Action item in arrays - can be function or promise export type ActionItem = ActionFunc | Promise; // Main action type - function, promise, or mixed array export type TimeoutAction = ActionItem; // Configuration options export interface TimeoutActionOptions { readonly timeout: number; readonly signal: AbortSignal; readonly controller: AbortController; readonly ctx: CTX; onTimeout: () => void; onAbort: (reason: unknown) => void; onError: (error: Error) => void; // onAbortAction: (reason: unknown) => void; } // ============================================================================ // TYPE GUARDS // ============================================================================ /** * Type guard to check if a TimeoutResult represents a successful completion. * * @template T - The type of the result value * @param result - The TimeoutResult to check * @returns True if the result state is "success" */ export function isSuccess(result: PurTimeoutResult): result is TimeoutResultSuccess { return result.state === "success"; } /** * Type guard to check if a TimeoutResult represents a timeout. * * @template T - The type of the result value * @param result - The TimeoutResult to check * @returns True if the result state is "timeout" */ export function isTimeout(result: PurTimeoutResult): result is TimeoutResultTimeout { return result.state === "timeout"; } /** * Type guard to check if a TimeoutResult was aborted via AbortSignal. * * @template T - The type of the result value * @param result - The TimeoutResult to check * @returns True if the result state is "aborted" */ export function isAborted(result: PurTimeoutResult): result is TimeoutResultAborted { return result.state === "aborted"; } /** * Type guard to check if a TimeoutResult represents an error during execution. * * @template T - The type of the result value * @param result - The TimeoutResult to check * @returns True if the result state is "error" */ export function isError(result: PurTimeoutResult): result is TimeoutResultError { return result.state === "error"; } /** * Extracts the value from a successful TimeoutResult or throws an error. * * @template T - The type of the result value * @param result - The TimeoutResult to unwrap * @returns The success value * @throws Error if the result is not in success state */ export function unwrap(result: TimeoutResult): T { if (isSuccess(result)) { return result.value; } throw new Error(`TimeoutResult is not success: ${result.state}`); } /** * Extracts the value from a successful TimeoutResult or returns a default value. * * @template T - The type of the result value * @param result - The TimeoutResult to unwrap * @param defaultValue - The value to return if result is not successful * @returns The success value or the default value */ export function unwrapOr(result: TimeoutResult, defaultValue: T): T { return isSuccess(result) ? result.value : defaultValue; } /** * Executes an action with comprehensive timeout and abort handling. * * Wraps a promise or async function with timeout, abort signal, and error handling capabilities. * The action can be controlled via AbortController and will properly clean up resources. * * @template T - The type of the action's result value * @template CTX - Optional context type passed through to callbacks * @param action - Either a Promise or a function that receives an AbortController and returns a Promise * @param options - Optional configuration: * - timeout: Timeout duration in milliseconds (default: 30000). Set to 0 or negative to disable. * - signal: External AbortSignal to link for cancellation * - controller: External AbortController to use instead of creating a new one * - ctx: Context object passed through in the result * - onTimeout: Callback invoked when timeout occurs * - onAbort: Callback invoked when aborted (with abort reason) * - onError: Callback invoked when action throws an error * - onAbortAction: Callback for cleanup when action needs to be aborted * * @returns Promise resolving to a TimeoutResult containing: * - state: "success" | "timeout" | "aborted" | "error" * - value (if success), error (if error), or reason (if aborted) * - duration: Elapsed time in milliseconds * - ctx: The context object if provided * * @example * ```typescript * // With a function that can be aborted * const result = await timeouted( * async (controller) => { * return fetch(url, { signal: controller.signal }); * }, * { timeout: 5000 } * ); * * if (isSuccess(result)) { * console.log('Request completed:', result.value); * } else if (isTimeout(result)) { * console.log('Request timed out after', result.duration, 'ms'); * } * * // With a direct promise * const result2 = await timeouted( * fetch(url), * { timeout: 3000 } * ); * ``` */ export async function timeouted( action: TimeoutAction, options: Partial> = {}, ): Promise> { const { timeout = 30000, signal, controller: externalController, ctx, onTimeout, onAbort, onError } = options; const controller = externalController || new AbortController(); const startTime = Date.now(); const toRemoveEventListeners: (() => void)[] = []; // Link external signal to internal controller if (signal) { function abortAction(): void { controller.abort(signal?.reason); } toRemoveEventListeners.push(() => { signal?.removeEventListener("abort", abortAction); }); signal.addEventListener("abort", abortAction); } function cleanup>(result: X, skipCleanupAction = false): TimeoutResult { for (const evtFn of toRemoveEventListeners) { evtFn(); } toRemoveEventListeners.length = 0; if (!skipCleanupAction) { // void onAbortAction?.(signal?.reason); controller.abort(new Error("Timeouted Abort Action")); } return { ...result, duration: Date.now() - startTime, ctx: ctx as CTX, } as TimeoutResult; } let toAwait: Promise; if (isPromise(action)) { toAwait = action; } else { try { toAwait = action(controller); } catch (error) { return cleanup( createTimeoutResult({ state: "error", error: error instanceof Error ? error : new Error(error as string), duration: Date.now() - startTime, ctx: ctx as CTX, }), ); } } const abortToAwait = new Future>(); function onAbortHandler(): void { abortToAwait.resolve( createTimeoutResult({ state: "aborted" as const, reason: controller.signal.reason as unknown, duration: Date.now() - startTime, ctx: ctx as CTX, }), ); } controller.signal.addEventListener("abort", onAbortHandler); toRemoveEventListeners.push(() => { controller.signal.removeEventListener("abort", onAbortHandler); }); const toRace: Promise>[] = [ toAwait .then((value) => createTimeoutResult({ state: "success" as const, value, duration: Date.now() - startTime, ctx: ctx as CTX }), ) .catch((error: Error) => createTimeoutResult({ state: "error" as const, error, duration: Date.now() - startTime, ctx: ctx as CTX }), ), abortToAwait.asPromise(), ]; if (timeout > 0) { toRace.push( sleep(timeout, controller.signal).then((r): PurTimeoutResult => { switch (true) { case r.isOk: return createTimeoutResult({ state: "timeout" as const, duration: Date.now() - startTime, ctx: ctx as CTX }); case r.isErr: return createTimeoutResult({ state: "error" as const, error: r.error, duration: Date.now() - startTime, ctx: ctx as CTX, }); case r.isAborted: return createTimeoutResult({ state: "aborted" as const, reason: r.reason, duration: Date.now() - startTime, ctx: ctx as CTX, }); } throw new Error("Unreachable code in timeoutAction"); }), ); } const res = await Promise.race(toRace); switch (true) { case res.state === "success": return cleanup(res); case res.state === "aborted": onAbort?.(res.reason); return cleanup(res, true); case res.state === "error": onError?.(res.error); return cleanup(res); case res.state === "timeout": onTimeout?.(); return cleanup(res, true); } throw new Error("Unreachable code in timeoutAction"); }