http-delayed-response
Advanced tools
Comparing version 0.0.1 to 0.0.2
@@ -1,5 +0,12 @@ | ||
0.0.1 / February 19 2014 | ||
0.0.2 / February 20 2014 | ||
======================= | ||
* added new method: "wait" | ||
* added support for "timeout" | ||
* revised documentation | ||
0.0.1 / February 18 2014 | ||
======================= | ||
* initial release | ||
* experimental status |
91
index.js
@@ -5,4 +5,12 @@ var stream = require('stream'); | ||
var TimeoutError = function () { | ||
var err = Error.apply(this, arguments); | ||
this.stack = err.stack; | ||
this.message = err.message; | ||
return this; | ||
}; | ||
/** | ||
* Creates a new DelayedResponse instance, wrapping an HTTP DelayedResponse. | ||
* Creates a new DelayedResponse instance. | ||
* | ||
* @param {http.ClientRequest} req The incoming HTTP request | ||
@@ -21,5 +29,3 @@ * @param {http.ServerResponse} res The HTTP response to delay | ||
this.res = res; | ||
this.res.statusCode = 202; | ||
this.next = next; | ||
this.heartbeatChar = ' '; | ||
@@ -36,8 +42,43 @@ // if request is aborted, end the response immediately | ||
/** | ||
* Starts the polling process, keeping the connection alive. | ||
* 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.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) { | ||
DelayedResponse.prototype.start = function (interval, initialDelay, timeout) { | ||
@@ -50,5 +91,8 @@ if (this.started) throw new Error('instance already started'); | ||
// disable socket buffering - make sure all content is sent immediately | ||
this.res.socket.setNoDelay(); | ||
// 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 timer | ||
@@ -60,2 +104,9 @@ setTimeout(function () { | ||
// setup the cancel timer | ||
if (timeout) { | ||
this.timeout = setTimeout(function () { | ||
delayed.end(new TimeoutError('timeout occurred')); | ||
}, timeout); | ||
} | ||
return this.end.bind(delayed); | ||
@@ -67,7 +118,8 @@ }; | ||
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 by default) | ||
this.res.write(this.heartbeatChar); | ||
// default behavior: write the heartbeat character (a space) | ||
this.res.write(' '); | ||
} | ||
@@ -94,3 +146,6 @@ | ||
// prevent double processing | ||
if (this.stopped) return; | ||
if (this.stopped) { | ||
console.warn('DelayedResponse.end has been called twice!'); | ||
return; | ||
} | ||
@@ -110,3 +165,2 @@ // detect a promise-like object | ||
// stop the polling timer | ||
this.stop(); | ||
@@ -116,3 +170,5 @@ | ||
if (err) { | ||
if (this.listeners('error').length) { | ||
if (err instanceof TimeoutError && this.listeners('cancel').length) { | ||
return this.emit('cancel'); | ||
} else if (this.listeners('error').length) { | ||
return this.emit('error', err); | ||
@@ -143,12 +199,13 @@ } else if (this.next) { | ||
/** | ||
* Stops this delayed response without impacting the HTTP response. | ||
* Stops long-polling without affecting the response. | ||
*/ | ||
DelayedResponse.prototype.stop = function () { | ||
// stop polling | ||
this.pollingTimer && clearInterval(this.pollingTimer); | ||
// stop timeout | ||
this.timeout && clearTimeout(this.timeout); | ||
// restore socket buffering | ||
this.res.socket.setNoDelay(false); | ||
// stop polling | ||
clearInterval(this.pollingTimer); | ||
this.stopped = true; | ||
this.res.socket && this.res.socket.setNoDelay(false); | ||
}; | ||
module.exports = DelayedResponse; |
{ | ||
"name": "http-delayed-response", | ||
"version": "0.0.1", | ||
"version": "0.0.2", | ||
"author": { | ||
@@ -8,3 +8,3 @@ "name": "Nicolas Mercier", | ||
}, | ||
"description": "Super simple HTTP long-polling module for delaying a response while keeping the connection alive", | ||
"description": "Simple module for delaying a response, optionally with long-polling support", | ||
"keywords": [ | ||
@@ -30,6 +30,6 @@ "http", | ||
"type": "git", | ||
"url": "http://github.com/extrabacon/http-long-polling" | ||
"url": "http://github.com/extrabacon/http-delayed-response" | ||
}, | ||
"homepage": "http://github.com/extrabacon/http-long-polling", | ||
"bugs": "http://github.com/extrabacon/http-long-polling/issues", | ||
"homepage": "http://github.com/extrabacon/http-delayed-response", | ||
"bugs": "http://github.com/extrabacon/http-delayed-response/issues", | ||
"engines": { | ||
@@ -36,0 +36,0 @@ "node": ">=0.10" |
219
README.md
# http-delayed-response | ||
A fast and easy way to delay a response with HTTP long-polling, making sure the connection stays alive until the data to send is available. Use this module to prevent request timeouts on platforms such as Heroku (error H12) or connection errors on aggressive firewalls. | ||
A fast and easy way to delay a response until results are available. Use this module to respond appropriately with status [HTTP 202 Accepted](http://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success) when the result cannot be determined within an acceptable delay. Supports HTTP long-polling for longer delays, ensuring the connection stays alive until the result is available for working around platform limitations such as error H12 on Heroku or connection errors from aggressive firewalls. | ||
The module replaces your standard response with a long-polling [HTTP 202](http://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success) response, waiting on either a callback or a promise. The connection is kept alive by writing non-significant bytes to the response at a given interval. | ||
Works with any Node HTTP server based on [ClientRequest](http://nodejs.org/api/http.html#http_class_http_clientrequest) and [ServerResponse](http://nodejs.org/api/http.html#http_class_http_serverresponse), including Express applications (can be used as standard middleware). | ||
Works with any Node.js HTTP server, including Express applications (can be used as standard middleware). This module has no dependencies. | ||
Note: This module is purely experimental and is not ready for production use. | ||
```js | ||
// without http-delayed-response | ||
app.use(function (req, res) { | ||
// if getData takes longer than 30 seconds, Heroku will close the connection with error H12 | ||
getData(function (err, data) { | ||
res.json(data); | ||
}); | ||
}); | ||
// with http-delayed-response | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
res.set('Content-Type', 'application/json'); | ||
// when calling "start", bytes are written periodically to keep the connection alive | ||
// since bytes written are insignificant, the response can still be parsed as JSON | ||
getData(delayed.start()); | ||
}); | ||
``` | ||
Note: This module is experimental and is not ready for production use. | ||
## Installation | ||
@@ -41,14 +20,21 @@ | ||
## Examples | ||
This module has no dependencies. | ||
For simplicity, all examples are depicted as Express middleware. However, any Node.js HTTP server based on http.ClientRequest and http.ServerResponse is supported. | ||
## Features | ||
### Waiting for a very long function to invoke its callback | ||
For simplicity, all examples are depicted as Express middleware. | ||
This example waits for a very slow function, rendering its return value into the response. | ||
### Waiting for a function to invoke its callback | ||
This example waits for a slow function indefinitely, rendering its return value into the response. The `wait` method returns a callback that you can use to handle results. | ||
```js | ||
function slowFunction (callback) { | ||
// let's do something that could take a while... | ||
} | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
verySlowFunction(delayed.start()); | ||
slowFunction(delayed.wait()); | ||
}); | ||
@@ -59,3 +45,3 @@ ``` | ||
Same thing, except that `verySlowFunction` returns a promise. | ||
Same thing, except the function returns a promise instead of invoking a callback. Use the `end` method to handle promises. | ||
@@ -65,4 +51,5 @@ ```js | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.start(); | ||
var promise = verySlowFunction(); | ||
delayed.wait(); | ||
var promise = slowFunction(); | ||
// will eventually end when the promise is fulfilled | ||
delayed.end(promise); | ||
@@ -72,5 +59,5 @@ }); | ||
### Rendering JSON | ||
### Handling results and timeouts | ||
You are responsible for writing headers before starting the delayed response. If the returned data needs to be rendered as JSON, set the "content-type" header beforehand. | ||
Use the "done" event to handle the response when the function returns successfully within the allocated time. Otherwise, use the "cancel" event to handle the response. During a timeout, the response is automatically set to status 202. | ||
@@ -80,3 +67,40 @@ ```js | ||
var delayed = new DelayedResponse(req, res); | ||
res.set('Content-Type', 'application/json'); | ||
delayed.on('done', function (results) { | ||
// slowFunction responded within 5 seconds | ||
res.json(results); | ||
}).on('cancel', function () { | ||
// slowFunction failed to invoke its callback within 5 seconds | ||
// response has been set to HTTP 202 | ||
res.write('sorry, this will take longer than expected...'); | ||
res.end(); | ||
}); | ||
slowFunction(delayed.wait(5000)); | ||
}); | ||
``` | ||
### Extended delays and long-polling | ||
If the function takes even longer to complete, we might face connectivity issues. For example, Heroku aborts the request if not a single byte is written within 30 seconds. To counter this situation, activate long-polling to keep the connection alive while waiting on the results. Use the `start` method instead of `wait` to periodically write non-significant bytes to the response. | ||
```js | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
// verySlowFunction can now run indefinitely | ||
verySlowFunction(delayed.start()); | ||
}); | ||
``` | ||
Long-polling is continuously writing spaces (char \x20) to the response body in order to prevent connection termination. Remember that using long-polling makes handling the response a little different, since HTTP status 202 and headers are already sent to the client. | ||
### Rendering JSON with long-polling | ||
You are responsible for writing headers before enabling long-polling. If the return value needs to be rendered as JSON, set "Content-Type" beforehand, or use the `json` method as a shortcut. | ||
```js | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
// shortcut for res.setHeader('Content-Type', 'application/json') | ||
delayed.json(); | ||
// starting will write to the body - headers must be set before | ||
@@ -87,15 +111,18 @@ verySlowFunction(delayed.start()); | ||
### Polling with Mongoose | ||
### Polling a database | ||
This example polls a MongoDB collection with Mongoose until a particular document is returned. The resulting document is rendered in the response as JSON. The "poll" event is used to periodically query the database. | ||
When long-polling is enabled, use the "poll" event to monitor a condition for ending the response. This example polls a MongoDB collection with Mongoose until a particular document is returned. The resulting document is rendered in the response as JSON. | ||
```js | ||
app.use(function (req, res) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.on('poll', function () { | ||
delayed.json().on('poll', function () { | ||
// "poll" event will occur every 5 seconds | ||
Model.findOne({ /* criteria */}, function (err, result) { | ||
if (result) { | ||
if (err) { | ||
// end with an error | ||
delayed.end(err); | ||
} else if (result) { | ||
// end with the resulting document | ||
delayed.end(null, result); | ||
@@ -111,19 +138,18 @@ } | ||
By default, the callback result is rendered into the response body. More precisely, | ||
- when returning null or undefined, the response is ended with no additional content | ||
- when returning a string or a buffer, result is written as-is | ||
By default, the callback result is rendered into the response body. More precisely: | ||
- when returning `null` or `undefined`, the response is ended with no additional content | ||
- when returning a `string` or a `Buffer`, it is written as-is | ||
- when returning a readable stream, the result is piped into the response | ||
- when returning anything else, the result is rendered using `JSON.stringify` | ||
It is possible to handle the response manually if the default behavior is not appropriate. Be careful: only the body of the response can be written to, since headers are necessarily already sent. When handling the response manually, you are responsible for ending the response. | ||
It is possible to handle the response manually if the default behavior is not appropriate. Be careful: headers are necessarily already sent when the "done" handler is called. When handling the response manually, you are responsible for ending it appropriately. | ||
```js | ||
app.use(function (req, res) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.on('done', function (data) { | ||
// handle "data" anyway you want, but do not forget to end the response! | ||
// handle "data" anyway you want, but don't forget to end the response! | ||
res.end(); | ||
}).start(); | ||
}).wait(); | ||
@@ -135,26 +161,27 @@ }); | ||
To handle errors, simply subscribe to the "error" event, as unhandled errors will be thrown. Remember that HTTP status 202 is already applied and the HTTP protocol has no mechanism to indicate an error past this point. When handling errors, you are responsible for ending the response. | ||
To handle errors, use the "error" event. Otherwise, unhandled errors will be thrown. When using long-polling, HTTP status 202 is already applied and the HTTP protocol has no mechanism to indicate an error past this point. Also, when handling errors, you are responsible for ending the response. | ||
Also, a timeout that is not handled with a "cancel" event is treated like a normal error. | ||
```js | ||
app.use(function (req, res) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.on('error', function (err) { | ||
// write a JSON error, assuming the client can interpret the result | ||
var error = { error: 'server_error', details: 'An error occurred on the server' }; | ||
res.end(JSON.stringify(error)); | ||
}).start(); | ||
// handle error here | ||
// timeout will also raise an error since there is no "cancel" handler | ||
}); | ||
slowFunction(delayed.wait(5000)); | ||
}); | ||
``` | ||
Errors can also be handled with Express middleware by supplying the `next` parameter to the constructor. | ||
Errors can also be handled with Connect or Express middleware by supplying the `next` parameter to the constructor. | ||
```js | ||
app.use(function (req, res, next) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res, next); | ||
// "next" will be invoked if "verySlowFunction" fails | ||
verySlowFunction(delayed.start(1000)); | ||
// "next" will be invoked if "slowFunction" fails or times out | ||
slowFunction(delayed.wait(1000)); | ||
}); | ||
@@ -165,7 +192,6 @@ ``` | ||
By default, a response is ended with no additional content if the client aborts the request before completion. If you need to handle an aborted request, simply subscribe to the "abort" event. When handling client disconnects, you are responsible for ending the response. | ||
By default, a response is ended with no additional content if the client aborts the request before completion. If you need to handle an aborted request, attach the "abort" event. When handling client disconnects, you are responsible for ending the response. | ||
```js | ||
app.use(function (req, res) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res); | ||
@@ -176,10 +202,13 @@ | ||
res.end(); | ||
}).start(); | ||
}); | ||
// wait indefinitely | ||
slowFunction(delayed.wait()); | ||
}); | ||
``` | ||
### Keeping the connection alive | ||
### Keeping the connection alive with long-polling | ||
By default, the connection is kept alive by writing a single space to the response at the specified interval (default is 100msec). | ||
By default, when using long-polling, the connection is kept alive by writing a single space to the response at the specified interval (default is 100msec). | ||
@@ -189,3 +218,3 @@ ```js | ||
var delayed = new DelayedResponse(req, res); | ||
// write a "\x20" every second | ||
// write a "\x20" every second, until function is completed | ||
verySlowFunction(delayed.start(1000)); | ||
@@ -195,3 +224,3 @@ }); | ||
An initial delay before the first byte can also be specified. | ||
An initial delay before the first byte can also be specified (default is also 100msec). | ||
@@ -201,3 +230,3 @@ ```js | ||
var delayed = new DelayedResponse(req, res); | ||
// write a "\x20" every second after 10 seconds | ||
// write a "\x20" every second after 10 seconds, until function is completed | ||
verySlowFunction(delayed.start(1000, 10000)); | ||
@@ -207,5 +236,5 @@ }); | ||
To avoid H12 errors in Heroku, initial delay must be under 30 seconds and polling must then occur under 55 seconds. See https://devcenter.heroku.com/articles/request-timeout for more details. | ||
To avoid H12 errors in Heroku, initial delay must be under 30 seconds and at least 1 byte must be written every 55 seconds. See https://devcenter.heroku.com/articles/request-timeout for more details. | ||
To manually keep the connection alive, subscribe to the "heartbeat" event. | ||
To manually keep the connection alive, attach the "heartbeat" event. | ||
@@ -216,3 +245,3 @@ ```js | ||
delayed.on('heartbeat', function () { | ||
// anything you need to do to keep the connection alive - will be called every second | ||
// anything you need to do to keep the connection alive | ||
}); | ||
@@ -223,2 +252,56 @@ verySlowFunction(delayed.start(1000)); | ||
## API Reference | ||
#### DelayedResponse(req, res, next) | ||
Creates a `DelayedResponse` instance. Parameters represent the usual middleware signature. | ||
#### DelayedResponse.wait(timeout) | ||
Returns a callback handler that must be invoked within the allocated time. | ||
#### DelayedResponse.start(interval, initialDelay, timeout) | ||
Starts long-polling for the delayed response, sending headers and HTTP status 202. | ||
Polling will occur at the specified `interval`, starting after `initialDelay`. | ||
Returns a callback handler, same as `DelayedResponse.end`. | ||
#### DelayedResponse.end(err, data) | ||
Stops waiting and sends the response contents represented by `data` - or invoke the error handler if an error is present. | ||
#### DelayedResponse.stop() | ||
Stops long polling and timeout timers without affecting the response. | ||
#### DelayedResponse.json() | ||
Shortcut for setting the "Content-Type" header to "application/json". Returns itself for chaining calls. | ||
#### Event: 'done' | ||
Fired when `end` is invoked without an error. | ||
#### Event: 'cancel' | ||
Fired when `end` failed to be invoked within the allocated time. | ||
#### Event: 'error' | ||
Fired when `end` is invoked with an error, or when an unhandled timeout occurs. | ||
#### Event: 'abort' | ||
Fired when the request is closed. | ||
#### Event: 'poll' | ||
Fired continuously at the specified interval when invoking `start`. | ||
#### Event: 'heartbeat' | ||
Fired continuously at the specified interval when invoking `start`. Can be used to override the "keep-alive" mechanism. | ||
## Compatibility | ||
@@ -225,0 +308,0 @@ |
@@ -9,3 +9,3 @@ var express = require('express'); | ||
describe('DelayedResponse', function () { | ||
describe('.start(interval, initialDelay)', function () { | ||
describe('.wait(timeout)', function () { | ||
it('should return a callback handler', function (done) { | ||
@@ -15,2 +15,54 @@ var app = express(); | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.wait().should.be.a.Function; | ||
res.end(); | ||
}); | ||
request(app).get('/').expect(200, done); | ||
}); | ||
it('should cancel after timeout', function (done) { | ||
var app = express(); | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.on('cancel', function () { | ||
res.end(); | ||
done(); | ||
}).wait(100); | ||
}); | ||
request(app).get('/').end(function () {}); | ||
}); | ||
it('should throw after timeout without cancel handler', function (done) { | ||
var app = express(); | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.on('error', function (err) { | ||
err.message.should.be.exactly('timeout occurred'); | ||
res.status(500).end(); | ||
}); | ||
delayed.wait(100); | ||
}); | ||
request(app).get('/').expect(500).end(done); | ||
}); | ||
it('should not poll', function (done) { | ||
var app = express(); | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.on('poll', function () { | ||
throw new Error('should not poll'); | ||
}).on('heartbeat', function (args) { | ||
throw new Error('should not poll'); | ||
}).json().wait(200); | ||
setTimeout(function () { | ||
delayed.end(null, { success: true }); | ||
}, 100); | ||
}); | ||
request(app).get('/') | ||
.expect(200) | ||
.expect({ success: true }) | ||
.end(done); | ||
}); | ||
}); | ||
describe('.start(interval, initialDelay, timeout)', function () { | ||
it('should return a callback handler', function (done) { | ||
var app = express(); | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.start().should.be.a.Function; | ||
@@ -51,2 +103,13 @@ res.end(); | ||
}); | ||
it('should cancel after timeout', function (done) { | ||
var app = express(); | ||
app.use(function (req, res) { | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.on('cancel', function () { | ||
done(); | ||
}).start(50, 0, 100); | ||
res.end(); | ||
}); | ||
request(app).get('/').expect(202).end(function () {}); | ||
}); | ||
it('should throw when started twice', function (done) { | ||
@@ -78,3 +141,3 @@ var app = express(); | ||
}); | ||
it('should stop polling when request is aborted', function (done) { | ||
it('should stop when request is aborted', function (done) { | ||
var app = express(); | ||
@@ -147,4 +210,4 @@ app.use(function (req, res) { | ||
app.use(function (req, res) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.json(); | ||
delayed.start(100, 0); | ||
@@ -178,4 +241,4 @@ setTimeout(function () { | ||
app.use(function (req, res) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res); | ||
delayed.json(); | ||
delayed.start(100, 0); | ||
@@ -192,3 +255,2 @@ var promise = when.resolve({ success: true }); | ||
app.use(function (req, res) { | ||
res.set('Content-Type', 'application/json'); | ||
var delayed = new DelayedResponse(req, res); | ||
@@ -249,3 +311,3 @@ delayed.start(100, 0); | ||
var delayed = new DelayedResponse(req, res, next); | ||
delayed.start(); | ||
delayed.wait(); | ||
setTimeout(function () { | ||
@@ -252,0 +314,0 @@ (function () { |
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
33075
492
1
319