You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
978 lines
38 KiB
JavaScript
978 lines
38 KiB
JavaScript
"use strict"; // FIXME: Force-lowercase user-supplied headers before merging them into the request?
|
|
// FIXME: Deep-merge query-string arguments between URL and argument?
|
|
// FIXME: Named arrays for multipart/form-data?
|
|
// FIXME: Are arrays of streams in `data` correctly recognized as being streams?
|
|
// Core modules
|
|
|
|
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
|
|
|
|
function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; }
|
|
|
|
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); }
|
|
|
|
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
|
|
|
|
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
|
|
|
|
function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && Symbol.iterator in Object(iter)) return Array.from(iter); }
|
|
|
|
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); }
|
|
|
|
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
|
|
|
|
var urlUtil = require("url");
|
|
|
|
var querystring = require("querystring");
|
|
|
|
var stream = require("stream");
|
|
|
|
var http = require("http");
|
|
|
|
var https = require("https"); // Utility modules
|
|
|
|
|
|
var Promise = require("bluebird");
|
|
|
|
var formFixArray = require("form-fix-array");
|
|
|
|
var errors = require("errors");
|
|
|
|
var debug = require("debug");
|
|
|
|
var debugRequest = debug("bhttp:request");
|
|
var debugResponse = debug("bhttp:response");
|
|
|
|
var extend = require("extend");
|
|
|
|
var devNull = require("dev-null");
|
|
|
|
var deepClone = require("lodash.clonedeep");
|
|
|
|
var deepMerge = require("lodash.merge"); // Other third-party modules
|
|
|
|
|
|
var formData = require("form-data2");
|
|
|
|
var concatStream = require("concat-stream");
|
|
|
|
var toughCookie = require("tough-cookie");
|
|
|
|
var streamLength = require("stream-length");
|
|
|
|
var sink = require("through2-sink");
|
|
|
|
var spy = require("through2-spy"); // For the version in the user agent, etc.
|
|
|
|
|
|
var packageConfig = require("../package.json");
|
|
|
|
var bhttpErrors = {}; // Error types
|
|
|
|
errors.create({
|
|
name: "bhttpError",
|
|
scope: bhttpErrors
|
|
});
|
|
errors.create({
|
|
name: "ConflictingOptionsError",
|
|
parents: bhttpErrors.bhttpError,
|
|
scope: bhttpErrors
|
|
});
|
|
errors.create({
|
|
name: "UnsupportedProtocolError",
|
|
parents: bhttpErrors.bhttpError,
|
|
scope: bhttpErrors
|
|
});
|
|
errors.create({
|
|
name: "RedirectError",
|
|
parents: bhttpErrors.bhttpError,
|
|
scope: bhttpErrors
|
|
});
|
|
errors.create({
|
|
name: "MultipartError",
|
|
parents: bhttpErrors.bhttpError,
|
|
scope: bhttpErrors
|
|
});
|
|
errors.create({
|
|
name: "ConnectionTimeoutError",
|
|
parents: bhttpErrors.bhttpError,
|
|
scope: bhttpErrors
|
|
});
|
|
errors.create({
|
|
name: "ResponseTimeoutError",
|
|
parents: bhttpErrors.bhttpError,
|
|
scope: bhttpErrors
|
|
}); // Utility functions
|
|
|
|
function shallowClone(object) {
|
|
return Object.assign({}, object);
|
|
}
|
|
|
|
function iterateValues(object) {
|
|
if (object == null) {
|
|
return [];
|
|
} else {
|
|
return Object.values(object);
|
|
}
|
|
}
|
|
|
|
function assign() {
|
|
for (var _len = arguments.length, objects = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
objects[_key] = arguments[_key];
|
|
}
|
|
|
|
var validObjects = objects.filter(function (object) {
|
|
return object != null;
|
|
});
|
|
|
|
if (validObjects.length === 0) {
|
|
return {};
|
|
} else if (validObjects.length === 1) {
|
|
return validObjects[0];
|
|
} else {
|
|
return Object.assign.apply(Object, _toConsumableArray(validObjects));
|
|
}
|
|
}
|
|
|
|
var ofTypes = function ofTypes(obj, types) {
|
|
var match = false;
|
|
|
|
var _iterator = _createForOfIteratorHelper(types),
|
|
_step;
|
|
|
|
try {
|
|
for (_iterator.s(); !(_step = _iterator.n()).done;) {
|
|
var type = _step.value;
|
|
match = match || obj instanceof type;
|
|
}
|
|
} catch (err) {
|
|
_iterator.e(err);
|
|
} finally {
|
|
_iterator.f();
|
|
}
|
|
|
|
return match;
|
|
};
|
|
|
|
var isStream = function isStream(obj) {
|
|
return obj != null && (ofTypes(obj, [stream.Readable, stream.Duplex, stream.Transform]) || obj.hasOwnProperty("_bhttpStreamWrapper"));
|
|
}; // Middleware
|
|
// NOTE: requestState is an object that signifies the current state of the overall request; eg. for a response involving one or more redirects, it will hold a 'redirect history'.
|
|
|
|
|
|
var prepareSession = function prepareSession(request, response, requestState) {
|
|
debugRequest("preparing session");
|
|
return Promise.try(function () {
|
|
if (requestState.sessionOptions != null) {
|
|
// Request options take priority over session options
|
|
request.options = deepMerge(shallowClone(requestState.sessionOptions), request.options);
|
|
} // Create a headers parameter if it doesn't exist yet - we'll need to add some stuff to this later on
|
|
// FIXME: We may need to do a deep-clone of other mutable options later on as well; otherwise, when getting a redirect in a session with pre-defined options, the contents may not be correctly cleared after following the redirect.
|
|
|
|
|
|
if (request.options.headers != null) {
|
|
request.options.headers = deepClone(request.options.headers);
|
|
} else {
|
|
request.options.headers = {};
|
|
} // If we have a cookie jar, start out by setting the cookie string.
|
|
|
|
|
|
if (request.options.cookieJar != null) {
|
|
return Promise.try(function () {
|
|
// Move the cookieJar to the request object, the http/https module doesn't need it.
|
|
request.cookieJar = request.options.cookieJar;
|
|
delete request.options.cookieJar; // Get the current cookie string for the URL
|
|
|
|
return request.cookieJar.get(request.url);
|
|
}).then(function (cookieString) {
|
|
debugRequest("sending cookie string: %s", cookieString);
|
|
request.options.headers["cookie"] = cookieString;
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
} else {
|
|
return Promise.resolve([request, response, requestState]);
|
|
}
|
|
});
|
|
};
|
|
|
|
var prepareDefaults = function prepareDefaults(request, response, requestState) {
|
|
debugRequest("preparing defaults");
|
|
return Promise.try(function () {
|
|
// These are the options that we need for response processing, but don't need to be passed on to the http/https module.
|
|
request.responseOptions = {
|
|
discardResponse: request.options.discardResponse != null ? request.options.discardResponse : false,
|
|
keepRedirectResponses: request.options.keepRedirectResponses != null ? request.options.keepRedirectResponses : false,
|
|
followRedirects: request.options.followRedirects != null ? request.options.followRedirects : true,
|
|
noDecode: request.options.noDecode != null ? request.options.noDecode : false,
|
|
decodeJSON: request.options.decodeJSON != null ? request.options.decodeJSON : false,
|
|
stream: request.options.stream != null ? request.options.stream : false,
|
|
justPrepare: request.options.justPrepare != null ? request.options.justPrepare : false,
|
|
redirectLimit: request.options.redirectLimit != null ? request.options.redirectLimit : 10,
|
|
onDownloadProgress: request.options.onDownloadProgress,
|
|
responseTimeout: request.options.responseTimeout
|
|
}; // Whether chunked transfer encoding for multipart/form-data payloads is acceptable. This is likely to break quietly on a lot of servers.
|
|
|
|
if (request.options.allowChunkedMultipart == null) {
|
|
request.options.allowChunkedMultipart = false;
|
|
} // Whether we should always use multipart/form-data for payloads, even if querystring-encoding would be a possibility.
|
|
|
|
|
|
if (request.options.forceMultipart == null) {
|
|
request.options.forceMultipart = false;
|
|
} // If no custom user-agent is defined, set our own
|
|
|
|
|
|
if (request.options.headers["user-agent"] == null) {
|
|
request.options.headers["user-agent"] = "bhttp/".concat(packageConfig.version);
|
|
} // Normalize the request method to lowercase.
|
|
|
|
|
|
request.options.method = request.options.method.toLowerCase();
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
};
|
|
|
|
var prepareUrl = function prepareUrl(request, response, requestState) {
|
|
debugRequest("preparing URL");
|
|
return Promise.try(function () {
|
|
// Parse the specified URL, and use the resulting information to build a complete `options` object
|
|
var urlOptions = urlUtil.parse(request.url, true);
|
|
assign(request.options, {
|
|
hostname: urlOptions.hostname,
|
|
port: urlOptions.port
|
|
});
|
|
request.options.path = urlUtil.format({
|
|
pathname: urlOptions.pathname,
|
|
query: request.options.query != null ? request.options.query : urlOptions.query
|
|
});
|
|
request.protocol = urlOptions.protocol.replace(/:$/, "");
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
};
|
|
|
|
var prepareProtocol = function prepareProtocol(request, response, requestState) {
|
|
debugRequest("preparing protocol");
|
|
return Promise.try(function () {
|
|
request.protocolModule = function () {
|
|
switch (request.protocol) {
|
|
case "http":
|
|
return http;
|
|
|
|
case "https":
|
|
return https;
|
|
// CAUTION / FIXME: Node will silently ignore SSL settings without a custom agent!
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}();
|
|
|
|
if (request.protocolModule == null) {
|
|
return Promise.reject(new bhttpErrors.UnsupportedProtocolError("The protocol specified (".concat(request.protocol, ") is not currently supported by this module.")));
|
|
}
|
|
|
|
if (request.options.port == null) {
|
|
request.options.port = function () {
|
|
switch (request.protocol) {
|
|
case "http":
|
|
return 80;
|
|
|
|
case "https":
|
|
return 443;
|
|
}
|
|
}();
|
|
}
|
|
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
};
|
|
|
|
var prepareOptions = function prepareOptions(request, response, requestState) {
|
|
debugRequest("preparing options");
|
|
return Promise.try(function () {
|
|
// Do some sanity checks - there are a number of options that cannot be used together
|
|
if ((request.options.formFields != null || request.options.files != null) && (request.options.inputStream != null || request.options.inputBuffer != null)) {
|
|
return Promise.reject(new bhttpErrors.ConflictingOptionsError({
|
|
message: "You cannot define both formFields/files and a raw inputStream or inputBuffer.",
|
|
request: request,
|
|
response: response,
|
|
requestState: requestState
|
|
}));
|
|
}
|
|
|
|
if (request.options.encodeJSON && (request.options.inputStream != null || request.options.inputBuffer != null)) {
|
|
var _bhttpErrors$Conflict;
|
|
|
|
return Promise.reject(new bhttpErrors.ConflictingOptionsError((_bhttpErrors$Conflict = {
|
|
message: "You cannot use both encodeJSON and a raw inputStream or inputBuffer.",
|
|
response: "If you meant to JSON-encode the stream, you will currently have to do so manually.",
|
|
request: request
|
|
}, _defineProperty(_bhttpErrors$Conflict, "response", response), _defineProperty(_bhttpErrors$Conflict, "requestState", requestState), _bhttpErrors$Conflict)));
|
|
} // If the user plans on streaming the response, we need to disable the agent entirely - otherwise the streams will block the pool.
|
|
|
|
|
|
if (request.responseOptions.stream) {
|
|
if (request.options.agent == null) {
|
|
request.options.agent = false;
|
|
}
|
|
}
|
|
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
};
|
|
|
|
var preparePayload = function preparePayload(request, response, requestState) {
|
|
debugRequest("preparing payload");
|
|
return Promise.try(function () {
|
|
// Persist the download progress event handler on the request object, if there is one.
|
|
request.onUploadProgress = request.options.onUploadProgress; // If a 'files' parameter is present, then we will send the form data as multipart data - it's most likely binary data.
|
|
|
|
var multipart = request.options.forceMultipart || request.options.files != null; // Similarly, if any of the formFields values are either a Stream or a Buffer, we will assume that the form should be sent as multipart.
|
|
|
|
multipart = multipart || iterateValues(request.options.formFields).some(function (item) {
|
|
return item instanceof Buffer || isStream(item);
|
|
}); // Really, 'files' and 'formFields' are the same thing - they mostly have different names for 1) clarity and 2) multipart detection. We combine them here.
|
|
|
|
assign(request.options.formFields, request.options.files); // For a last sanity check, we want to know whether there are any Stream objects in our form data *at all* - these can't be used when encodeJSON is enabled.
|
|
|
|
var containsStreams = iterateValues(request.options.formFields).some(function (item) {
|
|
return isStream(item);
|
|
});
|
|
|
|
if (request.options.encodeJSON && containsStreams) {
|
|
return Promise.reject(new bhttpErrors.ConflictingOptionsError("Sending a JSON-encoded payload containing data from a stream is not currently supported.", undefined, "Either don't use encodeJSON, or read your stream into a string or Buffer."));
|
|
}
|
|
|
|
if (!["get", "head", "delete"].includes(request.options.method)) {
|
|
// Prepare the payload, and set the appropriate headers.
|
|
if ((request.options.encodeJSON || request.options.formFields != null) && !multipart) {
|
|
// We know the payload and its size in advance.
|
|
debugRequest("got url-encodable form-data");
|
|
|
|
if (request.options.encodeJSON) {
|
|
debugRequest("... but encodeJSON was set, so we will send JSON instead");
|
|
request.options.headers["content-type"] = "application/json";
|
|
request.payload = JSON.stringify(request.options.formFields != null ? request.options.formFields : null);
|
|
} else if (Object.keys(request.options.formFields).length > 0) {
|
|
// The `querystring` module copies the key name verbatim, even if the value is actually an array. Things like PHP don't understand this, and expect every array-containing key to be suffixed with []. We'll just append that ourselves, then.
|
|
request.options.headers["content-type"] = "application/x-www-form-urlencoded";
|
|
request.payload = querystring.stringify(formFixArray(request.options.formFields));
|
|
} else {
|
|
request.payload = "";
|
|
}
|
|
|
|
request.options.headers["content-length"] = request.payload.length;
|
|
return Promise.resolve();
|
|
} else if (request.options.formFields != null && multipart) {
|
|
// This is going to be multipart data, and we'll let `form-data` set the headers for us.
|
|
debugRequest("got multipart form-data");
|
|
var formDataObject = new formData();
|
|
var object = formFixArray(request.options.formFields);
|
|
|
|
for (var fieldName in object) {
|
|
var fieldValue = object[fieldName];
|
|
|
|
if (!Array.isArray(fieldValue)) {
|
|
fieldValue = [fieldValue];
|
|
}
|
|
|
|
var _iterator2 = _createForOfIteratorHelper(fieldValue),
|
|
_step2;
|
|
|
|
try {
|
|
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
|
|
var valueElement = _step2.value;
|
|
var streamOptions;
|
|
|
|
if (valueElement._bhttpStreamWrapper != null) {
|
|
streamOptions = valueElement.options;
|
|
valueElement = valueElement.stream;
|
|
} else {
|
|
streamOptions = {};
|
|
}
|
|
|
|
formDataObject.append(fieldName, valueElement, streamOptions);
|
|
}
|
|
} catch (err) {
|
|
_iterator2.e(err);
|
|
} finally {
|
|
_iterator2.f();
|
|
}
|
|
}
|
|
|
|
request.payloadStream = formDataObject;
|
|
return Promise.try(function () {
|
|
return formDataObject.getHeaders();
|
|
}).then(function (headers) {
|
|
if (headers["content-transfer-encoding"] === "chunked" && !request.options.allowChunkedMultipart) {
|
|
return Promise.reject(new bhttpErrors.MultipartError({
|
|
message: "Most servers do not support chunked transfer encoding for multipart/form-data payloads, and we could not determine the length of all the input streams. See the documentation for more information.",
|
|
request: request,
|
|
response: response,
|
|
requestState: requestState
|
|
}));
|
|
} else {
|
|
assign(request.options.headers, headers);
|
|
return Promise.resolve();
|
|
}
|
|
});
|
|
} else if (request.options.inputStream != null) {
|
|
// A raw inputStream was provided, just leave it be.
|
|
debugRequest("got inputStream");
|
|
return Promise.try(function () {
|
|
request.payloadStream = request.options.inputStream;
|
|
|
|
if (request.payloadStream._bhttpStreamWrapper != null && (request.payloadStream.options.contentLength != null || request.payloadStream.options.knownLength != null)) {
|
|
return Promise.resolve(request.payloadStream.options.contentLength != null ? request.payloadStream.options.contentLength : request.payloadStream.options.knownLength);
|
|
} else {
|
|
return streamLength(request.options.inputStream);
|
|
}
|
|
}).then(function (length) {
|
|
debugRequest("length for inputStream is %s", length);
|
|
request.options.headers["content-length"] = length;
|
|
}).catch(function (_error) {
|
|
debugRequest("unable to determine inputStream length, switching to chunked transfer encoding");
|
|
request.options.headers["content-transfer-encoding"] = "chunked";
|
|
});
|
|
} else if (request.options.inputBuffer != null) {
|
|
// A raw inputBuffer was provided, just leave it be (but make sure it's an actual Buffer).
|
|
debugRequest("got inputBuffer");
|
|
|
|
if (typeof request.options.inputBuffer === "string") {
|
|
request.payload = new Buffer(request.options.inputBuffer); // Input string should be utf-8!
|
|
} else {
|
|
request.payload = request.options.inputBuffer;
|
|
}
|
|
|
|
debugRequest("length for inputBuffer is %s", request.payload.length);
|
|
request.options.headers["content-length"] = request.payload.length;
|
|
return Promise.resolve();
|
|
} else {
|
|
// No payload specified.
|
|
return Promise.resolve();
|
|
}
|
|
} else {
|
|
// GET, HEAD and DELETE should not have a payload. While technically not prohibited by the spec, it's also not specified, and we'd rather not upset poorly-compliant webservers.
|
|
// FIXME: Should this throw an Error?
|
|
return Promise.resolve();
|
|
}
|
|
}).then(function () {
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
};
|
|
|
|
var prepareCleanup = function prepareCleanup(request, response, requestState) {
|
|
debugRequest("preparing cleanup");
|
|
return Promise.try(function () {
|
|
// Remove the options that we're not going to pass on to the actual http/https library.
|
|
var key;
|
|
|
|
for (var _i = 0, _arr = ["query", "formFields", "files", "encodeJSON", "inputStream", "inputBuffer", "discardResponse", "keepRedirectResponses", "followRedirects", "noDecode", "decodeJSON", "allowChunkedMultipart", "forceMultipart", "onUploadProgress", "onDownloadProgress"]; _i < _arr.length; _i++) {
|
|
key = _arr[_i];
|
|
delete request.options[key];
|
|
} // Lo-Dash apparently has no `map` equivalent for object keys...?
|
|
|
|
|
|
var fixedHeaders = {};
|
|
|
|
for (key in request.options.headers) {
|
|
var value = request.options.headers[key];
|
|
fixedHeaders[key.toLowerCase()] = value;
|
|
}
|
|
|
|
request.options.headers = fixedHeaders;
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
}; // The guts of the module
|
|
|
|
|
|
var prepareRequest = function prepareRequest(request, response, requestState) {
|
|
debugRequest("preparing request"); // FIXME: Mock httpd for testing functionality.
|
|
|
|
return Promise.try(function () {
|
|
var middlewareFunctions = [prepareSession, prepareDefaults, prepareUrl, prepareProtocol, prepareOptions, preparePayload, prepareCleanup];
|
|
var promiseChain = Promise.resolve([request, response, requestState]);
|
|
middlewareFunctions.forEach(function (middleware) {
|
|
// We must use the functional construct here, to avoid losing references
|
|
promiseChain = promiseChain.spread(function (_request, _response, _requestState) {
|
|
return middleware(_request, _response, _requestState);
|
|
});
|
|
});
|
|
return promiseChain;
|
|
});
|
|
};
|
|
|
|
var makeRequest = function makeRequest(request, response, requestState) {
|
|
debugRequest("making %s request to %s", request.options.method.toUpperCase(), request.url);
|
|
return Promise.try(function () {
|
|
// Instantiate a regular HTTP/HTTPS request
|
|
var req = request.protocolModule.request(request.options);
|
|
var timeoutTimer = null;
|
|
return new Promise(function (resolve, reject) {
|
|
// Connection timeout handling, if one is set.
|
|
if (request.responseOptions.responseTimeout != null) {
|
|
debugRequest("setting response timeout timer to ".concat(request.responseOptions.responseTimeout, "ms..."));
|
|
req.on("socket", function (_socket) {
|
|
var timeoutHandler = function timeoutHandler() {
|
|
debugRequest("a response timeout occurred!");
|
|
req.abort();
|
|
return reject(new bhttpErrors.ResponseTimeoutError("The response timed out."));
|
|
};
|
|
|
|
timeoutTimer = setTimeout(timeoutHandler, request.responseOptions.responseTimeout);
|
|
});
|
|
} // Set up the upload progress monitoring.
|
|
|
|
|
|
var totalBytes = request.options.headers["content-length"];
|
|
var completedBytes = 0;
|
|
var progressStream = spy(function (chunk) {
|
|
completedBytes += chunk.length;
|
|
return req.emit("progress", completedBytes, totalBytes);
|
|
});
|
|
|
|
if (request.onUploadProgress != null) {
|
|
req.on("progress", function (completedBytes, totalBytes) {
|
|
return request.onUploadProgress(completedBytes, totalBytes, req);
|
|
});
|
|
} // This is where we write our payload or stream to the request, and the actual request is made.
|
|
|
|
|
|
if (request.payload != null) {
|
|
// The entire payload is a single Buffer. We'll still pretend that it's a stream for our progress events, though, to provide a consistent API.
|
|
debugRequest("sending payload");
|
|
req.emit("progress", request.payload.length, request.payload.length);
|
|
req.write(request.payload);
|
|
req.end();
|
|
} else if (request.payloadStream != null) {
|
|
// The payload is a stream.
|
|
debugRequest("piping payloadStream");
|
|
|
|
if (request.payloadStream._bhttpStreamWrapper != null) {
|
|
request.payloadStream.stream.pipe(progressStream).pipe(req);
|
|
} else {
|
|
request.payloadStream.pipe(progressStream).pipe(req);
|
|
}
|
|
} else {
|
|
// For GET, HEAD, DELETE, etc. there is no payload, but we still need to call end() to complete the request.
|
|
debugRequest("closing request without payload");
|
|
req.end();
|
|
} // In case something goes wrong during this process, somehow...
|
|
|
|
|
|
req.on("error", function (err) {
|
|
if (err.code === "ETIMEDOUT") {
|
|
debugRequest("a connection timeout occurred!");
|
|
return reject(new bhttpErrors.ConnectionTimeoutError("The connection timed out."));
|
|
} else {
|
|
return reject(err);
|
|
}
|
|
});
|
|
return req.on("response", function (res) {
|
|
if (timeoutTimer != null) {
|
|
debugResponse("got response in time, clearing response timeout timer");
|
|
clearTimeout(timeoutTimer);
|
|
}
|
|
|
|
return resolve(res);
|
|
});
|
|
});
|
|
}).then(function (response) {
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
};
|
|
|
|
var processResponse = function processResponse(request, response, requestState) {
|
|
debugResponse("processing response, got status code %s", response.statusCode); // When we receive the response, we'll buffer it up and/or decode it, depending on what the user specified, and resolve the returned Promise. If the user just wants the raw stream, we resolve immediately after receiving a response.
|
|
|
|
return Promise.try(function () {
|
|
// First, if a cookie jar is set and we received one or more cookies from the server, we should store them in our cookieJar.
|
|
if (request.cookieJar != null && response.headers["set-cookie"] != null) {
|
|
var promises = function () {
|
|
var result = [];
|
|
|
|
var _iterator3 = _createForOfIteratorHelper(response.headers["set-cookie"]),
|
|
_step3;
|
|
|
|
try {
|
|
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
|
|
var cookieHeader = _step3.value;
|
|
debugResponse("storing cookie: %s", cookieHeader);
|
|
result.push(request.cookieJar.set(cookieHeader, request.url));
|
|
}
|
|
} catch (err) {
|
|
_iterator3.e(err);
|
|
} finally {
|
|
_iterator3.f();
|
|
}
|
|
|
|
return result;
|
|
}();
|
|
|
|
return Promise.all(promises);
|
|
} else {
|
|
return Promise.resolve();
|
|
}
|
|
}).then(function () {
|
|
// Now the actual response processing.
|
|
response.request = request;
|
|
response.requestState = requestState;
|
|
response.redirectHistory = requestState.redirectHistory;
|
|
|
|
if ([301, 302, 303, 307].includes(response.statusCode) && request.responseOptions.followRedirects) {
|
|
if (requestState.redirectHistory.length >= request.responseOptions.redirectLimit - 1) {
|
|
return Promise.reject(new bhttpErrors.RedirectError("The maximum amount of redirects ({request.responseOptions.redirectLimit}) was reached."));
|
|
} // 301: For GET and HEAD, redirect unchanged. For POST, PUT, PATCH, DELETE, "ask user" (in our case: throw an error.)
|
|
// 302: Redirect, change method to GET.
|
|
// 303: Redirect, change method to GET.
|
|
// 307: Redirect, retain method. Make same request again.
|
|
|
|
|
|
switch (response.statusCode) {
|
|
case 301:
|
|
switch (request.options.method) {
|
|
case "get":
|
|
case "head":
|
|
return redirectUnchanged(request, response, requestState);
|
|
|
|
case "post":
|
|
case "put":
|
|
case "patch":
|
|
case "delete":
|
|
return Promise.reject(new bhttpErrors.RedirectError({
|
|
message: "Encountered a 301 redirect for POST, PUT, PATCH or DELETE. RFC says we can't automatically continue.",
|
|
request: request,
|
|
response: response,
|
|
requestState: requestState
|
|
}));
|
|
|
|
default:
|
|
return Promise.reject(new bhttpErrors.RedirectError("Encountered a 301 redirect, but not sure how to proceed for the ".concat(request.options.method.toUpperCase(), " method.")));
|
|
}
|
|
|
|
case 302:
|
|
case 303:
|
|
return redirectGet(request, response, requestState);
|
|
|
|
case 307:
|
|
if (request.containsStreams && !["get", "head"].includes(request.options.method)) {
|
|
return Promise.reject(new bhttpErrors.RedirectError({
|
|
message: "Encountered a 307 redirect for POST, PUT or DELETE, but your payload contained (single-use) streams. We therefore can't automatically follow the redirect.",
|
|
request: request,
|
|
response: response,
|
|
requestState: requestState
|
|
}));
|
|
} else {
|
|
return redirectUnchanged(request, response, requestState);
|
|
}
|
|
|
|
}
|
|
} else if (request.responseOptions.discardResponse) {
|
|
response.pipe(devNull()); // Drain the response stream
|
|
|
|
return Promise.resolve(response);
|
|
} else {
|
|
var totalBytes = response.headers["content-length"];
|
|
|
|
if (totalBytes != null) {
|
|
// Otherwise `undefined` will turn into `NaN`, and we don't want that.
|
|
totalBytes = parseInt(totalBytes);
|
|
}
|
|
|
|
var completedBytes = 0;
|
|
var progressStream = sink(function (chunk) {
|
|
completedBytes += chunk.length;
|
|
return response.emit("progress", completedBytes, totalBytes);
|
|
});
|
|
|
|
if (request.responseOptions.onDownloadProgress != null) {
|
|
response.on("progress", function (completedBytes, totalBytes) {
|
|
return request.responseOptions.onDownloadProgress(completedBytes, totalBytes, response);
|
|
});
|
|
}
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
// This is a very, very dirty hack - however, using .pipe followed by .pause breaks in Node.js v0.10.35 with "Cannot switch to old mode now". Our solution is to monkeypatch the `on` and `resume` methods to attach the progress event handler as soon as something else is attached to the response stream (or when it is drained). This way, a user can also pipe the response in a later tick, without the stream draining prematurely.
|
|
var _resume = response.resume.bind(response);
|
|
|
|
var _on = response.on.bind(response);
|
|
|
|
var _progressStreamAttached = false;
|
|
|
|
var attachProgressStream = function attachProgressStream() {
|
|
// To keep this from sending us into an infinite loop.
|
|
if (!_progressStreamAttached) {
|
|
debugResponse("attaching progress stream");
|
|
_progressStreamAttached = true;
|
|
return response.pipe(progressStream);
|
|
}
|
|
};
|
|
|
|
response.on = function (eventName, handler) {
|
|
debugResponse("'on' called, ".concat(eventName));
|
|
|
|
if (eventName === "data" || eventName === "readable") {
|
|
attachProgressStream();
|
|
}
|
|
|
|
return _on(eventName, handler);
|
|
};
|
|
|
|
response.resume = function () {
|
|
attachProgressStream();
|
|
return _resume();
|
|
}; // Continue with the regular response processing.
|
|
|
|
|
|
if (request.responseOptions.stream) {
|
|
return resolve(response);
|
|
} else {
|
|
response.on("error", function (err) {
|
|
return reject(err);
|
|
});
|
|
return response.pipe(concatStream(function (body) {
|
|
// FIXME: Separate module for header parsing?
|
|
if (request.responseOptions.decodeJSON || (response.headers["content-type"] != null ? response.headers["content-type"] : "").split(";")[0] === "application/json" && !request.responseOptions.noDecode) {
|
|
try {
|
|
response.body = JSON.parse(body);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
} else {
|
|
response.body = body;
|
|
}
|
|
|
|
return resolve(response);
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
}).then(function (response) {
|
|
return Promise.resolve([request, response, requestState]);
|
|
});
|
|
}; // Some wrappers
|
|
|
|
|
|
var doPayloadRequest = function doPayloadRequest(url, data, options, callback) {
|
|
// A wrapper that processes the second argument to .post, .put, .patch shorthand API methods.
|
|
// FIXME: Treat a {} for data as a null? Otherwise {} combined with inputBuffer/inputStream will error.
|
|
if (isStream(data)) {
|
|
options.inputStream = data;
|
|
} else if (ofTypes(data, [Buffer]) || typeof data === "string") {
|
|
options.inputBuffer = data;
|
|
} else {
|
|
options.formFields = data;
|
|
}
|
|
|
|
return this.request(url, options, callback);
|
|
};
|
|
|
|
var redirectGet = function redirectGet(request, response, requestState) {
|
|
debugResponse("following forced-GET redirect to %s", response.headers["location"]);
|
|
return Promise.try(function () {
|
|
var options = shallowClone(requestState.originalOptions);
|
|
options.method = "get";
|
|
|
|
for (var _i2 = 0, _arr2 = ["inputBuffer", "inputStream", "files", "formFields"]; _i2 < _arr2.length; _i2++) {
|
|
var key = _arr2[_i2];
|
|
delete options[key];
|
|
}
|
|
|
|
return doRedirect(request, response, requestState, options);
|
|
});
|
|
};
|
|
|
|
var redirectUnchanged = function redirectUnchanged(request, response, requestState) {
|
|
debugResponse("following same-method redirect to %s", response.headers["location"]);
|
|
return Promise.try(function () {
|
|
var options = shallowClone(requestState.originalOptions);
|
|
return doRedirect(request, response, requestState, options);
|
|
});
|
|
};
|
|
|
|
var doRedirect = function doRedirect(request, response, requestState, newOptions) {
|
|
return Promise.try(function () {
|
|
if (!request.responseOptions.keepRedirectResponses) {
|
|
response.pipe(devNull()); // Let the response stream drain out...
|
|
}
|
|
|
|
requestState.redirectHistory.push(response);
|
|
return bhttpAPI._doRequest(urlUtil.resolve(request.url, response.headers["location"]), newOptions, requestState);
|
|
});
|
|
};
|
|
|
|
var createCookieJar = function createCookieJar(jar) {
|
|
// Creates a cookie jar wrapper with a simplified API.
|
|
return {
|
|
set: function set(cookie, url) {
|
|
var _this = this;
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
return _this.jar.setCookie(cookie, url, function (err, cookie) {
|
|
if (err) {
|
|
return reject(err);
|
|
} else {
|
|
return resolve(cookie);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
get: function get(url) {
|
|
var _this2 = this;
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
return _this2.jar.getCookieString(url, function (err, cookies) {
|
|
if (err) {
|
|
return reject(err);
|
|
} else {
|
|
return resolve(cookies);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
jar: jar
|
|
};
|
|
}; // The exposed API
|
|
|
|
|
|
var bhttpAPI = {
|
|
head: function head(url, options, callback) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
options.method = "head";
|
|
return this.request(url, options, callback);
|
|
},
|
|
get: function get(url, options, callback) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
options.method = "get";
|
|
return this.request(url, options, callback);
|
|
},
|
|
post: function post(url, data, options, callback) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
options.method = "post";
|
|
return doPayloadRequest.bind(this)(url, data, options, callback);
|
|
},
|
|
put: function put(url, data, options, callback) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
options.method = "put";
|
|
return doPayloadRequest.bind(this)(url, data, options, callback);
|
|
},
|
|
patch: function patch(url, data, options, callback) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
options.method = "patch";
|
|
return doPayloadRequest.bind(this)(url, data, options, callback);
|
|
},
|
|
delete: function _delete(url, options, callback) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
options.method = "delete";
|
|
return this.request(url, options, callback);
|
|
},
|
|
request: function request(url, options, callback) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
return this._doRequest(url, options).nodeify(callback);
|
|
},
|
|
_doRequest: function _doRequest(url, options, requestState) {
|
|
var _this3 = this;
|
|
|
|
// This is split from the `request` method, so that the user doesn't have to pass in `undefined` for the `requestState` when they want to specify a `callback`.
|
|
return Promise.try(function () {
|
|
var request = {
|
|
url: url,
|
|
options: shallowClone(options)
|
|
};
|
|
var response = null;
|
|
|
|
if (requestState == null) {
|
|
requestState = {
|
|
originalOptions: shallowClone(options),
|
|
redirectHistory: []
|
|
};
|
|
}
|
|
|
|
if (requestState.sessionOptions == null) {
|
|
requestState.sessionOptions = _this3._sessionOptions != null ? _this3._sessionOptions : {};
|
|
}
|
|
|
|
return prepareRequest(request, response, requestState);
|
|
}).spread(function (request, response, requestState) {
|
|
if (request.responseOptions.justPrepare) {
|
|
return Promise.resolve([request, response, requestState]);
|
|
} else {
|
|
return Promise.try(function () {
|
|
return bhttpAPI.executeRequest(request, response, requestState);
|
|
}).spread(function (request, response, _requestState) {
|
|
// The user likely only wants the response.
|
|
return Promise.resolve(response);
|
|
});
|
|
}
|
|
});
|
|
},
|
|
executeRequest: function executeRequest(request, response, requestState) {
|
|
// Executes a pre-configured request.
|
|
return Promise.try(function () {
|
|
return makeRequest(request, response, requestState);
|
|
}).spread(function (request, response, requestState) {
|
|
return processResponse(request, response, requestState);
|
|
});
|
|
},
|
|
session: function session(options) {
|
|
if (options == null) {
|
|
options = {};
|
|
}
|
|
|
|
options = shallowClone(options);
|
|
var session = {};
|
|
|
|
for (var key in this) {
|
|
var value = this[key];
|
|
|
|
if (value instanceof Function) {
|
|
value = value.bind(session);
|
|
}
|
|
|
|
session[key] = value;
|
|
}
|
|
|
|
if (options.cookieJar == null) {
|
|
options.cookieJar = createCookieJar(new toughCookie.CookieJar());
|
|
} else if (options.cookieJar === false) {
|
|
delete options.cookieJar;
|
|
} else {
|
|
// Assume we've gotten a cookie jar.
|
|
options.cookieJar = createCookieJar(options.cookieJar);
|
|
}
|
|
|
|
session._sessionOptions = options;
|
|
return session;
|
|
},
|
|
wrapStream: function wrapStream(stream, options) {
|
|
// This is a method for wrapping a stream in an object that also contains metadata.
|
|
return {
|
|
_bhttpStreamWrapper: true,
|
|
stream: stream,
|
|
options: options
|
|
};
|
|
}
|
|
};
|
|
extend(bhttpAPI, bhttpErrors);
|
|
module.exports = bhttpAPI; // That's all, folks!
|