diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown index ef88dae..8bcc4eb 100644 --- a/CHANGELOG.markdown +++ b/CHANGELOG.markdown @@ -4,7 +4,8 @@ ### Notable changes - - **globalClient**: The addition of a globalClient enables calls without instantiating a client + - **SendBuffer**: A mechanism for plugins to inspect the complete request body before it is send + - **globalClient**: A default client object allows simply calls out-of-the-box - **URL**: An URL can now be passed directly to `rail.call()` ### Known issues diff --git a/README.markdown b/README.markdown index d249409..3b8aecf 100644 --- a/README.markdown +++ b/README.markdown @@ -128,8 +128,8 @@ firefox coverage/lcov-report/index.html ### Coverage ``` -Statements : 85.96% ( 343/399 ) -Branches : 77.24% ( 190/246 ) -Functions : 84.44% ( 38/45 ) -Lines : 85.96% ( 343/399 ) +Statements : 86.21% ( 394/457 ) +Branches : 78.06% ( 217/278 ) +Functions : 85.45% ( 47/55 ) +Lines : 86.21% ( 394/457 ) ``` diff --git a/doc/plugin-api.markdown b/doc/plugin-api.markdown index 25bc1dc..d86b85b 100644 --- a/doc/plugin-api.markdown +++ b/doc/plugin-api.markdown @@ -53,6 +53,13 @@ Emitted after a new request configuration has been pushed onto the stack. `function({Call} call, {Object} options)` +### Event: 'plugin-send-buffer' +Emitted right before the request object is created. + +`function({Call} call, {Object} options, {SendBuffer} buffer)` + +_Note_: A call to `__buffer()` is required to enable this event. + ### Event: 'plugin-request' Emitted directly after the request object has been created. @@ -95,7 +102,10 @@ All request configurations are stored in `call._stack`, the current configuratio ### call.\_\_configure(options) Creates a new request configuration from the given options and increments the internal pointer. -**Note**: Request options are _copied_, plugin options are _referenced_. +_Note_: Request options are _copied_, plugin options are _referenced_ when not primitive. + +### call.\_\_buffer() +Enables the `plugin-send-buffer` event using `SendBuffer`. ### call.\_\_request() Create a request object when no request is pending and a configuration is available. When no configuration is available, a _non-interceptable_ error is emitted. diff --git a/lib/call.js b/lib/call.js index da458ee..c55c87b 100644 --- a/lib/call.js +++ b/lib/call.js @@ -2,6 +2,7 @@ var util = require('util'); var stream = require('stream'); var parseURL = require('url').parse; +var SendBuffer = require('./send-buffer'); var protocols = { http: require('http'), @@ -19,7 +20,8 @@ var ports = { /** - * An API Call + * Call + * Manages a series of requests * * @param {RAIL} rail * @param {?(Object|string)=} opt_options @@ -48,6 +50,9 @@ function Call(rail, opt_options) { // first plugin event rail.emit('plugin-call', this, opt_options); + // request body buffer + this._buffer = null; + // configure the first request this.__configure(opt_options); } @@ -160,11 +165,13 @@ Call.prototype.__configure = function(options) { }; -Call.prototype.__request = function() { +Call.prototype.__request = function(opt_callback) { var self = this; var request; var options = this._stack[this._pointer]; + opt_callback = opt_callback || function() {}; + if (this.request) { return true; } else if (!options) { @@ -173,6 +180,12 @@ Call.prototype.__request = function() { return this.emit('error', new Error('Unknown protocol "' + options.proto + '"')); } + + if (this._buffer) { + this.rail.emit('plugin-send-buffer', this, + this._stack[this._pointer], this._buffer); + } + request = protocols[options.proto].request(options.request); this.rail.emit('plugin-request', this, options, request); @@ -199,18 +212,33 @@ Call.prototype.__request = function() { this.request = request; - process.nextTick(function() { - self.__emit('request', request); // interceptable event - }); + if (this._buffer) { + this._buffer.replay(request, function() { + opt_callback(); + self.__emit('request', request); // interceptable event + }); + } else { + process.nextTick(function() { + opt_callback(); + self.__emit('request', request); // interceptable event + }); + } return request; }; Call.prototype._write = function(chunk, encoding, callback) { - if (this.__request()) { - // TODO: allow intercepting outgoing body + if (this._buffer) { + if (!this._buffer.push(chunk, encoding)) { + // TODO: bail-out the buffer, max limit reached + callback(); // ZALGO! + } else { + callback(); // ZALGO! + } + } else if (this.__request()) { this.request.write(chunk, encoding, callback); + } else { callback(new Error('Not connected')); // ZALGO! } @@ -232,32 +260,38 @@ Call.prototype.end = function(chunk, encoding, opt_callback) { } else if (!encoding) { encoding = null; } + opt_callback = opt_callback || function() {}; if (this.ended) { - if (opt_callback) { - setImmediate(opt_callback, new Error('Trying to write after end')); - } else { - this.emit('error', new Error('Trying to write after end')); - } + this.emit('error', new Error('Trying to write after end')); + setImmediate(opt_callback); return this; } this.ended = true; - if (this.__request()) { - this._end(chunk, encoding, function(err) { - self.request.end(); - - if (opt_callback) { - opt_callback(err); - } else if (err) { - self.emit('error', err); + if (this._buffer || this.__request()) { + this._end(chunk, encoding, function() { + if (self._buffer) { + if (self.request) { + throw new Error('We should not have a request here'); + } + self._buffer.closed = true; + + self.__request(function() { + self.request.end(); + opt_callback(); + }); + + } else { + self.request.end(); + setImmediate(opt_callback); } }); - } else if (opt_callback) { - setImmediate(opt_callback, new Error('Not connected')); + } else { this.emit('error', new Error('Not connected')); + setImmediate(opt_callback); } return this; @@ -291,3 +325,12 @@ Call.prototype.__intercept = function(event, interceptor) { Call.prototype.__clear = function() { this._interceptors = {}; }; + + +Call.prototype.__buffer = function() { + if (!this._buffer && !this.request) { + this._buffer = new SendBuffer(); + return true; + } + return false; +}; diff --git a/lib/plugins/cookies.js b/lib/plugins/cookies.js index 30a4718..f0c7a48 100644 --- a/lib/plugins/cookies.js +++ b/lib/plugins/cookies.js @@ -30,7 +30,6 @@ function parse(jar, options, response) { var tokens, cookie; var domain = options.request.host; var cookies = response.headers['set-cookie']; - var now = Date.now(); if (!cookies || options.cookies === false) { return; diff --git a/lib/send-buffer.js b/lib/send-buffer.js new file mode 100644 index 0000000..64dd7de --- /dev/null +++ b/lib/send-buffer.js @@ -0,0 +1,83 @@ +'use strict'; +var util = require('util'); +var events = require('events'); + + + +/** + * SendBuffer + * Buffers & replays the request body + * + * @param {?Object=} opt_options + * + * @constructor + */ +function SendBuffer(opt_options) { + opt_options = opt_options || {}; + + this.max = opt_options.max || 134217728; // 128 MiB + + this.chunks = []; + this.encodings = []; + this.length = 0; // number of chunk bytes + + this.closed = false; +} +module.exports = SendBuffer; + + +SendBuffer.prototype.push = function(chunk, encoding) { + if (this.closed) { + return false; + } + this.chunks.push(chunk); + this.length += chunk.length; + this.encodings.push(encoding); + + if (this.length > this.max) { + return false; + } + + return true; +}; + + +SendBuffer.prototype.replay = function(writable, callback) { + var self = this; + var i = 0; + + if (!this.chunks.length) { + return setImmediate(function() { + callback(); + }); + } + + + // async variation of _duff's device_ + // https://en.wikipedia.org/wiki/Duff%27s_device + (function next() { + var mod = 0; + + if (self.chunks.length < 4) { + mod = (self.chunks.length - i) % 4; + } + // TODO: write callback! + + switch (mod) { + case 0: + writable.write(self.chunks[i], self.encodings[i++]); /* falls through */ + case 3: + writable.write(self.chunks[i], self.encodings[i++]); /* falls through */ + case 2: + writable.write(self.chunks[i], self.encodings[i++]); /* falls through */ + case 1: + writable.write(self.chunks[i], self.encodings[i++]); /* falls through */ + } + + if (self.chunks[i]) { + setImmediate(next); + } else { + setImmediate(callback); + } + })(/* auto-exec */); +}; diff --git a/test/test-http-send-buffer.js b/test/test-http-send-buffer.js new file mode 100644 index 0000000..ef554a7 --- /dev/null +++ b/test/test-http-send-buffer.js @@ -0,0 +1,90 @@ +'use strict'; +/* global suite: false, setup: false, test: false, + teardown: false, suiteSetup: false, suiteTeardown: false */ +var assert = require('assert'); +var common = require('./common'); +var http = require('http'); +var RAIL = require('../'); + + +suite('http:send-buffer', function() { + var rail, server; + var onrequest; + + var listener = function(request, response) { + if (typeof onrequest === 'function') { + onrequest(request, response); + } + }; + + + suiteSetup(function(done) { + rail = new RAIL({ + }); + + server = http.createServer(listener); + server.listen(common.port, done); + }); + + + test('call', function(done) { + onrequest = function(request, response) { + var body = []; + + request.on('readable', function() { + var data = request.read(); + if (data) { + body.push(data); + } + }); + + request.once('end', function() { + body = Buffer.concat(body); + assert.strictEqual(body.toString(), 'ping'); + response.end('pong'); + }); + }; + + var sendbuffer; + + rail.once('plugin-send-buffer', function(call_, options, buffer) { + sendbuffer = Buffer.concat(buffer.chunks); + }); + + var call = rail.call({ + proto: 'http', + port: common.port, + method: 'POST' + }, function(response) { + assert.strictEqual(response.statusCode, 200); + var body = []; + + response.on('readable', function() { + var data = response.read(); + + if (data) { + body.push(data); + } + }); + + response.on('end', function() { + body = Buffer.concat(body); + assert.strictEqual(body.length, 4); + assert.strictEqual(body.toString(), 'pong'); + + assert(sendbuffer); + assert.strictEqual(sendbuffer.toString(), 'ping'); + done(); + }); + }); + + call.__buffer(); + + call.end('ping'); + }); + + + suiteTeardown(function(done) { + server.close(done); + }); +});