diff --git a/docs/Reference.md b/docs/Reference.md index b20721f99..203b80b7b 100755 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -218,13 +218,15 @@ When creating a server instance, the following options configure the server's be - `server` - response timeout in milliseconds. Sets the maximum time allowed for the server to respond to an incoming client request before giving up and responding with a Service Unavailable (503) error response. Disabled by default (`false`). - `client` - request timeout in milliseconds. Sets the maximum time allowed for the client to transmit the request payload (body) before giving up - and responding with a Request Timeout (408) error response. Set to `false` to disable. Defaults to `10000` (10 seconds). + and responding with a Request Timeout (408) error response. Set to `false` to disable. Defaults to `10000` (10 seconds). - `socket` - by default, node sockets automatically timeout after 2 minutes. Use this option to override this behavior. Defaults to `undefined` which leaves the node default unchanged. Set to `false` to disable socket timeouts.

- `tls` - used to create an HTTPS server. The `tls` object is passed unchanged as options to the node.js HTTPS server as described in the [node.js HTTPS documentation](http://nodejs.org/api/https.html#https_https_createserver_options_requestlistener).

+- `maxSockets` - used to set the number of sockets available per outgoing host connection. Default is null. This impacts all servers sharing the process. +

- `views` - enables support for view rendering (using templates to generate responses). Disabled by default. To enable, set to an object with the following options: - `engines` - (required) an object where each key is a file extension (e.g. 'html', 'jade'), mapped to the npm module name (string) used for @@ -326,7 +328,7 @@ The following options are available when adding a route: Matching is done against the hostname part of the header only (excluding the port). Defaults to all hosts.

- `handler` - (required) the function called to generate the response after successful authentication and validation. The handler function is - described in [Route handler](#route-handler). Alternatively, `handler` can be set to the string `'notfound'` to return a Not Found (404) + described in [Route handler](#route-handler). Alternatively, `handler` can be set to the string `'notfound'` to return a Not Found (404) error response, or `handler` can be assigned an object with one of: - `file` - generates a static file endpoint for serving a single file. `file` can be set to: - a relative or absolute file path string (relative paths are resolved based on the server [`files`](#server.config.files) configuration). @@ -361,6 +363,10 @@ The following options are available when adding a route: - `passThrough` - if `true`, forwards the headers sent from the client to the upstream service being proxied to. Defaults to `false`. - `xforward` - if `true`, sets the 'X-Forwarded-For', 'X-Forwarded-Port', 'X-Forwarded-Proto' headers when making a request to the proxied upstream endpoint. Defaults to `false`. + - `redirects` - the maximum number of HTTP redirections allowed, to be followed automatically by the handler. Set to `false` or `0` to + disable all redirections (the response will contain the redirection received from the upstream service). If redirections are enabled, + no redirections (301, 302, 307, 308) will be passed along to the client, and reaching the maximum allowed redirections will return an + error response. Defaults to `false`. - `mapUri` - a function used to map the request URI to the proxied URI. Cannot be used together with `host`, `port`, or `protocol`. The function signature is `function(request, callback)` where: - `request` - is the incoming `request` object @@ -375,8 +381,6 @@ The following options are available when adding a route: - `settings` - the proxy handler configuration. - `res` - the node response object received from the upstream service. - `payload` - the response payload. - - `httpClient` - an alternative HTTP client function, compatible with the [**request**](https://npmjs.org/package/request) module `request()` - interface.

- `view` - generates a template-based response. The `view` options is set to the desired template file name. The view context available to the template includes: @@ -561,7 +565,7 @@ var paths = [ Parameterized paths are processed by matching the named parameters to the content of the incoming request path at that path segment. For example, '/book/{id}/cover' will match '/book/123/cover' and `request.params.id` will be set to `'123'`. Each path segment (everything between the opening '/' and the closing '/' unless it is the end of the path) can only include one named parameter. - + An optional '?' suffix following the parameter name indicates an optional parameter (only allowed if the parameter is at the ends of the path). For example, the route '/book/{id?}' matches '/book/'. @@ -836,10 +840,10 @@ subscribe to the `'request'` events and filter on `'error'` and `'state'` tags: ```javascript server.on('request', function (request, event, tags) { - + if (tags.error && tags.state) { console.error(event); - } + } }); ``` @@ -872,7 +876,7 @@ Registers an authentication strategy where: - `implementation` - an object with the **hapi** authentication scheme interface (use the `'hawk'` implementation as template). Cannot be used together with `scheme`. - `defaultMode` - if `true`, the scheme is automatically assigned as a required strategy to any route without an `auth` config. Can only be assigned to a single server strategy. Value must be `true` (which is the same as `'required'`) or a valid authentication mode (`'required'`, `'optional'`, `'try'`). Defaults to `false`. - + ##### Basic authentication Basic authentication requires validating a username and password combination. The `'basic'` scheme takes the following required options: @@ -984,7 +988,7 @@ var login = function () { var account = null; if (this.method === 'post') { - + if (!this.payload.username || !this.payload.password) { @@ -1065,7 +1069,7 @@ var credentials = { } var getCredentials = function (id, callback) { - + return callback(null, credentials[id]); }; @@ -1102,7 +1106,7 @@ var credentials = { } var getCredentials = function (id, callback) { - + return callback(null, credentials[id]); }; @@ -1151,7 +1155,7 @@ var Hapi = require('hapi'); var server = new Hapi.Server(); server.ext('onRequest', function (request, next) { - + // Change all requests to '/test' request.setUrl('/test'); next(); @@ -1175,7 +1179,7 @@ Each incoming request passes through a pre-defined set of steps, along with opti - **`'onRequest'`** extension point - always called - the `request` object passed to the extension functions is decorated with the `request.setUrl(url)` and `request.setMethod(verb)` methods. Calls to these methods - will impact how the request is routed and can be used for rewrite rules. + will impact how the request is routed and can be used for rewrite rules. - Lookup route using request path - Parse cookies - **`'onPreAuth'`** extension point @@ -1405,7 +1409,8 @@ Each request object have the following properties: - `id` - a unique request identifier. - `info` - request information: - `received` - request reception timestamp. - - `address` - remote client IP address. + - `remoteAddress` - remote client IP address. + - `remotePort` - remote client port. - `referrer` - content of the HTTP 'Referrer' (or 'Referer') header. - `host` - content of the HTTP 'Host' header. - `method` - the request method in lower case (e.g. `'get'`, `'post'`). @@ -1443,7 +1448,7 @@ var Hapi = require('hapi'); var server = new Hapi.Server(); server.ext('onRequest', function (request, next) { - + // Change all requests to '/test' request.setUrl('/test'); next(); @@ -1463,7 +1468,7 @@ var Hapi = require('hapi'); var server = new Hapi.Server(); server.ext('onRequest', function (request, next) { - + // Change all requests to 'GET' request.setMethod('GET'); next(); @@ -1663,7 +1668,7 @@ var server = new Hapi.Server({ path: __dirname + '/templates' } }); - + var handler = function () { var context = { @@ -1784,7 +1789,7 @@ var handler = function () { onTimeout(function () { response.send(); - }, 1000); + }, 1000); }; ``` @@ -1799,7 +1804,7 @@ Every response type must include the following properties: #### `Generic` The `Generic` response type is used as the parent prototype for all other response types. It cannot be instantiated directly and is only made available -for deriving other response types. It provides the following methods: +for deriving other response types. It provides the following methods: - `code(statusCode)` - sets the HTTP status code where: - `statusCode` - the HTTP status code. @@ -2195,7 +2200,7 @@ Provides a set of utilities for returning HTTP errors. An alias of the [**boom** if `reformat()` is called. Any content allowed and by default includes the following content: - `code` - the HTTP status code, derived from `error.response.code`. - `error` - the HTTP status message (e.g. 'Bad Request', 'Internal Server Error') derived from `code`. - - `message` - the error message derived from `error.message`. + - `message` - the error message derived from `error.message`. - inherited `Error` properties. It also supports the following method: @@ -2404,7 +2409,7 @@ Each `Pack` object instance has the following properties: #### `pack.server([host], [port], [options])` -Creates a `Server` instance and adds it to the pack, where `host`, `port`, `options` are the same as described in +Creates a `Server` instance and adds it to the pack, where `host`, `port`, `options` are the same as described in [`new Server()`](#new-serverhost-port-options) with the exception that the `cache` option is not allowed and must be configured via the pack `cache` option. @@ -2831,7 +2836,7 @@ exports.register = function (plugin, options, next) { this.reply(Hapi.error.internal('Not implemented yet')); }; - + plugin.route({ method: 'GET', path: '/', handler: handler }); next(); }; @@ -2914,7 +2919,7 @@ exports.register = function (plugin, options, next) { }, path: './templates' }); - + next(); }; ``` @@ -3167,15 +3172,15 @@ var handler = function (request) { var content = request.pre.user; Hapi.state.prepareValue('user', content, cookieOptions, function (err, value) { - + if (err) { return request.reply(err); } - + if (value.length < maxCookieSize) { request.setState('user', value, { encoding: 'none' } ); // Already encoded } - + request.reply('success'); }); }; diff --git a/examples/proxy.js b/examples/proxy.js index c1a47dc3c..6fcfed314 100755 --- a/examples/proxy.js +++ b/examples/proxy.js @@ -17,7 +17,7 @@ internals.main = function () { callback(null, 'http://www.google.com/search?q=' + request.params.term); }; - server.route({ method: '*', path: '/{p*}', handler: { proxy: { host: 'google.com', port: 80 } } }); + server.route({ method: '*', path: '/{p*}', handler: { proxy: { host: 'google.com', port: 80, redirects: 5 } } }); server.route({ method: 'GET', path: '/hapi/{term}', handler: { proxy: { mapUri: mapper } } }); server.start(); }; diff --git a/lib/client.js b/lib/client.js new file mode 100755 index 000000000..0e5e2b8eb --- /dev/null +++ b/lib/client.js @@ -0,0 +1,190 @@ +// Load modules + +var Url = require('url'); +var Http = require('http'); +var Https = require('https'); +var Stream = require('stream'); +var Utils = require('./utils'); +var Boom = require('boom'); + + +// Declare internals + +var internals = {}; + + +// Create and configure server instance + +exports.request = function (method, url, options, callback, _trace) { + + Utils.assert(typeof options.payload === 'string' || options.payload instanceof Stream || Buffer.isBuffer(options.payload), 'options.payload must be either a string, Buffer, or Stream'); + + // Setup request + + var uri = Url.parse(url); + uri.method = method.toUpperCase(); + uri.headers = options.headers; + + var redirects = (options.hasOwnProperty('redirects') ? options.redirects : false); // Needed to allow 0 as valid value when passed recursively + + _trace = (_trace || []); + _trace.push({ method: uri.method, url: url }); + + var agent = (uri.protocol === 'https:' ? Https : Http); + var req = agent.request(uri); + + var shadow = null; // A copy of the streamed request payload when redirects are enabled + + // Register handlers + + var isFinished = false; + var finish = function (err, res) { + + if (!isFinished) { + isFinished = true; + + req.removeAllListeners(); + + return callback(err, res); + } + }; + + req.once('error', function (err) { + + return finish(Boom.internal('Client request error', { err: err, trace: _trace })); + }); + + req.once('response', function (res) { + + // Pass-through response + + if (redirects === false || + [301, 302, 307, 308].indexOf(res.statusCode) === -1) { + + return finish(null, res); + } + + // Redirection + + if (redirects === 0) { + return finish(Boom.internal('Maximum redirections reached', _trace)); + } + + var redirectMethod = (res.statusCode === 301 || res.statusCode === 302 ? 'GET' : uri.method); + var location = res.headers.location; + + if (!location) { + return finish(Boom.internal('Received redirection without location', _trace)); + } + + if (!location.match(/^https?:/i)) { + location = Url.resolve(uri.href, location) + } + + var redirectOptions = { + headers: options.headers, + payload: shadow || options.payload, // shadow must be ready at this point if set + redirects: --redirects + }; + + res.destroy(); + exports.request(redirectMethod, location, redirectOptions, finish, _trace); + }); + + // Write payload + + if (uri.method !== 'GET' && + uri.method !== 'HEAD' && + options.payload !== null && + options.payload !== undefined) { // Value can be falsey + + if (options.payload instanceof Stream) { + options.payload.pipe(req); + + if (redirects) { + var collector = new internals.Collector(function () { + + shadow = collector.collect(); + }); + + options.payload.pipe(collector); + } + + return; + } + + req.write(options.payload); + } + + // Finalize request + + req.end(); +}; + + +exports.parse = function (res, callback) { + + var isFinished = false; + var finish = function (err, buffer) { + + if (!isFinished) { + isFinished = true; + + writer.removeAllListeners(); + res.removeAllListeners(); + + return callback(err, buffer); + } + }; + + res.once('error', function (err) { + + return finish(Boom.internal('Client response error', err)); + }); + + res.once('close', function () { + + return finish(Boom.internal('Client request closed')); + }); + + var writer = new internals.Collector(function () { + + return finish(null, writer.collect()); + }); + + res.pipe(writer); +}; + + +internals.Collector = function (options, callback) { + + if (!callback) { + callback = options; + options = {} + } + + Stream.Writable.call(this); + this.buffers = []; + this.length = 0; + + this.once('finish', callback); + + return this; +}; + +Utils.inherits(internals.Collector, Stream.Writable); + + +internals.Collector.prototype._write = function (chunk, encoding, next) { + + this.legnth += chunk.length; + this.buffers.push(chunk); + next(); +}; + + +internals.Collector.prototype.collect = function () { + + var buffer = (this.buffers.length === 0 ? new Buffer(0) : (this.buffers.length === 1 ? this.buffers[0] : Buffer.concat(this.buffers, this.length))); + return buffer; +}; diff --git a/lib/defaults.js b/lib/defaults.js index d514839d9..b23a7f178 100755 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -20,6 +20,8 @@ exports.server = { // cert: '' // }, + maxSockets: null, // Sets http/https globalAgent maxSockets value + // Router router: { @@ -37,9 +39,9 @@ exports.server = { strictHeader: true // Require an RFC 6265 compliant header format } }, - + // Location - + location: '', // Base uri used to prefix non-absolute outgoing Location headers ('http://example.com:8080'). Must not contain trailing '/'. // Payload @@ -105,7 +107,7 @@ exports.cors = { ], additionalMethods: [], exposedHeaders: [ - 'WWW-Authenticate', + 'WWW-Authenticate', 'Server-Authorization' ], additionalExposedHeaders: [], @@ -136,7 +138,7 @@ exports.state = { }; -// Views +// Views exports.views = { defaultExtension: '', diff --git a/lib/proxy.js b/lib/proxy.js index 3b034a57a..539ea7ab6 100755 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -1,8 +1,8 @@ // Load modules -var Request = require('request'); -var Utils = require('./utils'); var Boom = require('boom'); +var Client = require('./client'); +var Utils = require('./utils'); // Declare internals @@ -20,6 +20,7 @@ exports = module.exports = internals.Proxy = function (options, route) { Utils.assert(!options.mapUri || typeof options.mapUri === 'function', 'options.mapUri must be a function'); Utils.assert(!options.postResponse || typeof options.postResponse === 'function', 'options.postResponse must be a function'); Utils.assert(!options.hasOwnProperty('isCustomPostResponse'), 'Cannot manually set options.isCustomPostResponse'); + Utils.assert(!options.redirects || (typeof options.redirects === 'number' && options.redirects >= 0), 'Proxy redirects must false or a positive number'); this.settings = {}; this.settings.mapUri = options.mapUri || internals.mapUri(options.protocol, options.host, options.port); @@ -27,14 +28,12 @@ exports = module.exports = internals.Proxy = function (options, route) { this.settings.passThrough = options.passThrough || false; this.settings.isCustomPostResponse = !!options.postResponse; this.settings.postResponse = options.postResponse || internals.postResponse; // function (request, settings, response, payload) + this.settings.redirects = options.redirects || false; return this; }; -internals.Proxy.prototype.httpClient = Request; - - internals.Proxy.prototype.handler = function () { var self = this; @@ -50,10 +49,9 @@ internals.Proxy.prototype.handler = function () { var req = request.raw.req; var options = { - uri: uri, - method: request.method, headers: {}, - jar: false + payload: null, + redirects: self.settings.redirects }; if (self.settings.passThrough) { // Never set with cache @@ -66,51 +64,48 @@ internals.Proxy.prototype.handler = function () { } if (self.settings.xforward) { - options.headers['x-forwarded-for'] = (options.headers['x-forwarded-for'] ? options.headers['x-forwarded-for'] + ',' : '') + req.connection.remoteAddress || req.socket.remoteAddress; - options.headers['x-forwarded-port'] = (options.headers['x-forwarded-port'] ? options.headers['x-forwarded-port'] + ',' : '') + req.connection.remotePort || req.socket.remotePort; + options.headers['x-forwarded-for'] = (options.headers['x-forwarded-for'] ? options.headers['x-forwarded-for'] + ',' : '') + request.info.remoteAddress; + options.headers['x-forwarded-port'] = (options.headers['x-forwarded-port'] ? options.headers['x-forwarded-port'] + ',' : '') + request.info.remotePort; options.headers['x-forwarded-proto'] = (options.headers['x-forwarded-proto'] ? options.headers['x-forwarded-proto'] + ',' : '') + self.settings.protocol; } - var isGet = (request.method === 'get' || request.method === 'head'); + var isParsed = (self.settings.isCustomPostResponse || request.route.cache.mode.server); + if (isParsed) { + delete options.headers['accept-encoding']; + } - // Parsed payload interface + // Pass payload - if (self.settings.isCustomPostResponse || // Custom response method - (isGet && request.route.cache.mode.server)) { // GET/HEAD with Cache + var contentType = req.headers['content-type']; + if (contentType) { + options.headers['Content-Type'] = contentType; + } - delete options.headers['accept-encoding']; // Remove until Request supports unzip/deflate - self.httpClient(options, function (err, res, payload) { + options.payload = request.rawPayload || request.raw.req; - // Request handles all redirect responses (3xx) and will return an err if redirection fails + // Send request - if (err) { - return request.reply(Boom.internal('Proxy error', err)); - } + Client.request(request.method, uri, options, function (err, res) { - return self.settings.postResponse(request, self.settings, res, payload); - }); + if (err) { + return request.reply(err); + } - return; - } + if (!isParsed) { + return request.reply(res); // Request._respond will pass-through headers and status code + } - // Streamed payload interface + // Parse payload for caching or post-processing - if (!isGet && - request.rawPayload && - request.rawPayload.length) { + Client.parse(res, function (err, buffer) { - options.headers['Content-Type'] = req.headers['content-type']; - options.body = request.rawPayload; - } - - var reqStream = self.httpClient(options); - reqStream.once('response', request.reply); // Request._respond will pass-through headers and status code - - if (!isGet && - !request.rawPayload) { + if (err) { + return request.reply(err); + } - request.raw.req.pipe(reqStream); - } + return self.settings.postResponse(request, self.settings, res, buffer.toString()); + }); + }); }); }; }; @@ -132,14 +127,14 @@ internals.mapUri = function (protocol, host, port) { internals.postResponse = function (request, settings, res, payload) { var contentType = res.headers['content-type']; - var statusCode = res.statusCode; - if (statusCode >= 400) { - return request.reply(Boom.passThrough(statusCode, payload, contentType)); + if (res.statusCode !== 200) { + return request.reply(Boom.passThrough(res.statusCode, payload, contentType)); } var response = request.reply(payload); if (contentType) { response.type(contentType); } -}; \ No newline at end of file +}; + diff --git a/lib/request.js b/lib/request.js index f46b91db6..6c5612969 100755 --- a/lib/request.js +++ b/lib/request.js @@ -54,11 +54,14 @@ exports = module.exports = internals.Request = function (server, req, res, optio this.pre = {}; this.info = { received: now, - address: (req.connection && req.connection.remoteAddress) || '', + remoteAddress: (req.connection && req.connection.remoteAddress) || '', + remotePort: (req.connection && req.connection.remotePort) || '', referrer: req.headers.referrer || req.headers.referer || '', host: req.headers.host ? req.headers.host.replace(/\s/g, '') : '' }; + this.info.address = this.info.remoteAddress; // Backwards compatibility + // Apply options if (options.credentials) { @@ -457,7 +460,6 @@ internals.Request.prototype._replyInterface = function (next, withProperties) { delete self.reply; - Utils.assert(!self.route.cache.mode.server || result instanceof Stream === false, 'Cannot reply using a stream when caching enabled'); response = Response._generate(result, process); return response; }; @@ -466,15 +468,13 @@ internals.Request.prototype._replyInterface = function (next, withProperties) { return reply; } - if (!this.route.cache.mode.server) { - reply.close = function () { + reply.close = function () { - delete self.reply; + delete self.reply; - response = new Closed(); - process(); - }; - } + response = new Closed(); + process(); + }; // Chain initializers @@ -635,7 +635,7 @@ internals.handler = function (request, next) { } request._log(['handler'], { msec: timer.elapsed() }); - return exit(null, response); + return exit(null, response, !response.varieties.cacheable); }; // Execute handler diff --git a/lib/response/stream.js b/lib/response/stream.js index 80a481e03..bdf4df559 100755 --- a/lib/response/stream.js +++ b/lib/response/stream.js @@ -46,24 +46,19 @@ internals.Stream.prototype._setStream = function (stream) { return; } - // Check if stream is a node HTTP response (stream.*) or a (mikeal's) Request object (stream.response.*) + // Check if stream is a node HTTP response - if (stream.statusCode || - (stream.response && stream.response.statusCode)) { - - this._passThrough.code = stream.statusCode || stream.response.statusCode; + if (stream.statusCode) { + this._passThrough.code = stream.statusCode; } - if (stream.headers || - (stream.response && stream.response.headers)) { - - this._passThrough.headers = stream.headers || stream.response.headers; + if (stream.headers) { + this._passThrough.headers = stream.headers; } // Support pre node v0.10 streams API if (stream.pipe === Stream.prototype.pipe) { - var oldStream = stream; oldStream.pause(); stream = new Stream.Readable().wrap(oldStream); @@ -145,17 +140,9 @@ internals.Stream.prototype._transmit = function (request, callback) { } }); - if (encoding === 'gzip') { - this._headers['Content-Encoding'] = 'gzip'; - this._headers.Vary = 'Accept-Encoding'; - encoder = Zlib.createGzip(); - } - - if (encoding === 'deflate') { - this._headers['Content-Encoding'] = 'deflate'; - this._headers.Vary = 'Accept-Encoding'; - encoder = Zlib.createDeflate(); - } + this._headers['Content-Encoding'] = encoding; + this._headers.Vary = 'Accept-Encoding'; + encoder = (encoding === 'gzip' ? Zlib.createGzip() : Zlib.createDeflate()); } } @@ -178,7 +165,9 @@ internals.Stream.prototype._transmit = function (request, callback) { self._preview.removeAllListeners(); self._stream.removeAllListeners(); - self._stream.destroy && self._stream.destroy(); + if (self._stream.destroy) { + self._stream.destroy(); + } callback(); } diff --git a/lib/schema.js b/lib/schema.js index e7893d73b..6143a0677 100755 --- a/lib/schema.js +++ b/lib/schema.js @@ -91,7 +91,8 @@ internals.serverSchema = { allowInsecureAccess: T.Boolean(), partialsPath: T.String(), contentType: T.String() - }).nullOk() + }).nullOk(), + maxSockets: T.Number().nullOk() }; diff --git a/lib/server.js b/lib/server.js index c45fde94e..d5ff02d94 100755 --- a/lib/server.js +++ b/lib/server.js @@ -68,7 +68,7 @@ module.exports = internals.Server = function (/* host, port, options */) { this._host = args.host ? args.host.toLowerCase() : ''; this._port = typeof args.port !== 'undefined' ? args.port : (this.settings.tls ? 443 : 80); - + Utils.assert(!this.settings.location || this.settings.location.charAt(this.settings.location.length - 1) !== '/', 'Location setting must not contain a trailing \'/\''); var socketTimeout = (this.settings.timeout.socket === undefined ? 2 * 60 * 1000 : this.settings.timeout.socket); @@ -119,23 +119,28 @@ module.exports = internals.Server = function (/* host, port, options */) { this.listener = Http.createServer(this._dispatch()); } + if (this.settings.maxSockets !== null) { + Https.globalAgent.maxSockets = this.settings.maxSockets; + Http.globalAgent.maxSockets = this.settings.maxSockets; + } + // Authentication if (this.settings.auth) { this._auth.addBatch(this.settings.auth); } - + // Server information - + this.info = { host: this._host || '0.0.0.0', port: this._port || 0, protocol: (this.settings.tls ? 'https' : 'http') }; - + if (this.info.port) { this.info.uri = this.info.protocol + '://' + this.info.host + ':' + this.info.port; - } + } return this; }; @@ -203,7 +208,7 @@ internals.Server.prototype._start = function (callback) { this.listener.once('listening', function () { // Update the host, port, and uri with active values - + var address = self.listener.address(); self.info.host = self._host || address.address || '0.0.0.0'; self.info.port = address.port; diff --git a/package.json b/package.json index 16b363758..3bcf85fb3 100755 --- a/package.json +++ b/package.json @@ -32,13 +32,12 @@ "hoek": "0.8.x", "boom": "0.4.x", "joi": "0.3.x", - "catbox": "0.5.x", + "catbox": "0.6.x", "hawk": "0.13.x", "shot": "0.4.x", "cryptiles": "0.2.x", "iron": "0.3.x", "async": "0.2.x", - "request": "2.21.x", "formidable": "1.0.13", "mime": "1.2.x", "lru-cache": "2.3.x", @@ -48,6 +47,7 @@ }, "devDependencies": { "lab": "0.1.x", + "request": "2.21.x", "handlebars": "1.0.x", "jade": "0.30.x", "complexity-report": "0.x.x" diff --git a/test/integration/cache.js b/test/integration/cache.js index d0eb22096..5eed23941 100755 --- a/test/integration/cache.js +++ b/test/integration/cache.js @@ -47,11 +47,6 @@ describe('Cache', function () { request.reply(cacheable); }; - var badHandler = function (request) { - - request.reply(new Stream()); - }; - var errorHandler = function (request) { var error = new Error('myerror'); @@ -69,7 +64,6 @@ describe('Cache', function () { { method: 'GET', path: '/item', config: { handler: activeItemHandler, cache: { expiresIn: 120000 } } }, { method: 'GET', path: '/item2', config: { handler: activeItemHandler } }, { method: 'GET', path: '/item3', config: { handler: activeItemHandler, cache: { expiresIn: 120000 } } }, - { method: 'GET', path: '/bad', config: { handler: badHandler, cache: { mode: 'client+server', expiresIn: 120000 } } }, { method: 'GET', path: '/cache', config: { handler: cacheItemHandler, cache: { mode: 'client+server', expiresIn: 120000 } } }, { method: 'GET', path: '/error', config: { handler: errorHandler, cache: { mode: 'client+server', expiresIn: 120000 } } }, { method: 'GET', path: '/clientserver', config: { handler: profileHandler, cache: { mode: 'client+server', expiresIn: 120000 } } }, @@ -108,15 +102,6 @@ describe('Cache', function () { }); }); - it('returns 500 when returning a stream in a cached endpoint handler', function (done) { - - server.inject('/bad', function (res) { - - expect(res.statusCode).to.equal(500); - done(); - }); - }); - it('doesn\'t cache error responses', function (done) { server.inject('/error', function () { diff --git a/test/integration/proxy.js b/test/integration/proxy.js index cabdf96f0..06c78e963 100755 --- a/test/integration/proxy.js +++ b/test/integration/proxy.js @@ -2,9 +2,10 @@ var Lab = require('lab'); var Fs = require('fs'); -var Request = require('request'); var Zlib = require('zlib'); +var Request = require('request'); var Hapi = require('../..'); +var Client = require('../../lib/client'); // Declare internals @@ -27,8 +28,6 @@ describe('Proxy', function () { before(function (done) { - // Define backend handlers - var mapUriWithError = function (request, callback) { return callback(new Error('myerror')); @@ -41,26 +40,28 @@ describe('Proxy', function () { } var profile = { - 'id': 'fa0dbda9b1b', - 'name': 'John Doe' + id: 'fa0dbda9b1b', + name: 'John Doe' }; this.reply(profile).state('test', '123'); }; + var activeCount = 0; var activeItem = function () { this.reply({ - 'id': '55cf687663', - 'name': 'Active Item' + id: '55cf687663', + name: 'Active Item', + count: activeCount++ }); }; var item = function () { this.reply({ - 'id': '55cf687663', - 'name': 'Item' + id: '55cf687663', + name: 'Item' }).created('http://example.com'); }; @@ -95,9 +96,9 @@ describe('Proxy', function () { var headers = function () { this.reply({ status: 'success' }) - .header('Custom1', 'custom header value 1') - .header('X-Custom2', 'custom header value 2') - .header('access-control-allow-headers', 'Invalid, List, Of, Values'); + .header('Custom1', 'custom header value 1') + .header('X-Custom2', 'custom header value 2') + .header('access-control-allow-headers', 'Invalid, List, Of, Values'); }; var gzipHandler = function () { @@ -110,6 +111,16 @@ describe('Proxy', function () { this.reply(new Hapi.response.File(__dirname + '/../../package.json')); }; + var redirectHandler = function () { + + switch (this.query.x) { + case '1': this.reply.redirect('/redirect?x=1'); break; + case '2': this.reply().header('Location', '//localhost:' + this.server.info.port + '/profile').code(302); break; + case '3': this.reply().code(302); break; + default: this.reply.redirect('/profile'); break; + } + }; + var upstream = new Hapi.Server(0); upstream.route([ { method: 'GET', path: '/profile', handler: profile }, @@ -123,7 +134,11 @@ describe('Proxy', function () { { method: 'GET', path: '/noHeaders', handler: headers }, { method: 'GET', path: '/forward', handler: forward }, { method: 'GET', path: '/gzip', handler: gzipHandler }, - { method: 'GET', path: '/gzipstream', handler: gzipStreamHandler } + { method: 'GET', path: '/gzipstream', handler: gzipStreamHandler }, + { method: 'GET', path: '/redirect', handler: redirectHandler }, + { method: 'POST', path: '/post1', handler: function () { this.reply.redirect('/post2').rewritable(false); } }, + { method: 'POST', path: '/post2', handler: function () { this.reply(this.payload); } }, + { method: 'GET', path: '/cached', handler: profile } ]); var mapUri = function (request, callback) { @@ -136,7 +151,7 @@ describe('Proxy', function () { var backendPort = upstream.info.port; var routeCache = { expiresIn: 500, mode: 'server+client' }; - server = new Hapi.Server(0, { cors: true }); + server = new Hapi.Server(0, { cors: true, maxSockets: 10 }); server.route([ { method: 'GET', path: '/profile', handler: { proxy: { host: 'localhost', port: backendPort, xforward: true, passThrough: true } } }, { method: 'GET', path: '/item', handler: { proxy: { host: 'localhost', port: backendPort } }, config: { cache: routeCache } }, @@ -154,8 +169,13 @@ describe('Proxy', function () { { method: 'GET', path: '/noHeaders', handler: { proxy: { host: 'localhost', port: backendPort } } }, { method: 'GET', path: '/gzip', handler: { proxy: { host: 'localhost', port: backendPort, passThrough: true } } }, { method: 'GET', path: '/gzipstream', handler: { proxy: { host: 'localhost', port: backendPort, passThrough: true } } }, - { method: 'GET', path: '/google', handler: { proxy: { mapUri: function (request, callback) { callback(null, 'http://google.com'); } } } } - ]); + { method: 'GET', path: '/google', handler: { proxy: { mapUri: function (request, callback) { callback(null, 'http://www.google.com'); } } } }, + { method: 'GET', path: '/googler', handler: { proxy: { mapUri: function (request, callback) { callback(null, 'http://google.com'); }, redirects: 1 } } }, + { method: 'GET', path: '/redirect', handler: { proxy: { host: 'localhost', port: backendPort, passThrough: true, redirects: 2 } } }, + { method: 'POST', path: '/post1', handler: { proxy: { host: 'localhost', port: backendPort, redirects: 3 } }, config: { payload: 'stream' } }, + { method: 'GET', path: '/nowhere', handler: { proxy: { host: 'no.such.domain.x8' } } }, + { method: 'GET', path: '/cached', handler: { proxy: { host: 'localhost', port: backendPort } }, config: { cache: routeCache } } + ]); server.state('auto', { autoValue: 'xyz' }); server.start(function () { @@ -165,38 +185,48 @@ describe('Proxy', function () { }); }); - function makeRequest(options, callback) { + it('can add a proxy route with a http protocol set', function (done) { - options = options || {}; - options.path = options.path || '/'; - options.method = options.method || 'get'; + server.route({ method: 'GET', path: '/httpport', handler: { proxy: { host: 'localhost', protocol: 'http' } } }); + done(); + }); - var req = { - method: options.method, - url: server.info.uri + options.path, - form: options.form, - headers: options.headers, - jar: false - }; + it('can add a proxy route with a https protocol set', function (done) { - Request(req, function (err, res) { + server.route({ method: 'GET', path: '/httpsport', handler: { proxy: { host: 'localhost', protocol: 'https' } } }); + done(); + }); - callback(res); + it('proxies to a remote site', function (done) { + + server.inject('/google', function (res) { + + expect(res.statusCode).to.equal(200); + done(); }); - } + }); + + it('proxies to a remote site with redirects', function (done) { + + server.inject('/googler', function (res) { + + expect(res.statusCode).to.equal(200); + done(); + }); + }); it('forwards on the response when making a GET request', function (done) { - makeRequest({ path: '/profile' }, function (rawRes) { + server.inject('/profile', function (res) { + + expect(res.statusCode).to.equal(200); + expect(res.payload).to.contain('John Doe'); + expect(res.headers['set-cookie']).to.deep.equal(['test=123', 'auto=xyz']); - expect(rawRes.statusCode).to.equal(200); - expect(rawRes.body).to.contain('John Doe'); - expect(rawRes.headers['set-cookie']).to.deep.equal(['test=123','auto=xyz']); - - makeRequest({ path: '/profile' }, function (rawRes) { + server.inject('/profile', function (res) { - expect(rawRes.statusCode).to.equal(200); - expect(rawRes.body).to.contain('John Doe'); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.contain('John Doe'); done(); }); }); @@ -204,20 +234,20 @@ describe('Proxy', function () { it('forwards on x-forward headers', function (done) { - makeRequest({ path: '/forward', headers: { 'x-forwarded-for': 'xforwardfor', 'x-forwarded-port': '9000', 'x-forwarded-proto': 'xforwardproto' } }, function (rawRes) { + server.inject({ url: '/forward', headers: { 'x-forwarded-for': 'xforwardfor', 'x-forwarded-port': '9000', 'x-forwarded-proto': 'xforwardproto' } }, function (res) { - expect(rawRes.statusCode).to.equal(200); - expect(rawRes.body).to.equal('Success'); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Success'); done(); }); }); it('forwards upstream headers', function (done) { - server.inject({ url: '/headers', method: 'GET' }, function (res) { + server.inject('/headers', function (res) { expect(res.statusCode).to.equal(200); - expect(res.result).to.equal('{\"status\":\"success\"}'); + expect(res.payload).to.equal('{\"status\":\"success\"}'); expect(res.headers.custom1).to.equal('custom header value 1'); expect(res.headers['x-custom2']).to.equal('custom header value 2'); expect(res.headers['access-control-allow-headers']).to.equal('Authorization, Content-Type, If-None-Match'); @@ -230,10 +260,12 @@ describe('Proxy', function () { Zlib.gzip(new Buffer('123456789012345678901234567890123456789012345678901234567890'), function (err, zipped) { - makeRequest({ path: '/gzip', method: 'GET', headers: { 'accept-encoding': 'gzip' } }, function (res) { + expect(err).to.not.exist; + + server.inject({ url: '/gzip', headers: { 'accept-encoding': 'gzip' } }, function (res) { expect(res.statusCode).to.equal(200); - expect(res.body).to.equal(zipped.toString()); + expect(res.rawPayload).to.deep.equal(zipped); done(); }); }); @@ -241,15 +273,16 @@ describe('Proxy', function () { it('forwards gzipped stream', function (done) { - makeRequest({ path: '/gzipstream', method: 'GET', headers: { 'accept-encoding': 'gzip' } }, function (res) { + server.inject({ url: '/gzipstream', headers: { 'accept-encoding': 'gzip' } }, function (res) { expect(res.statusCode).to.equal(200); - Fs.readFile(__dirname + '/../../package.json', function (err, file) { + Fs.readFile(__dirname + '/../../package.json', { encoding: 'utf-8' }, function (err, file) { - Zlib.gzip(file, function (err, zipped) { + Zlib.unzip(new Buffer(res.payload, 'binary'), function (err, unzipped) { - expect(zipped.toString()).to.equal(res.body); + expect(err).to.not.exist; + expect(unzipped.toString('utf-8')).to.deep.equal(file); done(); }); }); @@ -258,135 +291,180 @@ describe('Proxy', function () { it('does not forward upstream headers without passThrough', function (done) { - makeRequest({ path: '/noHeaders' }, function (rawRes) { + server.inject('/noHeaders', function (res) { - expect(rawRes.statusCode).to.equal(200); - expect(rawRes.body).to.equal('{\"status\":\"success\"}'); - expect(rawRes.headers.custom1).to.not.exist; - expect(rawRes.headers['x-custom2']).to.not.exist; + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('{\"status\":\"success\"}'); + expect(res.headers.custom1).to.not.exist; + expect(res.headers['x-custom2']).to.not.exist; done(); }); }); - it('forwards on the response when making a GET request to a route that also accepts a POST', function (done) { + it('request a cached proxy route', function (done) { - makeRequest({ path: '/item' }, function (rawRes) { + server.inject('/item', function (res) { - expect(rawRes.statusCode).to.equal(200); - expect(rawRes.body).to.contain('Active Item'); - done(); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.contain('Active Item'); + var counter = res.result.count; + + server.inject('/item', function (res) { + + expect(res.statusCode).to.equal(200); + expect(res.result.count).to.equal(counter); + done(); + }); }); }); it('forwards on the status code when making a POST request', function (done) { - makeRequest({ path: '/item', method: 'post' }, function (rawRes) { + server.inject({ url: '/item', method: 'POST' }, function (res) { - expect(rawRes.statusCode).to.equal(201); - expect(rawRes.body).to.contain('Item'); + expect(res.statusCode).to.equal(201); + expect(res.payload).to.contain('Item'); done(); }); }); it('sends the correct status code with a request is unauthorized', function (done) { - makeRequest({ path: '/unauthorized', method: 'get' }, function (rawRes) { + server.inject('/unauthorized', function (res) { - expect(rawRes.statusCode).to.equal(401); + expect(res.statusCode).to.equal(401); done(); }); }); it('sends a 404 status code with a proxied route doesn\'t exist', function (done) { - makeRequest({ path: '/notfound', method: 'get' }, function (rawRes) { + server.inject('/notfound', function (res) { - expect(rawRes.statusCode).to.equal(404); + expect(res.statusCode).to.equal(404); done(); }); }); it('forwards on the status code when a custom postResponse returns an error', function (done) { - makeRequest({ path: '/postResponseError', method: 'get' }, function (rawRes) { + server.inject('/postResponseError', function (res) { - expect(rawRes.statusCode).to.equal(403); + expect(res.statusCode).to.equal(403); done(); }); }); it('forwards the error message with a custom postResponse and a route error', function (done) { - makeRequest({ path: '/postResponseNotFound', method: 'get' }, function (rawRes) { + server.inject('/postResponseNotFound', function (res) { - expect(rawRes.body).to.contain('error'); + expect(res.payload).to.contain('error'); done(); }); }); - it('handles an error from request safely', function (done) { + it('forwards on a POST body', function (done) { - var requestStub = function (options, callback) { + server.inject({ url: '/echo', method: 'POST', payload: '{"echo":true}' }, function (res) { - callback(new Error()); - }; + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('true@'); + done(); + }); + }); - var route = server._router.route({ method: 'get', path: '/proxyerror', info: {}, raw: { req: { headers: {} } } }); - route.proxy.httpClient = requestStub; + it('replies with an error when it occurs in mapUri', function (done) { - makeRequest({ path: '/proxyerror', method: 'get' }, function (rawRes) { + server.inject('/maperror', function (res) { - expect(rawRes.statusCode).to.equal(500); + expect(res.payload).to.contain('myerror'); done(); }); }); - it('forwards on a POST body', function (done) { + it('works with a stream when the proxy response is streamed', function (done) { + + Fs.createReadStream(__dirname + '/proxy.js').pipe(Request.post(server.info.uri + '/file', function (err, res, body) { + + expect(res.statusCode).to.equal(200); + done(); + })); + }); + + it('maxs out redirects to another endpoint', function (done) { - makeRequest({ path: '/echo', method: 'post', form: { echo: true } }, function (rawRes) { + server.inject('/redirect?x=1', function (res) { - expect(rawRes.statusCode).to.equal(200); - expect(rawRes.body).to.equal('true@'); + expect(res.statusCode).to.equal(500); done(); }); }); - it('replies with an error when it occurs in mapUri', function (done) { + it('errors on redirect missing location header', function (done) { - makeRequest({ path: '/maperror', method: 'get' }, function (rawRes) { + server.inject('/redirect?x=3', function (res) { - expect(rawRes.body).to.contain('myerror'); + expect(res.statusCode).to.equal(500); done(); }); }); - it('works with a stream when the proxy response is streamed', function (done) { + it('errors on redirection to bad host', function (done) { - Fs.createReadStream(__dirname + '/proxy.js').pipe(Request.post(server.info.uri + '/file', function (err, rawRes, body) { + server.inject('/nowhere', function (res) { - expect(rawRes.statusCode).to.equal(200); + expect(res.statusCode).to.equal(500); done(); - })); + }); }); - it('can add a proxy route with a http protocol set', function (done) { + it('redirects to another endpoint', function (done) { - server.route({ method: 'GET', path: '/httpport', handler: { proxy: { host: 'localhost', protocol: 'http' } } }); - done(); + server.inject('/redirect', function (res) { + + expect(res.statusCode).to.equal(200); + expect(res.payload).to.contain('John Doe'); + expect(res.headers['set-cookie']).to.deep.equal(['test=123', 'auto=xyz']); + done(); + }); }); - it('can add a proxy route with a https protocol set', function (done) { + it('redirects to another endpoint with relative location', function (done) { - server.route({ method: 'GET', path: '/httpsport', handler: { proxy: { host: 'localhost', protocol: 'https' } } }); - done(); + server.inject('/redirect?x=2', function (res) { + + expect(res.statusCode).to.equal(200); + expect(res.payload).to.contain('John Doe'); + expect(res.headers['set-cookie']).to.deep.equal(['test=123', 'auto=xyz']); + done(); + }); }); - it('proxies to a remote site', function (done) { + it('redirects to a post endpoint with stream', function (done) { - makeRequest({ path: '/google' }, function (rawRes) { + server.inject({ method: 'POST', url: '/post1', payload: 'test', headers: { 'content-type': 'text/plain' } }, function (res) { - expect(rawRes.statusCode).to.equal(200); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('test'); done(); }); }); -}); \ No newline at end of file + + it('errors on invalid response stream', function (done) { + + var orig = Client.parse; + Client.parse = function (res, callback) { + + Client.parse = orig; + callback(Hapi.error.internal('Fake error')); + }; + + server.inject('/cached', function (res) { + + expect(res.statusCode).to.equal(500); + done(); + }); + }); +}); + diff --git a/test/integration/request.js b/test/integration/request.js index 06d46a261..c2ca5c2f2 100755 --- a/test/integration/request.js +++ b/test/integration/request.js @@ -344,7 +344,8 @@ describe('Request', function () { var handler = function (request) { - expect(request.info.address).to.equal('127.0.0.1'); + expect(request.info.remoteAddress).to.equal('127.0.0.1'); + expect(request.info.remoteAddress).to.equal(request.info.address); request.reply('ok'); }; diff --git a/test/integration/response.js b/test/integration/response.js index 50c57d46c..dd33d139b 100755 --- a/test/integration/response.js +++ b/test/integration/response.js @@ -1079,12 +1079,7 @@ describe('Response', function () { var HeadersStream = function () { Stream.Readable.call(this); - this.response = { - headers: { - custom: 'header' - } - }; - + this.headers = { custom: 'header' }; return this; }; @@ -1123,10 +1118,7 @@ describe('Response', function () { var HeadersStream = function () { Stream.Readable.call(this); - this.response = { - statusCode: 201 - }; - + this.statusCode = 201; return this; }; @@ -1884,23 +1876,6 @@ describe('Response', function () { done(); }); }); - - it('returns 500 when using close() and route is cached', function (done) { - - var handler = function () { - - this.reply.close(); - }; - - var server = new Hapi.Server({ debug: false }); - server.route({ method: 'GET', path: '/', config: { handler: handler, cache: { mode: 'server', expiresIn: 9999 } } }); - - server.inject('/', function (res) { - - expect(res.statusCode).to.equal(500); - done(); - }); - }); }); describe('Extension', function () { diff --git a/test/unit/client.js b/test/unit/client.js new file mode 100644 index 000000000..d024b5af6 --- /dev/null +++ b/test/unit/client.js @@ -0,0 +1,58 @@ +// Load modules + +var Lab = require('lab'); +var Boom = require('boom'); +var Events = require('events'); +var Client = require('../../lib/client'); + + +// Declare internals + +var internals = {}; + + +// Test shortcuts + +var expect = Lab.expect; +var before = Lab.before; +var after = Lab.after; +var describe = Lab.experiment; +var it = Lab.test; + + +describe('Client', function () { + + describe('#parse', function () { + + it('handles errors with a boom response', function (done) { + + var res = new Events.EventEmitter(); + res.pipe = function () { }; + + Client.parse(res, function (err) { + + expect(err).to.be.instanceOf(Boom); + done(); + }); + + res.emit('error', new Error('my error')); + }); + + it('handles responses that close early', function (done) { + + var res = new Events.EventEmitter(); + res.pipe = function () { }; + + Client.parse(res, function (err) { + + expect(err).to.be.instanceOf(Boom); + done(); + }); + + res.emit('close'); + }); + }); +}); + + +