suspend
Version:
Async control-flow for Node.js using ES6 generators.
341 lines (304 loc) • 9.84 kB
JavaScript
var Promise = require('promise/lib/es6-extensions');
/**
* Our suspend namespace, which doubles as an alias for `suspend.fn` (although
* at the code level it may be more accurate to say that `suspend.fn` is an
* alias for `suspend`...
* Accepts a generator and returns a new function that makes no assumptions
* regarding callback and/or error conventions.
*/
var suspend = module.exports = function fn(generator) {
if (!isGeneratorFunction(generator)) {
throw new Error('First .fn() argument must be a GeneratorFunction.');
}
return function() {
var suspender = new Suspender(generator);
// preserve `this` context
suspender.start(this, Array.prototype.slice.call(arguments));
};
};
suspend.fn = suspend;
/**
* Accepts a generator, and returns a new function that follows Node's callback
* conventions. The callback is required.
*/
suspend.callback = function callback(generator) {
if (!isGeneratorFunction(generator)) {
throw new Error('First .callback() argument must be a GeneratorFunction.');
}
return function() {
var callback = arguments[arguments.length - 1],
args = Array.prototype.slice.call(arguments, 0, -1);
if (typeof callback !== 'function') {
throw new Error('Last argument must be a callback function.');
}
var suspender = new Suspender(generator, callback);
// preserve `this` context
suspender.start(this, args);
};
};
/**
* Accepts a generator, and returns a new function that returns a promise.
*/
suspend.promise = function promise(generator) {
if (!isGeneratorFunction(generator)) {
throw new Error('First .promise() argument must be a GeneratorFunction.');
}
return function() {
var self = this,
args = Array.prototype.slice.call(arguments);
return new Promise(function(resolve, reject) {
var suspender = new Suspender(generator, function(err, ret) {
err ? reject(err) : resolve(ret);
});
suspender.start(self, args);
});
};
};
/**
* Accepts a generator and an optional callback. The generator is invoked
* immediately - any errors or returned values are passed to the callback.
*/
suspend.run = function run(generator, callback) {
if (!isGeneratorFunction(generator)) {
throw new Error('First .run() argument must be a GeneratorFunction.');
}
if (callback && typeof callback !== 'function') {
throw new Error('Second .run() argument must be a callback function.');
}
var suspender = new Suspender(generator, callback);
// preserve `this` context
suspender.start(this);
};
/**
* Factory method for creating node-style callbacks that know how to resume
* execution of the generator. The callback expects the first argument to be
* an error, if it occurred, or the completion value as the second argument.
*/
suspend.resume = function resumeFactory() {
var suspender = getActiveSuspender();
if (!suspender) {
throw new Error('resume() must be called from the generator body.');
}
var alreadyResumed = false;
return function resume() {
if (alreadyResumed) {
throw new Error('Cannot call same resumer multiple times.');
}
alreadyResumed = true;
suspender.resume.apply(suspender, arguments);
};
};
/**
* Factory method for creating a callback that doesn't make any assumptions
* regarding Node's callback conventions. All arguments passed to it are made
* available in an array.
*/
suspend.resumeRaw = function resumeRawFactory() {
var resume = suspend.resume.apply(this, arguments);
getActiveSuspender().rawResume = true;
return resume;
};
/**
* Used for "forking" parallel operations. Rather than resuming the generator,
* completion values are stored until a subsequent `.join()` operation.
*/
suspend.fork = function fork() {
var suspender = getActiveSuspender();
if (!suspender) {
throw new Error('fork() must be called from the generator body.');
}
return suspender.forkFactory();
};
/**
* Similar to `resume()`, except that the resulting value is an array of all
* the completion values from previous `fork()` operations.
*/
suspend.join = function join() {
var suspender = getActiveSuspender();
if (!suspender) {
throw new Error('join() must be called from the generator body.');
}
if (suspender.pendingJoin) {
throw new Error('There is already a join() pending unresolved forks.');
}
suspender.join();
};
/**
* Constructor function used for "wrapping" generator. Manages the state and
* interactions with a suspend-wrapped generator.
*/
function Suspender(generator, callback) {
var self = this;
this.generator = generator;
// initialized in start()
this.iterator = null;
// flag to keep track of whether or not the entire generator completed.
// See start() for state tracking.
this.syncComplete = true;
// makes sure to not unleash zalgo: https://github.com/jmar777/suspend/pull/21
this.callback = callback && function() {
if (self.syncComplete) {
var args = Array.prototype.slice.call(arguments);
setImmediate(function() {
callback.apply(this, args);
});
} else {
callback.apply(this, arguments);
}
};
// flag indicating whether or not the iterator has completed
this.done = false;
// flag to keep track of whether or not we were resumed synchronously.
// See nextOrThrow() for state tracking.
this.syncResume = false;
// flag indicating whether or not the values passed to resume() should be
// treated as raw values, or handled per the error-first callback convention
this.rawResume = false;
// holding place for values from forked operations, waiting for a join()
this.forkValues = [];
// number of pending forks we have out there
this.pendingForks = 0;
// index used for preserving fork result positions
this.forkIndex = 0;
// flag indicating whether or not we have a pending join operation (which
// waits until all forks are resolved)
this.pendingJoin = false;
}
/**
* Starts the generator and begins iteration.
*/
Suspender.prototype.start = function start(ctx, args) {
this.iterator = this.generator.apply(ctx, args);
this.nextOrThrow();
this.syncComplete = false;
};
/**
* Handles values that are yielded from the generator (such as promises).
*/
Suspender.prototype.handleYield = function handleYield(ret) {
if (ret.done) {
this.done = true;
if (this.callback) {
this.callback.call(null, null, ret.value);
}
return;
}
// if nothing was yielded, then assume that resume()/join() are being used
if (!ret.value) return;
// check if a promise was yielded
if (typeof ret.value.then === 'function') {
// todo: may be more efficient to have a single instance-level resume
// function
ret.value.then(this.resume.bind(this, null), this.resume.bind(this));
}
};
/**
* Calls `.next()` or `.throw()` on the iterator, depending on the value of the
* `isError` flag. This method ensures that yielded values and thrown errors
* will be properly handled, and helps keep track of whether or not we are
* resumed synchronously.
*/
Suspender.prototype.nextOrThrow = function next(val, isError) {
var self = this;
this.syncResume = true;
setActiveSuspender(this);
var ret;
try {
ret = isError ? this.iterator.throw(val) : this.iterator.next(val);
} catch (err) {
// prevents promise swallowing: https://github.com/jmar777/suspend/pull/21
setImmediate(function() {
if (self.callback) {
return self.callback(err);
} else {
throw err;
}
});
return;
} finally {
this.syncResume = false;
clearActiveSuspender();
}
// everything was ok, so keep going
this.handleYield(ret);
};
/**
* Resumes execution of the generator once an async operation has completed.
*/
Suspender.prototype.resume = function resume(err, result) {
// if we have been synchronously resumed, then wait for the next turn on
// the event loop (avoids 'Generator already running' errors).
if (this.syncResume) {
return setImmediate(this.resume.bind(this, err, result));
}
if (this.rawResume) {
this.rawResume = false;
this.nextOrThrow(Array.prototype.slice.call(arguments));
} else {
if (this.done) {
throw new Error('Generators cannot be resumed once completed.');
}
if (err) return this.nextOrThrow(err, true);
this.nextOrThrow(result);
}
};
/**
* Returns a fork continuation that stashes the fulfillment value until `join()`
* is subsequently called.
*/
Suspender.prototype.forkFactory = function forkFactory() {
var self = this,
index = this.forkIndex++,
alreadyFulfilled = false;
this.pendingForks++;
return function fork() {
if (alreadyFulfilled) {
throw new Error('fork was fulfilled more than once.');
}
alreadyFulfilled = true;
self.forkValues[index] = Array.prototype.slice.call(arguments);
if (--self.pendingForks === 0 && self.pendingJoin) {
self.join();
}
};
};
/**
* Causes the generator to be resumed (with the values of any previous `fork()`
* fulfillments).
*/
Suspender.prototype.join = function join() {
this.pendingJoin || (this.pendingJoin = true);
if (this.pendingForks) return;
var err = null,
results = [];
for (var i = 0, len = this.forkValues.length; i < len; i++) {
var forkValue = this.forkValues[i];
if (forkValue[0]) {
err = forkValue[0];
break;
} else {
results[i] = forkValue[1];
}
}
// reset fork/join state
this.pendingJoin = false;
this.pendingForks = 0;
this.forkIndex = 0;
this.forkValues.length = 0;
// resume the generator with our fork/join results
this.resume(err, results);
};
// keep track of the currently active generator (used by the resumer factory).
var suspenderStack = [];
function setActiveSuspender(suspender) {
suspenderStack.push(suspender);
}
function getActiveSuspender() {
return suspenderStack[suspenderStack.length - 1];
}
function clearActiveSuspender() {
suspenderStack.pop();
}
function isGeneratorFunction(v) {
return v && v.constructor && v.constructor.name === 'GeneratorFunction';
}