'use strict'; /** * The core component to sequentialize asynchronous steps. * Basically it's an 'interruptable promise', but more than be interrupted, * it could 'shift' from one to another phase, with the non-preemptive * interrupting model. * * Example: * var process = new Process(); * process.start() // the default phase is 'start' * .next(stepA) * .next(stepB) * ... * // later, some urgent events come * process.stop() // one of the default three phases * .next(stopStepA) * .next(stopStepB) * .... * // later, some other interrupts come * process.shift('stop', 'dizzy') * .next(dizzyStepA) * .next(dizzyStepB) * * The phases listed above would immediately interrupt the steps scheduled * at the previous phase. However, this is a *non-preemptive* process by * default. So, if there is a long-waiting Promise step in the 'start' phase: * * process.start() * .next( longlonglongWaitingPromise ) // <--- now it's waiting this * .next( thisStepIsStarving ) * .next( andThisOneToo ) * .next( poorSteps ) * .... * // some urgent event occurs when it goes to the long waiting promise * process.stop() * .next( doThisAsQuickAsPossible ) * * The first step of the 'stop' phase, namely the 'doThisAsQuickAsPossible', * would *not* get executed immediately, since the promise is still waiting the * last step earlier interruption. So, even the following steps of the 'start' * phase would all get dropped, the new phase still need to wait the last one * asynchronous step get resolved to get kicked off. * * --- * ## About the non-preemptive model * * The reason why we can't have a preemptive process is because we couldn't * interrupt each single step in the process, so the most basic unit could be * interrupted is the step. So, the caveat here is make the step as small as * possible, and treat it as some atomic operation that guaranteed to not been * interrupted by Process. For example, if we alias 'next' as 'atomic': * * process.start() * .atomic(stepA) // <--- now it's waiting this * .atomic(stepB) * * // some urgent event occurs * process.stop() * .atomic( doThisAsQuickAsPossible ) * * It would be better than: * * process.start() * .atomic(() => stepA.then(stepB)) * * // some urgent event occurs * process.stop() * .atomic( doThisAsQuickAsPossible ) * * Since in the second example the first step of the 'stop' phase must wait * both the stepA & stepB, while in the first one it only needs to wait stepA. * However, this depends on which atomic operations is needed. * * Nevertheless, user is able to make the steps 'interruptible' via some special * methods of the process. That is, to monitor the phase changes to nullify the * step: * * var process = new Process(); * process.start() * .next(() => { * var phaseShifted = false; * process.until('stop') * .next(() => {phaseShifted = true;}); * return new Promise((r, rj) => { * setTimeout(() => { * if (phaseShifted) { console.log('do nothing'); } * else { console.log('do something'); } * }, 1000) * }); * }) * * // some urgent event occurs * process.stop() * .next( doThisAsQuickAsPossible ) * * So that the first step of the 'stop' phase would execute immediately after * the phase shifted, since the last step of the previous phase aborted itself. * In future the trick to nullify the last step may be included in as a method * of Process, but currently the manual detecting is still necessary. */ (function(exports) { var Process = function() { this.states = { phase: null, currentPromise: null, until: { resolver: null, phase: null }, // @see: #next stepResults: [], // @see: #next currentPhaseSteps: 0 }; }; Process.prototype.start = function() { this.states.phase = 'start'; this.states.currentPromise = Promise.resolve(); return this; }; Process.prototype.stop = function() { return this.shift('start', 'stop'); }; Process.prototype.destroy = function() { return this.shift('stop', 'destroy'); }; Process.prototype.shift = function(prev, current) { if (prev !== this.states.phase) { var error = new Error(`Must be ${prev} before shift to ${current}, but now it's ${this.states.phase}`); console.error(error); throw error; } this.states.phase = current; if (this.until.phase === this.states.phase) { this.until.resolver(); } // Concat new step to switch to the 'next promise'. this.states.currentPromise = this.states.currentPromise.catch((err) => { if (!(err instanceof Process.InterruptError)) { // We need to re-throw it again and bypass the whole // phase, until the next phase (final phase) to // handle it. Since in Promise, steps after catch would // not be affected by the catched error and keep executing. throw err; } // And if it's an interrupt error we do nothing, so that it would // make the chain omit this error and execute the following steps. }); // At the moment of shifting, there are no steps belong to the new phase. this.states.currentPhaseSteps = 0; return this; }; /** * Return a Promise that only be resolved when we get shifed to the * target phase. */ Process.prototype.until = function(phase) { var promise = new Promise((res) => { this.states.until.resolver = res; }); return promise; }; /** * The 'step' can only be a function return Promise/Process/plain value. * No matter a Promise or Process it would return, * the chain would concat it as the Promise rule. * If it's plain value then this process would ignore it, as * what a Promise does. * * About the resolving values: * * .next( fnResolveA, fnResolveB ) --> #save [a, b] in this process * .next( fnResolveC ) --> #receive [a, b] as first argument * .next( fnResolveD ) --> #receive c as first argument * .next( fnResolveE, fnResolveF) --> #each of them receive d as argument */ Process.prototype.next = function(...tasks) { if (!this.states.currentPromise) { throw new Error('Process should initialize with the `start` method'); } // At definition stage, set it's phase. // And check if it's a function. tasks.forEach((task) => { if ('function' !== typeof task) { throw new Error(`The task is not a function: ${task}`); } task.phase = this.states.phase; }); // First, concat a 'then' to check interrupt. this.states.currentPromise = this.states.currentPromise.then(() => { // Would check: if the phase it belongs to is not what we're in, // the process need to be interrputed. for (var task of tasks) { if (this.checkInterrupt(task)) { throw new Process.InterruptError(); } } }); // Read it as: // 1. execute all tasks to generate resolvable-promises // 2. Promise.all(...) to wait these resolvable-promises // 3. append a general error handler after the Promise.all // so that if any error occurs it would print them out // So, including the code above, we would have: // // currentPromise { // [checkInterrupt(tasks)] // [Promise.all([taskA1, taskA2...])] // [error handler] +} // // The 'checkInterrupt' and 'error handler' wrap the actual steps // to do the necessary checks. this.states.currentPromise = this.states.currentPromise.then(() => this.generateStep(tasks)); this.states.currentPromise = this.states.currentPromise.catch(this.generateErrorLogger({ 'nth-step': this.currentPhaseSteps })); // A way to know if these tasks is the first steps in the current phase, // and it's also convenient for debugging. this.states.currentPhaseSteps += 1; return this; }; Process.prototype.rescue = function(handler) { this.states.currentPromise = this.states.currentPromise.catch((err) => { if (err instanceof Process.InterruptError) { // Only built-in phase transferring catch can handle interrupts. // Re-throw it until we reach the final catch we set. throw err; } else { handler(err); } }); return this; }; /** * An interface to explicitly put multiple tasks execute at one time. **/ Process.prototype.wait = function() { this.next.apply(this, arguments); return this; }; /** * At the preemptive mode a suppressor would be installed as the final step * of the previous interrupted Promise, to igore any errors since it has been * interrupted. */ Process.prototype.generateSuppressor = function() { return (err) => {}; }; /** * Execute task and get Promises or plain values them return, * and then return the wrapped Promise as the next step of this * process. The name 'step' indicates the generated Promise, * which is one step of the main Promise of the current phase. */ Process.prototype.generateStep = function(tasks) { // So we unwrap the task first, and then put it in the array. // Since we need to give the 'currentPromise' a function as what the // tasks passed here. var chains = tasks.map((task) => { // Reset the registered results. var stepResults = this.states.stepResults; this.states.stepResults = []; var chain; // If it has multiple results, means it's a task group // generated results. if (this.states.stepResults.length > 1) { chain = task(stepResults); } else { chain = task(stepResults[0]); } // Ordinary function returns 'undefine' or other things. if (!chain) { // It's a plain value. // Store it as one of results. this.states.stepResults.push(chain); return Promise.resolve(chain); } if (chain instanceof Process) { // Premise: it's a started process. return chain.states.currentPromise.then((resolvedValue) => { this.states.stepResults.push(resolvedValue); }); } else if (chain.then) { // Ordinary promise can be concated immediately. return chain.then((resolvedValue) => { this.states.stepResults.push(resolvedValue); }); } else { // It's a plain value. // Store it as one of results. this.states.stepResults.push(chain); return Promise.resolve(chain); } }); return Promise.all(chains); }; /** We need this to prevent the step() throw errors. * In this catch, we distinguish the interrupt and other errors, * and then bypass the former and print the later out. * * The final fate of the real errors is it would be re-throw again * after we print the instance out. We need to do that since after an * error the promise shouldn't keep executing. If we don't throw it * again, since the error has been catched, the rest steps in the * promise would still be executed, and the user-set rescues would * not catch this error. * * As a conclusion, no matter whether the error is an interrupt, * we all need to throw it again. The only difference is if it's * and interrupt we don't print it out. */ Process.prototype.generateErrorLogger = function(debuginfo) { return (err) => { if (!(err instanceof Process.InterruptError)) { console.error(`ERROR during #${debuginfo['nth-steps']} step executes: ${err.message}`, err); } throw err; }; }; Process.prototype.checkInterrupt = function(step) { if (step.phase !== this.states.phase) { return true; } return false; }; /** * Because DRY. */ Process.defer = function() { var resolve, reject; var promise = new Promise((res, rej) => { resolve = res; reject = rej; }); var result = { 'resolve': resolve, 'reject': reject, 'promise': promise }; return result; }; /* Static version for mimicking Promise.all */ Process.wait = function(steps) { var process = new Process(); return process.wait(steps); }; Process.InterruptError = function(message) { this.name = 'InterruptError'; this.message = message || ''; }; Process.InterruptError.prototype = new Error(); exports.Process = Process; })(window);