'use strict'; const Events = require('events'); const Http = require('http'); const Https = require('https'); const Stream = require('stream'); const Url = require('url'); const Zlib = require('zlib'); const Boom = require('boom'); const Bourne = require('bourne'); const Hoek = require('hoek'); const Payload = require('./payload'); const Recorder = require('./recorder'); const Tap = require('./tap'); const internals = { jsonRegex: /^application\/([a-z0-9.]*[+-]json|json)$/, shallowOptions: ['agent', 'agents', 'beforeRedirect', 'downstreamRes', 'payload', 'redirected'] }; // New instance is exported as module.exports internals.Client = function (options = {}) { Hoek.assert(!options.agents || (options.agents.https && options.agents.http && options.agents.httpsAllowUnauthorized), 'Option agents must include "http", "https", and "httpsAllowUnauthorized"'); this._defaults = Hoek.cloneWithShallow(options, internals.shallowOptions); this.agents = this._defaults.agents || { https: new Https.Agent({ maxSockets: Infinity }), http: new Http.Agent({ maxSockets: Infinity }), httpsAllowUnauthorized: new Https.Agent({ maxSockets: Infinity, rejectUnauthorized: false }) }; if (!options.events) { return; } this.events = new Events.EventEmitter(); this._emit = function (...args) { this.events.emit(...args); }; }; internals.Client.prototype.defaults = function (options) { Hoek.assert(options && (typeof options === 'object'), 'options must be provided to defaults'); options = Hoek.applyToDefaultsWithShallow(this._defaults, options, internals.shallowOptions); return new internals.Client(options); }; internals.resolveUrl = function (baseUrl, path) { if (!path) { return baseUrl; } const parsedPath = Url.parse(path); if (parsedPath.host) { Hoek.assert(parsedPath.protocol, 'Invalid destination path missing protocol'); return Url.format(parsedPath); } const parsedBase = Url.parse(baseUrl); parsedBase.pathname = parsedBase.pathname + parsedPath.pathname; parsedBase.pathname = parsedBase.pathname.replace(/[/]{2,}/g, '/'); parsedBase.search = parsedPath.search; // Always use the querystring from the path argument return Url.format(parsedBase); }; internals.Client.prototype.request = function (method, url, options = {}) { try { options = Hoek.applyToDefaultsWithShallow(this._defaults, options, internals.shallowOptions); Hoek.assert(options.payload === undefined || typeof options.payload === 'string' || typeof options.payload === 'object', 'options.payload must be a string, a Buffer, a Stream, or an Object'); Hoek.assert((options.agent === undefined || options.agent === null) || (typeof options.rejectUnauthorized !== 'boolean'), 'options.agent cannot be set to an Agent at the same time as options.rejectUnauthorized is set'); Hoek.assert(options.beforeRedirect === undefined || options.beforeRedirect === null || typeof options.beforeRedirect === 'function', 'options.beforeRedirect must be a function'); Hoek.assert(options.redirected === undefined || options.redirected === null || typeof options.redirected === 'function', 'options.redirected must be a function'); Hoek.assert(options.gunzip === undefined || typeof options.gunzip === 'boolean' || options.gunzip === 'force', 'options.gunzip must be a boolean or "force"'); } catch (err) { return Promise.reject(err); } options.beforeRedirect = options.beforeRedirect || ((redirectMethod, statusCode, location, resHeaders, redirectOptions, next) => next()); if (options.baseUrl) { url = internals.resolveUrl(options.baseUrl, url); delete options.baseUrl; } const relay = {}; const req = this._request(method, url, options, relay); const promise = new Promise((resolve, reject) => { relay.callback = (err, res) => { if (err) { reject(err); return; } resolve(res); return; }; }); promise.req = req; return promise; }; internals.Client.prototype._request = function (method, url, options, relay, _trace) { const uri = {}; let parsedUri; if (options.socketPath) { uri.socketPath = options.socketPath; delete options.socketPath; parsedUri = Url.parse(url); } else { uri.setHost = false; parsedUri = new Url.URL(url); } internals.applyUrlToOptions(uri, parsedUri); uri.method = method.toUpperCase(); uri.headers = options.headers || {}; uri.headers.host = parsedUri.host; const hasContentLength = internals.findHeader('content-length', uri.headers) !== undefined; if (options.payload && typeof options.payload === 'object' && !(options.payload instanceof Stream) && !Buffer.isBuffer(options.payload)) { options.payload = JSON.stringify(options.payload); if (!internals.findHeader('content-type', uri.headers)) { uri.headers['content-type'] = 'application/json'; } } if (options.gunzip && internals.findHeader('accept-encoding', uri.headers) === undefined) { uri.headers['accept-encoding'] = 'gzip'; } const payloadSupported = (uri.method !== 'GET' && uri.method !== 'HEAD' && options.payload !== null && options.payload !== undefined); if (payloadSupported && (typeof options.payload === 'string' || Buffer.isBuffer(options.payload)) && (!hasContentLength)) { uri.headers = Hoek.clone(uri.headers); uri.headers['content-length'] = Buffer.isBuffer(options.payload) ? options.payload.length : Buffer.byteLength(options.payload); } let 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 }); const client = (uri.protocol === 'https:' ? Https : Http); if (options.rejectUnauthorized !== undefined && uri.protocol === 'https:') { uri.agent = options.rejectUnauthorized ? this.agents.https : this.agents.httpsAllowUnauthorized; } else if (options.agent || options.agent === false) { uri.agent = options.agent; } else { uri.agent = uri.protocol === 'https:' ? this.agents.https : this.agents.http; } if (options.secureProtocol !== undefined) { uri.secureProtocol = options.secureProtocol; } if (options.ciphers !== undefined) { uri.ciphers = options.ciphers; } if (this._emit) { this._emit('request', uri, options); } const start = Date.now(); const req = client.request(uri); let shadow = null; // A copy of the streamed request payload when redirects are enabled let timeoutId; const onError = (err) => { err.trace = _trace; return finishOnce(Boom.badGateway('Client request error', err)); }; req.once('error', onError); const onResponse = (res) => { // Pass-through response const statusCode = res.statusCode; const redirectMethod = internals.redirectMethod(statusCode, uri.method, options); if (redirects === false || !redirectMethod) { return finishOnce(null, res); } // Redirection res.destroy(); if (redirects === 0) { return finishOnce(Boom.badGateway('Maximum redirections reached', _trace)); } let location = res.headers.location; if (!location) { return finishOnce(Boom.badGateway('Received redirection without location', _trace)); } if (!/^https?:/i.test(location)) { location = Url.resolve(uri.href, location); } const redirectOptions = Hoek.cloneWithShallow(options, internals.shallowOptions); redirectOptions.payload = shadow || options.payload; // shadow must be ready at this point if set redirectOptions.redirects = --redirects; return options.beforeRedirect(redirectMethod, statusCode, location, res.headers, redirectOptions, () => { const redirectReq = this._request(redirectMethod, location, redirectOptions, { callback: finishOnce }, _trace); if (options.redirected) { options.redirected(statusCode, location, redirectReq); } }); }; // Register handlers const finish = (err, res) => { if (err) { req.abort(); } req.removeListener('response', onResponse); req.removeListener('error', onError); req.on('error', Hoek.ignore); clearTimeout(timeoutId); if (this._emit) { this._emit('response', err, { req, res, start, uri }); } return relay.callback(err, res); }; const finishOnce = Hoek.once(finish); req.once('response', onResponse); if (options.timeout) { timeoutId = setTimeout(() => { return finishOnce(Boom.gatewayTimeout('Client request timeout')); }, options.timeout); delete options.timeout; } // Custom abort method to detect early aborts const _abort = req.abort; let aborted = false; req.abort = () => { if (!aborted && !req.res && !req.socket) { process.nextTick(() => { // Fake an ECONNRESET error const error = new Error('socket hang up'); error.code = 'ECONNRESET'; finishOnce(error); }); } aborted = true; return _abort.call(req); }; // Write payload if (payloadSupported) { if (options.payload instanceof Stream) { let stream = options.payload; if (redirects) { const collector = new Tap(); collector.once('finish', () => { shadow = collector.collect(); }); stream = options.payload.pipe(collector); } internals.deferPipeUntilSocketConnects(req, stream); return req; } req.write(options.payload); } // Finalize request req.end(); return req; }; internals.deferPipeUntilSocketConnects = function (req, stream) { const onSocket = (socket) => { if (!socket.connecting) { return onSocketConnect(); } socket.once('connect', onSocketConnect); }; const onSocketConnect = () => { stream.pipe(req); stream.removeListener('error', onStreamError); }; const onStreamError = (err) => { req.emit('error', err); }; req.once('socket', onSocket); stream.on('error', onStreamError); }; internals.redirectMethod = function (code, method, options) { switch (code) { case 301: case 302: return method; case 303: if (options.redirect303) { return 'GET'; } break; case 307: case 308: return method; } return null; }; internals.Client.prototype.read = function (res, options = {}) { return new Promise((resolve, reject) => { this._read(res, options, (err, payload) => { if (err) { reject(err); return; } resolve(payload); return; }); }); }; internals.Client.prototype._read = function (res, options, callback) { options = Hoek.applyToDefaultsWithShallow(this._defaults, options, internals.shallowOptions); // Finish once let clientTimeoutId = null; const finish = (err, buffer) => { clearTimeout(clientTimeoutId); reader.removeListener('error', onReaderError); reader.removeListener('finish', onReaderFinish); res.removeListener('error', onResError); res.removeListener('close', onResAborted); res.removeListener('aborted', onResAborted); res.on('error', Hoek.ignore); if (err) { return callback(err); } if (!options.json) { return callback(null, buffer); } // Parse JSON if (buffer.length === 0) { return callback(null, null); } if (options.json === 'force') { return internals.tryParseBuffer(buffer, callback); } // mode is "smart", "strict" or true const contentType = (res.headers && internals.findHeader('content-type', res.headers)) || ''; const mime = contentType.split(';')[0].trim().toLowerCase(); if (!internals.jsonRegex.test(mime)) { if (options.json === 'strict') { return callback(Boom.notAcceptable('The content-type is not JSON compatible')); } return callback(null, buffer); } return internals.tryParseBuffer(buffer, callback); }; const finishOnce = Hoek.once(finish); const clientTimeout = options.timeout; if (clientTimeout && clientTimeout > 0) { clientTimeoutId = setTimeout(() => { finishOnce(Boom.clientTimeout()); }, clientTimeout); } // Hander errors const onResError = (err) => { return finishOnce(err.isBoom ? err : Boom.internal('Payload stream error', err)); }; const onResAborted = () => { // Workaround https://github.com/nodejs/node/pull/20611 // This is covered in node 10 /* $lab:coverage:off$ */ if (res.complete) { return; } /* $lab:coverage:on$ */ return finishOnce(Boom.internal('Payload stream closed prematurely')); }; res.once('error', onResError); res.once('close', onResAborted); res.once('aborted', onResAborted); // Read payload const reader = new Recorder({ maxBytes: options.maxBytes }); const onReaderError = (err) => { if (res.destroy) { // GZip stream has no destroy() method res.destroy(); } return finishOnce(err); }; reader.once('error', onReaderError); const onReaderFinish = () => { return finishOnce(null, reader.collect()); }; reader.once('finish', onReaderFinish); if (options.gunzip) { const contentEncoding = options.gunzip === 'force' ? 'gzip' : (res.headers && internals.findHeader('content-encoding', res.headers)) || ''; if (/^(x-)?gzip(\s*,\s*identity)?$/.test(contentEncoding)) { const gunzip = Zlib.createGunzip(); gunzip.once('error', onReaderError); res.pipe(gunzip).pipe(reader); return; } } res.pipe(reader); }; internals.Client.prototype.toReadableStream = function (payload, encoding) { return new Payload(payload, encoding); }; internals.Client.prototype.parseCacheControl = function (field) { /* Cache-Control = 1#cache-directive cache-directive = token [ "=" ( token / quoted-string ) ] token = [^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+ quoted-string = "(?:[^"\\]|\\.)*" */ // 1: directive = 2: token 3: quoted-string const regex = /(?:^|(?:\s*\,\s*))([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\]|\\.)*)\")))?/g; const header = {}; const error = field.replace(regex, ($0, $1, $2, $3) => { const value = $2 || $3; header[$1] = value ? value.toLowerCase() : true; return ''; }); if (header['max-age']) { try { const maxAge = parseInt(header['max-age'], 10); if (isNaN(maxAge)) { return null; } header['max-age'] = maxAge; } catch (err) { } } return (error ? null : header); }; // Shortcuts internals.Client.prototype.get = function (uri, options) { return this._shortcut('GET', uri, options); }; internals.Client.prototype.post = function (uri, options) { return this._shortcut('POST', uri, options); }; internals.Client.prototype.patch = function (uri, options) { return this._shortcut('PATCH', uri, options); }; internals.Client.prototype.put = function (uri, options) { return this._shortcut('PUT', uri, options); }; internals.Client.prototype.delete = function (uri, options) { return this._shortcut('DELETE', uri, options); }; internals.Client.prototype._shortcut = async function (method, uri, options = {}) { const res = await this.request(method, uri, options); let payload; try { payload = await this.read(res, options); } catch (err) { err.data = err.data || {}; err.data.res = res; throw err; } if (res.statusCode < 400) { return { res, payload }; } // Response error const data = { isResponseError: true, headers: res.headers, res, payload }; throw new Boom(`Response Error: ${res.statusCode} ${res.statusMessage}`, { statusCode: res.statusCode, data }); }; internals.tryParseBuffer = function (buffer, next) { let payload; try { payload = Bourne.parse(buffer.toString()); } catch (err) { return next(Boom.badGateway(err.message, { payload: buffer })); } return next(null, payload); }; internals.findHeader = function (headerName, headers) { const foundKey = Object.keys(headers) .find((key) => key.toLowerCase() === headerName.toLowerCase()); return foundKey && headers[foundKey]; }; internals.applyUrlToOptions = (options, url) => { options.origin = url.origin; options.searchParams = url.searchParams; options.protocol = url.protocol; options.hostname = url.hostname; options.hash = url.hash; options.search = url.search; options.pathname = url.pathname; options.path = `${url.pathname}${url.search || ''}`; options.href = url.href; if (url.port !== '') { options.port = Number(url.port); } if (url.username || url.password) { options.auth = `${url.username}:${url.password}`; options.username = url.username; options.password = url.password; } return options; }; module.exports = new internals.Client();