/** * SyncTasks.ts * Author: David de Regt * Copyright: Microsoft 2015 * * A very simple promise library that resolves all promises synchronously instead of * kicking them back to the main ticking thread. This affirmatively rejects the A+ * standard for promises, and is used for a combination of performance (wrapping * things back to the main thread is really slow) and because indexeddb loses * context for its calls if you send them around the event loop and transactions * automatically close. */ export const config = { // If we catch exceptions in success/fail blocks, it silently falls back to the fail case of the outer promise. // If this is global variable is true, it will also spit out a console.error with the exception for debugging. exceptionsToConsole: true, // Whether or not to actually attempt to catch exceptions with try/catch blocks inside the resolution cases. // Disable this for debugging when you'd rather the debugger caught the exception synchronously rather than // digging through a stack trace. catchExceptions: true, // Use this option in order to debug double resolution asserts locally. // Enabling this option in the release would have a negative impact on the application performance. traceEnabled: false, exceptionHandler: undefined as (((ex: Error) => void)|undefined), // If an ErrorFunc is not added to the task (then, catch, always) before the task rejects or synchonously // after that, then this function is called with the error. Default throws the error. unhandledErrorHandler: <(err: any) => void>((err: any) => { throw err; }) }; export interface Es6Thenable { then(onFulfilled?: (value: R) => U | Es6Thenable, onRejected?: (error: any) => U | Es6Thenable): Es6Thenable; then(onFulfilled?: (value: R) => U | Es6Thenable, onRejected?: (error: any) => void): Es6Thenable; } export function fromThenable(thenable: Es6Thenable): STPromise { const deferred = Defer(); // NOTE: The {} around the error handling is critical to ensure that // we do not trigger "Possible unhandled rejection" warnings. By adding // the braces, the error handler rejects the outer promise, but returns // void. If we remove the braces, it would *also* return something which // would be unhandled thenable.then( value => { deferred.resolve(value); }, (err: any) => { deferred.reject(err); }); // Force async before this promise resolves to prevent ES6 promises from catching thrown exceptions downstream return deferred.promise().thenAsync(x => x); } function isThenable(object: any): object is Thenable { return object !== null && object !== void 0 && typeof object.then === 'function'; } function isCancelable(object: any): object is Cancelable { return object !== null && object !== void 0 && typeof object.cancel === 'function'; } // Runs trier(). If config.catchExceptions is set then any exception is caught and handed to catcher. function run(trier: () => T, catcher?: (e: Error) => C): T | C { if (config.catchExceptions) { // Any try/catch/finally block in a function makes the entire function ineligible for optimization is most JS engines. // Make sure this stays in a small/quick function, or break out into its own function. try { return trier(); } catch (e) { return catcher!!!(e); } } else { return trier(); } } let asyncCallbacks: (() => void)[] = []; // Ideally, we use setImmediate, but that's only supported on some environments. // Suggestion: Use the "setimmediate" NPM package to polyfill where it's not available. const useSetImmediate = typeof setImmediate !== 'undefined'; /** * This function will defer callback of the specified callback lambda until the next JS tick, simulating standard A+ promise behavior */ export function asyncCallback(callback: () => void) { asyncCallbacks.push(callback); if (asyncCallbacks.length === 1) { // Start a callback for the next tick if (useSetImmediate) { setImmediate(resolveAsyncCallbacks); } else { setTimeout(resolveAsyncCallbacks, 0); } } } function resolveAsyncCallbacks() { const savedCallbacks = asyncCallbacks; asyncCallbacks = []; for (let i = 0; i < savedCallbacks.length; i++) { savedCallbacks[i](); } } export type SuccessFunc = (value: T) => U | Thenable; export type ErrorFunc = (error: any) => U | Thenable; export type CancelFunc = (context: any) => void; export interface Deferred { resolve(obj: T): Deferred; reject(obj?: any): Deferred; promise(): STPromise; onCancel(callback: CancelFunc): Deferred; } export interface Thenable { then(successFunc: SuccessFunc, errorFunc?: ErrorFunc): STPromise; } export interface Cancelable { // Will call any cancellation lambdas up the call chain, and reject a chain up the fail blocks cancel(context?: any): void; } export interface STPromise extends Thenable, Cancelable { catch(errorFunc: ErrorFunc): STPromise; finally(func: (value: T|any) => void): STPromise; always(func: (value: T|any) => U | Thenable): STPromise; done(successFunc: (value: T) => void): STPromise; fail(errorFunc: (error: any) => void): STPromise; // Defer the resolution of the then until the next event loop, simulating standard A+ promise behavior thenAsync(successFunc: SuccessFunc, errorFunc?: ErrorFunc): STPromise; setTracingEnabled(enabled: boolean): STPromise; toEs6Promise(): Promise; } export { STPromise as Promise }; module Internal { export interface CallbackSet { successFunc?: SuccessFunc; failFunc?: ErrorFunc; task?: Deferred; asyncCallback?: boolean; wasCanceled?: boolean; cancelContext?: any; } export class SyncTask implements Deferred, STPromise { private _storedResolution: T|undefined; private _storedErrResolution: any|undefined; private _completedSuccess = false; private _completedFail = false; private _traceEnabled = false; // If _traceEnabled is true we save stacktrace of the first resolution of the task in the _completeStack // we are storing the Error instead of stack because it would be faster in standard scenario. private _completeStack: Error; private _cancelCallbacks: CancelFunc[] = []; private _cancelContext: any; private _wasCanceled = false; // The owner of this promise should not call cancel twice. However, cancellation through bubbling is independent of this. private _wasExplicitlyCanceled = false; private _resolving = false; private _storedCallbackSets: CallbackSet[] = []; // 'Handled' just means there was a callback set added. // Note: If that callback does not handle the error then that callback's task will be 'unhandled' instead of this one. private _mustHandleError = true; private static _rejectedTasks: SyncTask[] = []; private static _enforceErrorHandledTimer: number|undefined; private _addCallbackSet(set: CallbackSet, callbackWillChain: boolean): STPromise { const task = new SyncTask(); task.onCancel(context => { set.wasCanceled = true; set.cancelContext = context; // Note: Cancel due to bubbling should not throw if the public cancel is called before/after. this._cancelInternal(context); }); set.task = task; this._storedCallbackSets.push(set); if (callbackWillChain) { // The callback inherits responsibility for "handling" errors. this._mustHandleError = false; } else { // The callback can never "handle" errors since nothing can chain to it. task._mustHandleError = false; } // The _resolve* functions handle callbacks being added while they are running. if (!this._resolving) { if (this._completedSuccess) { this._resolveSuccesses(); } else if (this._completedFail) { this._resolveFailures(); } } return task.promise(); } onCancel(callback: CancelFunc): Deferred { // Only register cancel callback handler on promise that hasn't been completed if (!this._completedSuccess && !this._completedFail) { if (this._wasCanceled) { callback(this._cancelContext); } else { this._cancelCallbacks.push(callback); } } return this; } then(successFunc: SuccessFunc, errorFunc?: ErrorFunc): STPromise { return this._addCallbackSet({ successFunc: successFunc, failFunc: errorFunc }, true); } thenAsync(successFunc: SuccessFunc, errorFunc?: ErrorFunc): STPromise { return this._addCallbackSet({ successFunc: successFunc, failFunc: errorFunc, asyncCallback: true }, true); } catch(errorFunc: ErrorFunc): STPromise { return this._addCallbackSet({ failFunc: errorFunc }, true); } always(func: (value: T|any) => U | STPromise): STPromise { return this._addCallbackSet({ successFunc: func, failFunc: func }, true); } setTracingEnabled(enabled: boolean) : STPromise { this._traceEnabled = enabled; return this; } // Finally should let you inspect the value of the promise as it passes through without affecting the then chaining // i.e. a failed promise with a finally after it should then chain to the fail case of the next then finally(func: (value: T|any) => void): STPromise { this._addCallbackSet({ successFunc: func, failFunc: func }, false); return this; } done(successFunc: (value: T) => void): STPromise { this._addCallbackSet({ successFunc: successFunc }, false); return this; } fail(errorFunc: (error: any) => void): STPromise { this._addCallbackSet({ failFunc: errorFunc }, false); return this; } resolve(obj: T): Deferred { this._checkState(true); this._completedSuccess = true; this._storedResolution = obj; // Cannot cancel resolved promise - nuke chain this._cancelCallbacks = []; this._resolveSuccesses(); return this; } reject(obj?: any): Deferred { this._checkState(false); this._completedFail = true; this._storedErrResolution = obj; // Cannot cancel resolved promise - nuke chain this._cancelCallbacks = []; this._resolveFailures(); SyncTask._enforceErrorHandled(this); return this; } private _checkState(resolve: boolean) { if (this._completedSuccess || this._completedFail) { if (this._completeStack) { console.error(this._completeStack.message, this._completeStack.stack); } const message = 'Failed to ' + (resolve ? 'resolve' : 'reject') + ': the task is already ' + (this._completedSuccess ? 'resolved' : 'rejected'); throw new Error(message); } if (config.traceEnabled || this._traceEnabled) { this._completeStack = new Error('Initial ' + resolve ? 'resolve' : 'reject'); } } // Make sure any rejected task has its failured handled. private static _enforceErrorHandled(task: SyncTask): void { if (!task._mustHandleError) { return; } SyncTask._rejectedTasks.push(task); // Wait for some async time in the future to check these tasks. if (!SyncTask._enforceErrorHandledTimer) { SyncTask._enforceErrorHandledTimer = setTimeout(() => { SyncTask._enforceErrorHandledTimer = undefined; const rejectedTasks = SyncTask._rejectedTasks; SyncTask._rejectedTasks = []; rejectedTasks.forEach((rejectedTask, i) => { if (rejectedTask._mustHandleError) { // Unhandled! config.unhandledErrorHandler(rejectedTask._storedErrResolution); } }); }, 0); } } cancel(context?: any): void { if (this._wasExplicitlyCanceled) { throw new Error('Already Canceled'); } this._wasExplicitlyCanceled = true; this._cancelInternal(context); } private _cancelInternal(context?: any): void { if (this._wasCanceled) { return; } this._wasCanceled = true; this._cancelContext = context; const callbacks = this._cancelCallbacks; this._cancelCallbacks = []; if (callbacks.length > 0) { callbacks.forEach(callback => { if (!this._completedSuccess && !this._completedFail) { callback(this._cancelContext); } }); } } static cancelOtherInternal(promise: Cancelable, context: any): void { // Warning: this cast is a bit dirty, but we need to avoid .cancel for SyncTasks. // Note: Cancel due to bubbling should not throw if the public cancel is called before/after. const task = promise as SyncTask; if (task._storedCallbackSets && task._cancelInternal) { // Is probably a SyncTask. task._cancelInternal(context); } else { promise.cancel(context); } } promise(): STPromise { return this; } private _resolveSuccesses() { this._resolving = true; // New callbacks can be added as the current callbacks run: use a loop to get through all of them. while (this._storedCallbackSets.length) { // Only iterate over the current list of callbacks. const callbacks = this._storedCallbackSets; this._storedCallbackSets = []; callbacks.forEach(callback => { if (callback.asyncCallback) { asyncCallback(() => this._resolveSuccessCallback(callback)); } else { this._resolveSuccessCallback(callback); } }); } this._resolving = false; } private _resolveSuccessCallback(callback: CallbackSet) { if (callback.successFunc) { run(() => { const ret = callback.successFunc!!!(this._storedResolution!!!); if (isCancelable(ret)) { if (callback.wasCanceled) { SyncTask.cancelOtherInternal(ret, callback.cancelContext); } else { callback.task!!!.onCancel(context => SyncTask.cancelOtherInternal(ret, context)); } // Note: don't care if ret is canceled. We don't need to bubble out since this is already resolved. } if (isThenable(ret)) { // The success block of a then returned a new promise, so ret.then(r => { callback.task!!!.resolve(r); }, e => { callback.task!!!.reject(e); }); } else { callback.task!!!.resolve(ret); } }, e => { this._handleException(e, 'SyncTask caught exception in success block: ' + e.toString()); callback.task!!!.reject(e); }); } else { callback.task!!!.resolve(this._storedResolution); } } private _resolveFailures() { this._resolving = true; // New callbacks can be added as the current callbacks run: use a loop to get through all of them. while (this._storedCallbackSets.length) { // Only iterate over the current list of callbacks. const callbacks = this._storedCallbackSets; this._storedCallbackSets = []; callbacks.forEach(callback => { if (callback.asyncCallback) { asyncCallback(() => this._resolveFailureCallback(callback)); } else { this._resolveFailureCallback(callback); } }); } this._resolving = false; } private _resolveFailureCallback(callback: CallbackSet) { if (callback.failFunc) { run(() => { const ret = callback.failFunc!!!(this._storedErrResolution); if (isCancelable(ret)) { if (callback.wasCanceled) { SyncTask.cancelOtherInternal(ret, callback.cancelContext); } else { callback.task!!!.onCancel(context => SyncTask.cancelOtherInternal(ret, context)); } // Note: don't care if ret is canceled. We don't need to bubble out since this is already rejected. } if (isThenable(ret)) { ret.then(r => { callback.task!!!.resolve(r); }, e => { callback.task!!!.reject(e); }); } else { // The failure has been handled: ret is the resolved value. callback.task!!!.resolve(ret); } }, e => { this._handleException(e, 'SyncTask caught exception in failure block: ' + e.toString()); callback.task!!!.reject(e); }); } else { callback.task!!!.reject(this._storedErrResolution); } } private _handleException(e: Error, message: string): void { if (config.exceptionsToConsole) { console.error(message); } if (config.exceptionHandler) { config.exceptionHandler(e); } } toEs6Promise(): Promise { return new Promise((resolve, reject) => this.then(resolve, reject)); } } } export type Raceable = T | Thenable | undefined | null; // Resolves once all of the given items resolve (non-thenables are 'resolved'). // Rejects once any of the given thenables reject. // Note: resolves immediately if given no items. export function all( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise<[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]>; export function all( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise<[T1, T2, T3, T4, T5, T6, T7, T8, T9]>; export function all( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise<[T1, T2, T3, T4, T5, T6, T7, T8]>; export function all( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise<[T1, T2, T3, T4, T5, T6, T7]>; export function all( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise<[T1, T2, T3, T4, T5, T6]>; export function all( values: [Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise<[T1, T2, T3, T4, T5]>; export function all(values: [Raceable, Raceable, Raceable, Raceable]): STPromise<[T1, T2, T3, T4]>; export function all(values: [Raceable, Raceable, Raceable]): STPromise<[T1, T2, T3]>; export function all(values: [Raceable, Raceable]): STPromise<[T1, T2]>; export function all(values: (T|Thenable)[]): STPromise; export function all(items: any[]): STPromise { if (items.length === 0) { return Resolved([]); } const outTask = Defer(); let countRemaining = items.length; let foundError: any|undefined; const results = Array(items.length); outTask.onCancel((val) => { items.forEach(item => { if (isCancelable(item)) { Internal.SyncTask.cancelOtherInternal(item, val); } }); }); const checkFinish = () => { if (--countRemaining === 0) { if (foundError !== undefined) { outTask.reject(foundError); } else { outTask.resolve(results); } } }; items.forEach((item, index) => { if (isThenable(item)) { const task = >item; task.then(res => { results[index] = res; checkFinish(); }, err => { if (foundError === undefined) { foundError = (err !== undefined) ? err : true; } checkFinish(); }); } else { // Not a task, so resolve directly with the item results[index] = item; checkFinish(); } }); return outTask.promise(); } export function Defer(): Deferred { return new Internal.SyncTask(); } export function Resolved(): STPromise; export function Resolved(val: T): STPromise; export function Resolved(val?: T): STPromise { return new Internal.SyncTask().resolve(val!!!).promise(); } export function Rejected(val?: any): STPromise { return new Internal.SyncTask().reject(val).promise(); } // Resolves/Rejects once any of the given items resolve or reject (non-thenables are 'resolved'). // Note: never resolves if given no items. export function race( values: [ Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable ] ): STPromise; export function race( values: [ Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable ] ): STPromise; export function race( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise; export function race( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise; export function race( values: [Raceable, Raceable, Raceable, Raceable, Raceable, Raceable] ): STPromise; export function race( values: [Raceable, Raceable, Raceable, Raceable, Raceable]): STPromise; export function race(values: [Raceable, Raceable, Raceable, Raceable]): STPromise; export function race(values: [Raceable, Raceable, Raceable]): STPromise; export function race(values: [Raceable, Raceable]): STPromise; export function race(values: (T|Thenable)[]): STPromise; export function race(items: any[]): STPromise { const outTask = Defer(); let hasSettled = false; outTask.onCancel((val) => { items.forEach(item => { if (isCancelable(item)) { Internal.SyncTask.cancelOtherInternal(item, val); } }); }); items.forEach(item => { if (isThenable(item)) { const task = >item; task.then(res => { if (!hasSettled) { hasSettled = true; outTask.resolve(res); } }, err => { if (!hasSettled) { hasSettled = true; outTask.reject(err); } }); } else { // Not a task, so resolve directly with the item if (!hasSettled) { hasSettled = true; outTask.resolve(item); } } }); return outTask.promise(); } export type RaceTimerResponse = { timedOut: boolean, result?: T }; export function raceTimer(promise: STPromise, timeMs: number): STPromise> { let timerDef = Defer>(); const token = setTimeout(() => { timerDef.resolve({ timedOut: true }); }, timeMs); const adaptedPromise = promise.then(resp => { clearTimeout(token); return { timedOut: false, result: resp } as RaceTimerResponse; }); return race([adaptedPromise, timerDef.promise()]); }