http-delayed-response
Version:
Simple module for delaying a response, optionally with long-polling support
211 lines (180 loc) • 6.71 kB
JavaScript
var stream = require('stream');
var EventEmitter = require('events').EventEmitter;
var util = require('util');
var TimeoutError = function () {
var err = Error.apply(this, arguments);
this.stack = err.stack;
this.message = err.message;
return this;
};
/**
* Creates a new DelayedResponse instance.
*
* @param {http.ClientRequest} req The incoming HTTP request
* @param {http.ServerResponse} res The HTTP response to delay
* @param {Function} next The next function to invoke, when DelayedResponse is used as middleware with
* express or connect.
*/
var DelayedResponse = function (req, res, next) {
if (!req) throw new Error('req is required');
if (!res) throw new Error('res is required');
var delayed = this;
this.req = req;
this.res = res;
this.next = next;
this.timers = {};
// if request is aborted, end the response immediately
req.on('close', function () {
abort.call(delayed);
});
// make sure timers stop if response is ended or closed
res.on('close', function () {
delayed.stop();
}).on('finish', function () {
delayed.stop();
});
EventEmitter.call(this);
};
util.inherits(DelayedResponse, EventEmitter);
/**
* Shorthand for adding the "Content-Type" header for returning JSON.
* @return {DelayedResponse} The same instance, for chaining calls
*/
DelayedResponse.prototype.json = function () {
this.res.setHeader('Content-Type', 'application/json');
return this;
};
/**
* Waits for callback results without long-polling.
*
* @param {Number} timeout The maximum amount of time to wait before cancelling
* @return {Function} The callback handler to use to end the delayed response (same as DelayedResponse.end).
*/
DelayedResponse.prototype.wait = function (timeout) {
if (this.started) throw new Error('instance already started');
var delayed = this;
// setup the cancel timer
if (timeout) {
this.timers.timeout = setTimeout(function () {
// timeout implies status is unknown, set HTTP Accepted status
delayed.res.statusCode = 202;
delayed.end(new TimeoutError('timeout occurred'));
}, timeout);
}
return this.end.bind(delayed);
};
/**
* Starts long-polling to keep the connection alive while waiting for the callback results.
* Also sets the response to status code 202 (Accepted).
*
* @param {Number} interval The interval at which "heartbeat" events are emitted
* @param {Number} initialDelay The initial delay before starting the polling process
* @param {Number} timeout The maximum amount of time to wait before cancelling
* @return {Function} The callback handler to use to end the delayed response (same as DelayedResponse.end).
*/
DelayedResponse.prototype.start = function (interval, initialDelay, timeout) {
if (this.started) throw new Error('instance already started');
var delayed = this;
interval = interval || 100;
initialDelay = typeof initialDelay === 'undefined' ? interval : initialDelay;
// set HTTP Accepted status code
this.res.statusCode = 202;
// disable socket buffering: make sure content is flushed immediately during long-polling
this.res.socket && this.res.socket.setNoDelay();
// start the polling and initial delay timers
this.timers.initialDelay = setTimeout(function () {
delayed.timers.poll = setInterval(heartbeat.bind(delayed), interval);
}, initialDelay);
this.started = true;
// setup the cancel timer
if (timeout) {
this.timers.timeout = setTimeout(function () {
delayed.end(new TimeoutError('timeout occurred'));
}, timeout);
}
return this.end.bind(delayed);
};
function heartbeat() {
// always emit "poll" event
this.emit('poll');
// if "heartbeat" event is attached, delegate to handlers
if (this.listeners('heartbeat').length) {
return this.emit('heartbeat');
}
// default behavior: write the heartbeat character (a space)
this.res.write(' ');
}
function abort() {
this.stop();
if (this.listeners('abort').length) {
return this.emit('abort');
}
// default behavior: end the response with no fanfare
this.res.end();
}
/**
* Ends this delayed response, writing the contents to the HTTP response and ending it. Attach a handler on the "done"
* event to manually end the response, or "error" to manually handle the error.
*
* @param {Error} err The error to throw if the operation has failed.
* @param {*} data The return value to render in the response.
*/
DelayedResponse.prototype.end = function (err, data) {
// detect a promise-like object
if (err && 'then' in err && typeof err.then === 'function') {
var promise = err;
var delayed = this;
return promise.then(function (result) {
delayed.end(null, result);
return result;
}, function (err) {
// this will throw err
delayed.end(err);
});
}
// prevent double processing
if (this.ended) return console.warn('DelayedResponse.end has been called twice!');
this.ended = true;
// restore socket buffering
this.res.socket && this.res.socket.setNoDelay(false);
// handle an error
if (err) {
if (err instanceof TimeoutError && this.listeners('cancel').length) {
return this.emit('cancel');
} else if (this.listeners('error').length) {
return this.emit('error', err);
} else if (this.next) {
return this.next(err);
}
throw err;
}
// if "done" handlers are attached, they are in charge of ending the response
if (this.listeners('done').length) {
return this.emit('done', data);
}
// otherwise, end the response with default behavior
if (typeof data === 'undefined' || data === null) {
this.res.end();
} else if (data instanceof stream.Readable) {
data.pipe(this.res);
} else if (typeof data === 'string' || Buffer.isBuffer(data)) {
this.res.end(data);
} else {
this.res.end(JSON.stringify(data));
}
};
/**
* Stops long-polling without affecting the response.
*/
DelayedResponse.prototype.stop = function () {
// stop initial delay
clearTimeout(this.timers.initialDelay);
this.timers.initialDelay = null;
// stop polling
clearInterval(this.timers.poll);
this.timers.poll = null;
// stop timeout
clearTimeout(this.timers.timeout);
this.timers.timeout = null;
};
module.exports = DelayedResponse;