587 lines
17 KiB
JavaScript
587 lines
17 KiB
JavaScript
|
|
||
|
const { URL } = require('url');
|
||
|
const {EventEmitter} = require('events');
|
||
|
const _extend = require('util')._extend;
|
||
|
const {DebounceTimers , assertIsObject , ERR_INVALID_ARG_TYPE} = require('./utils');
|
||
|
const {initializeTLSOptions } = require('./request-options');
|
||
|
const http = require('http');
|
||
|
const https = require('https');
|
||
|
const {Stream} = require('stream');
|
||
|
function addFunctions(container , obj){
|
||
|
const proto = obj.prototype;
|
||
|
Object.keys(proto).forEach((name)=>{
|
||
|
if (container.indexOf(name)!=-1)
|
||
|
return;
|
||
|
if (name.indexOf('_')!=0 && typeof proto[name] == 'function'){
|
||
|
container.push(name);
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
const STUBBED_METHODS_NAME = [
|
||
|
]
|
||
|
//We need to proxy all v1 function
|
||
|
addFunctions(STUBBED_METHODS_NAME, http.ClientRequest);
|
||
|
addFunctions(STUBBED_METHODS_NAME, http.OutgoingMessage);
|
||
|
addFunctions(STUBBED_METHODS_NAME, EventEmitter);
|
||
|
addFunctions(STUBBED_METHODS_NAME, Stream);
|
||
|
|
||
|
const PROPERTIES_TO_PROXY = [
|
||
|
'httpVersionMajor',
|
||
|
'httpVersionMinor',
|
||
|
'httpVersion',
|
||
|
];
|
||
|
const HEADERS_TO_REMOVE = ['host' , 'connection']
|
||
|
|
||
|
const $stubs = Symbol('stubs');
|
||
|
|
||
|
function ClientRequest(){
|
||
|
this.http2Mimic = true;
|
||
|
this[$stubs] = [];
|
||
|
for (var i=0;i<STUBBED_METHODS_NAME.length;i++){
|
||
|
let name = STUBBED_METHODS_NAME[i];
|
||
|
if (!ClientRequest.prototype[name]){
|
||
|
this[name] = function method(){
|
||
|
return this.genericStubber(name , arguments);
|
||
|
}.bind(this);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var requestOptions , cb,url , args;
|
||
|
const isInternal = arguments[0] instanceof RequestInternalEnforce;
|
||
|
var isInternalMethod,isInternalProtocol;
|
||
|
if (isInternal){
|
||
|
const enforceOptions = arguments[0];
|
||
|
if ( enforceOptions.method)
|
||
|
isInternalMethod = enforceOptions.method;
|
||
|
if ( enforceOptions.protocol)
|
||
|
isInternalProtocol = enforceOptions.protocol;
|
||
|
}
|
||
|
if (isInternal){
|
||
|
args = arguments[0].args;
|
||
|
}
|
||
|
else{
|
||
|
args = arguments;
|
||
|
}
|
||
|
if (args[2] != undefined){
|
||
|
url = args[0];
|
||
|
requestOptions = args[1];
|
||
|
cb = args[2];
|
||
|
}
|
||
|
else if(args[1] == undefined){
|
||
|
requestOptions = args[0];
|
||
|
}
|
||
|
else{
|
||
|
requestOptions = args[0];
|
||
|
cb = args[1];
|
||
|
}
|
||
|
cb = cb || function dummy(){};
|
||
|
|
||
|
if (typeof requestOptions === 'string') {
|
||
|
requestOptions = urlToOptions(new URL(requestOptions));
|
||
|
if (!requestOptions.hostname) {
|
||
|
throw new Error('Unable to determine the domain name');
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
if (url){
|
||
|
requestOptions = _extend(urlToOptions(new URL(url)), requestOptions);
|
||
|
}
|
||
|
else{
|
||
|
requestOptions = _extend({}, requestOptions);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (isInternalProtocol!=isInternalProtocol){
|
||
|
requestOptions.protocol = isInternalProtocol;
|
||
|
}
|
||
|
|
||
|
if (requestOptions.protocol == 'https:' && !requestOptions.port && requestOptions.port !=0)
|
||
|
requestOptions.port = 443;
|
||
|
|
||
|
if (!requestOptions.port && requestOptions.port !=0)
|
||
|
requestOptions.port = 80;
|
||
|
|
||
|
if (isInternalMethod){
|
||
|
requestOptions.method = isInternalMethod;
|
||
|
}
|
||
|
else if (!requestOptions.method )
|
||
|
requestOptions.method = 'GET';
|
||
|
|
||
|
requestOptions.method = requestOptions.method.toUpperCase();
|
||
|
|
||
|
const requestManager = requestOptions.requestManager || this.getGlobalManager(requestOptions);
|
||
|
requestManager.handleClientRequest(this , requestOptions , cb);
|
||
|
}
|
||
|
ClientRequest.prototype = {
|
||
|
getGlobalManager(options){
|
||
|
if (options.agent)
|
||
|
return (options.agent.protocol == 'https:' ? HttpsRequest.globalManager : HttpRequest.globalManager);
|
||
|
else
|
||
|
return HttpRequestManager.globalManager;
|
||
|
},
|
||
|
genericStubber(method , args){
|
||
|
if (this[$stubs ]){
|
||
|
this[$stubs].push([method,args]);
|
||
|
return true;
|
||
|
}
|
||
|
else
|
||
|
return this[method](...arguments);
|
||
|
},
|
||
|
on(eventName , cb){
|
||
|
if (eventName == 'response'){
|
||
|
if (!cb.http2Safe){
|
||
|
eventName = 'http1.response';
|
||
|
arguments[0] = eventName;
|
||
|
}
|
||
|
}
|
||
|
if (this._on){
|
||
|
this._on(...arguments);
|
||
|
}
|
||
|
else
|
||
|
this.genericStubber('on' , arguments);
|
||
|
|
||
|
},
|
||
|
once(eventName , cb){
|
||
|
if (eventName == 'response'){
|
||
|
if (!cb.http2Safe){
|
||
|
eventName = 'http1.response';
|
||
|
}
|
||
|
}
|
||
|
if (this._once){
|
||
|
this._once(...arguments);
|
||
|
}
|
||
|
else
|
||
|
this.genericStubber('once' , arguments);
|
||
|
|
||
|
},
|
||
|
emitError(error) {
|
||
|
if (this[$stubs]){
|
||
|
this[$stubs].forEach(([method, args]) => {
|
||
|
if ((method === 'on' || method === 'once') && args[0] === 'error') {
|
||
|
args[1](error);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
else
|
||
|
return this.emit('error', error);
|
||
|
},
|
||
|
take(stream){
|
||
|
//We forward all functions to the stream
|
||
|
for (var i=0;i<STUBBED_METHODS_NAME.length;i++){
|
||
|
let name = STUBBED_METHODS_NAME[i];
|
||
|
if (stream[name]){
|
||
|
this[name] = stream[name].bind(stream);
|
||
|
}
|
||
|
// else{
|
||
|
// throw new Error(`for stub ${name} no original method found`)
|
||
|
// }
|
||
|
}
|
||
|
this._on = stream.on.bind(stream);
|
||
|
this._once = stream.once.bind(stream);
|
||
|
this.proxyProps(stream);
|
||
|
//This should come later in case of user exception
|
||
|
//We trigger the all the stubs that were generted before
|
||
|
for (let i = 0; i<this[$stubs ].length;i++){
|
||
|
var stub = this[$stubs ][i];
|
||
|
stream[stub[0]](...stub[1]);
|
||
|
}
|
||
|
this[$stubs] = null;
|
||
|
},
|
||
|
proxyProps(http2Stream){
|
||
|
function getter(){
|
||
|
return http2Stream[this];
|
||
|
}
|
||
|
function setter(value){
|
||
|
http2Stream[this] = value;
|
||
|
}
|
||
|
const notToProxy = ['on' , '_on','_once' , 'once','http2Mimic'].concat(STUBBED_METHODS_NAME);
|
||
|
const keys = Object.keys(this);
|
||
|
const keysToProxy = [].concat(PROPERTIES_TO_PROXY);
|
||
|
keys.forEach(function whichProxyKeys(key){
|
||
|
if (notToProxy.indexOf(key) == -1 && keysToProxy.indexOf(key)==-1){
|
||
|
keysToProxy.push(key)
|
||
|
}
|
||
|
});
|
||
|
const properties = Object.getOwnPropertyDescriptors(http2Stream);
|
||
|
for (var i=0;i<keysToProxy.length;i++){
|
||
|
let name = keysToProxy[i];
|
||
|
const propConfig = properties[name];
|
||
|
let shouldCopyValue;
|
||
|
if (!propConfig)
|
||
|
shouldCopyValue = true;
|
||
|
if (propConfig && (propConfig.writable || propConfig))
|
||
|
shouldCopyValue = true;
|
||
|
|
||
|
if (shouldCopyValue)
|
||
|
http2Stream[name] = this[name];
|
||
|
|
||
|
Object.defineProperty(this , name , {
|
||
|
get : getter.bind(name),
|
||
|
set : setter.bind(name),
|
||
|
})
|
||
|
}
|
||
|
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
class HttpRequestManager extends EventEmitter{
|
||
|
constructor(options){
|
||
|
super();
|
||
|
this.httpsAgent = https.globalAgent;
|
||
|
this.httpAgent = http.globalAgent;
|
||
|
this.init(options);
|
||
|
}
|
||
|
log(){
|
||
|
}
|
||
|
init(options){
|
||
|
options = options || {};
|
||
|
this.http2Clients = {};
|
||
|
this.cachedHTTP1Result = {};
|
||
|
this.setModules();
|
||
|
this.http2Debouncer = new DebounceTimers(function stopConnection(key){
|
||
|
this.log('stopping ' , key);
|
||
|
var foundConnection = this.http2Clients[key];
|
||
|
if (foundConnection){
|
||
|
this.removeHttp2Client(key , foundConnection)
|
||
|
}
|
||
|
}.bind(this) , 1000);
|
||
|
|
||
|
this.keepH1IdentificationCacheFor = options.keepH1IdentificationCacheFor || 30000;
|
||
|
//the debouncer will accept only values greater then zero
|
||
|
this.http2Debouncer.setDelay(options.keepH2ConnectionFor);
|
||
|
if (options.useHttp){
|
||
|
this.enforceProtocol = 'http:';
|
||
|
}
|
||
|
else if(options.useHttps){
|
||
|
this.enforceProtocol = 'https:';
|
||
|
}
|
||
|
}
|
||
|
setModules() {
|
||
|
this['http'] = require('http');
|
||
|
this['https'] = require('https');
|
||
|
this['tls'] = require('tls');
|
||
|
this['net'] = require('net');
|
||
|
this.http2Support = false;
|
||
|
try{
|
||
|
this['http2'] = require('http2');
|
||
|
this.http2Support = true;
|
||
|
}
|
||
|
catch(err){
|
||
|
//It will automatically fallback to http
|
||
|
}
|
||
|
}
|
||
|
handleClientRequest(clientRequest , requestOptions ,cb){
|
||
|
const requestManager = this;
|
||
|
const clientKey = requestManager.getClientKey(requestOptions);
|
||
|
|
||
|
if (requestManager.hasCachedConnection(clientKey)){
|
||
|
const socket = requestManager.getHttp2Client(clientKey);
|
||
|
const connectionOptions = {
|
||
|
createConnection(){
|
||
|
return socket;
|
||
|
}
|
||
|
};
|
||
|
process.nextTick(function onMakeRequest(){
|
||
|
requestManager.makeRequest( clientRequest ,clientKey , requestOptions, cb , connectionOptions);
|
||
|
}.bind(requestManager))
|
||
|
}
|
||
|
else
|
||
|
requestManager.holdConnectionToIdentification(clientKey , requestOptions , function onIdentification(error , connectionOptions){
|
||
|
if (error) {
|
||
|
clientRequest.emitError(error);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
requestManager.makeRequest(clientRequest , clientKey , requestOptions, cb , connectionOptions);
|
||
|
}.bind(requestManager));
|
||
|
}
|
||
|
getClientKey(url){
|
||
|
return `${url.protocol || this.enforceProtocol}${url.servername || url.host || url.hostname}:${url.port}`;
|
||
|
}
|
||
|
getHttp2Client(clientKey){
|
||
|
return this.http2Clients[clientKey];
|
||
|
}
|
||
|
setHttp2Client(clientKey , client){
|
||
|
const httpManager = this;
|
||
|
const prevClient = httpManager.http2Clients[clientKey];
|
||
|
if (prevClient)
|
||
|
httpManager.removeHttp2Client(clientKey , prevClient);
|
||
|
httpManager.http2Clients[clientKey] = client;
|
||
|
|
||
|
function closeClient(){
|
||
|
httpManager.removeHttp2Client(clientKey , client);
|
||
|
}
|
||
|
client.on('close' , closeClient);
|
||
|
client.on('goaway' , closeClient);
|
||
|
client.on('error' , closeClient);
|
||
|
client.on('frameError' , closeClient);
|
||
|
client.on('timeout' , closeClient);
|
||
|
|
||
|
}
|
||
|
removeHttp2Client(clientKey , client){
|
||
|
try{
|
||
|
delete this.http2Clients[clientKey];
|
||
|
if (!client.closed){
|
||
|
client.close();
|
||
|
}
|
||
|
}
|
||
|
catch(err){
|
||
|
|
||
|
}
|
||
|
client.removeAllListeners('close');
|
||
|
client.removeAllListeners('error');
|
||
|
client.removeAllListeners('frameError');
|
||
|
client.removeAllListeners('timeout');
|
||
|
}
|
||
|
request(url, options, cb){
|
||
|
var args = new RequestInternalEnforce(arguments);
|
||
|
if (this.enforceProtocol){
|
||
|
args.protocol = this.enforceProtocol;
|
||
|
}
|
||
|
return new ClientRequest(args)
|
||
|
}
|
||
|
get(){
|
||
|
var args = new RequestInternalEnforce(arguments);
|
||
|
args.method = 'GET';
|
||
|
var request = this.request(args);
|
||
|
request.end();
|
||
|
return request;
|
||
|
}
|
||
|
hasCachedConnection(clientKey){
|
||
|
const http2Client = this.getHttp2Client(clientKey);
|
||
|
if (http2Client){
|
||
|
return true;
|
||
|
}
|
||
|
return this.cachedHTTP1Result[clientKey] + this.keepH1IdentificationCacheFor < Date.now();
|
||
|
}
|
||
|
makeRequest(inStream , clientKey , requestOptions ,cb , connectionOptions){
|
||
|
const http2Client = this.getHttp2Client(clientKey);
|
||
|
if (http2Client){
|
||
|
return this.makeHttp2Request(clientKey , inStream , http2Client ,Object.assign(connectionOptions || {}, requestOptions), cb);
|
||
|
}
|
||
|
//It's http1.1 let Node.JS core manage it
|
||
|
if (!requestOptions.agent){
|
||
|
if (requestOptions.protocol == 'https:')
|
||
|
requestOptions.agent = this.httpsAgent;
|
||
|
else
|
||
|
requestOptions.agent = this.httpAgent;
|
||
|
}
|
||
|
return this.makeHttpRequest(clientKey , inStream , requestOptions ,cb , connectionOptions);
|
||
|
}
|
||
|
holdConnectionToIdentification(clientKey , requestOptions , cb){
|
||
|
const topic = `identify-${clientKey}`;
|
||
|
//If there are any pending identification process let's wait for one to finish
|
||
|
if (this._events[topic])
|
||
|
this.once(topic , cb); //There is.. let's wait
|
||
|
else{
|
||
|
//We will need to start identification
|
||
|
this.once(topic , function letKnowThereIsAnEvent(){}); //There is.. let's wait
|
||
|
const socket = this.identifyConnection(requestOptions , function onIdentify(error , type){
|
||
|
if (error) {
|
||
|
return cb(error);
|
||
|
}
|
||
|
|
||
|
var options = {
|
||
|
createConnection(){
|
||
|
return socket;
|
||
|
}
|
||
|
}
|
||
|
if ( type == 'h2' && this.http2Support){
|
||
|
var http2Client = this.http2.connect(requestOptions ,options);
|
||
|
this.setHttp2Client(clientKey , http2Client);
|
||
|
}
|
||
|
else{
|
||
|
//This is http1.1
|
||
|
//Cache last result time
|
||
|
this.cachedHTTP1Result[clientKey] = Date.now();
|
||
|
//Continue let core handle http1.1
|
||
|
}
|
||
|
cb(null, options);
|
||
|
this.emit(topic , options);
|
||
|
}.bind(this))
|
||
|
}
|
||
|
|
||
|
}
|
||
|
makeHttpRequest(clientKey , inStream , options , cb , connectionOptions){
|
||
|
if (options instanceof URL)
|
||
|
options = urlToOptions(options);
|
||
|
|
||
|
const h1op = _extend({} , options);
|
||
|
if (connectionOptions)
|
||
|
h1op.createConnection = connectionOptions.createConnection;
|
||
|
|
||
|
const requestModule = h1op.protocol == 'https:' ? this.https : this.http;
|
||
|
const req = requestModule.request(h1op ,cb);
|
||
|
|
||
|
inStream.take(req);
|
||
|
inStream._on('response' , function onHttp1Response(v){this.emit('http1.response' , v)})
|
||
|
|
||
|
}
|
||
|
makeHttp2Request(clientKey , inStream , http2Client , requestOptions , cb){
|
||
|
var http2Debouncer = this.http2Debouncer;
|
||
|
http2Debouncer.pause(clientKey);
|
||
|
var headers = _extend({} , requestOptions.headers || {});
|
||
|
if (requestOptions.method)
|
||
|
headers[':method'] = requestOptions.method;
|
||
|
if (requestOptions.path)
|
||
|
headers[':path'] = requestOptions.path;
|
||
|
|
||
|
Object.keys(headers).forEach((key)=>{
|
||
|
if (HEADERS_TO_REMOVE.indexOf( (key+'').toLowerCase() ) !=-1){
|
||
|
delete headers[key];
|
||
|
}
|
||
|
})
|
||
|
requestOptions.headers = headers;
|
||
|
var req = http2Client.request(
|
||
|
headers
|
||
|
);
|
||
|
inStream.emit('socket' , requestOptions.createConnection());
|
||
|
|
||
|
let maxContentLength;
|
||
|
let currentContent = 0;
|
||
|
|
||
|
req.on('data' , function onData(data){
|
||
|
currentContent+=data.length;
|
||
|
if (currentContent>= maxContentLength)
|
||
|
http2Debouncer.unpauseAndTime(clientKey);
|
||
|
})
|
||
|
inStream.take(req);
|
||
|
function onResponse(headers){
|
||
|
maxContentLength = parseInt(headers['content-length']);
|
||
|
if (maxContentLength < 0 )
|
||
|
this.http2Debouncer.unpauseAndTime(clientKey);
|
||
|
|
||
|
HttpRequestManager.httpCompatibleResponse(req , requestOptions , headers);
|
||
|
inStream.emit('http1.response' , req);
|
||
|
if (cb)
|
||
|
cb(req);
|
||
|
}
|
||
|
onResponse.http2Safe = true;
|
||
|
req.once('response' , onResponse.bind(this));
|
||
|
}
|
||
|
static httpCompatibleResponse(res , requestOptions , headers){
|
||
|
res.httpVersion = '2.0';
|
||
|
res.rawHeaders = headers;
|
||
|
res.headers = headers;
|
||
|
res.statusCode = headers[':status'];
|
||
|
delete headers[':status'];
|
||
|
}
|
||
|
identifyConnection(requestOptions , cb){
|
||
|
var socket = this.connect( requestOptions, { allowHTTP1: true }, function onConnect(){
|
||
|
socket.removeListener('error', cb);
|
||
|
|
||
|
if (socket.alpnProtocol == 'h2'){
|
||
|
cb(null, 'h2')
|
||
|
}
|
||
|
else{
|
||
|
//close http1.1 connection is it cannot be reused
|
||
|
socket.end();
|
||
|
cb(null, 'h1')
|
||
|
}
|
||
|
});
|
||
|
socket.on('error', cb);
|
||
|
return socket;
|
||
|
}
|
||
|
connect(authority, options, listener) {
|
||
|
if (typeof options === 'function') {
|
||
|
listener = options;
|
||
|
options = undefined;
|
||
|
}
|
||
|
|
||
|
assertIsObject(options, 'options');
|
||
|
options = Object.assign({}, options);
|
||
|
|
||
|
if (typeof authority === 'string')
|
||
|
authority = new URL(authority);
|
||
|
|
||
|
assertIsObject(authority, 'authority', ['string', 'Object', 'URL']);
|
||
|
|
||
|
var protocol = authority.protocol || options.protocol || (this.enforceProtocol != 'detect' ? this.enforceProtocol : null) || 'http:';
|
||
|
var port = '' + (authority.port !== '' ?
|
||
|
authority.port : (authority.protocol === 'http:' ? 80 : 443));
|
||
|
var host = authority.hostname || authority.host || 'localhost';
|
||
|
|
||
|
var socket;
|
||
|
if (typeof options.createConnection === 'function') {
|
||
|
socket = options.createConnection(authority, options);
|
||
|
} else {
|
||
|
switch (protocol) {
|
||
|
case 'http:':
|
||
|
socket = this.net.connect(port, host , listener);
|
||
|
break;
|
||
|
case 'https:':
|
||
|
socket = this.tls.connect(port, host, initializeTLSOptions.call(this , options, host) , listener);
|
||
|
break;
|
||
|
default:
|
||
|
throw new Error('Not supprted' + protocol);
|
||
|
}
|
||
|
}
|
||
|
return socket;
|
||
|
}
|
||
|
}
|
||
|
function urlToOptions(url) {
|
||
|
var options = {
|
||
|
protocol: url.protocol,
|
||
|
hostname: url.hostname,
|
||
|
hash: url.hash,
|
||
|
search: url.search,
|
||
|
pathname: url.pathname,
|
||
|
path: `${url.pathname}${url.search}`,
|
||
|
href: url.href
|
||
|
};
|
||
|
if (url.port !== '') {
|
||
|
options.port = Number(url.port);
|
||
|
}
|
||
|
if (url.username || url.password) {
|
||
|
options.auth = `${url.username}:${url.password}`;
|
||
|
}
|
||
|
return options;
|
||
|
}
|
||
|
class RequestInternalEnforce{
|
||
|
constructor(args){
|
||
|
if (args[0] instanceof RequestInternalEnforce){
|
||
|
return args[0];
|
||
|
}
|
||
|
this.args = args;
|
||
|
this.method = null;
|
||
|
this.protocol = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class HttpsRequest extends HttpRequestManager{
|
||
|
constructor(){
|
||
|
super(...arguments);
|
||
|
this.Agent = https.Agent;
|
||
|
this.globalAgent = https.globalAgent;
|
||
|
this.enforceProtocol = 'https:';
|
||
|
}
|
||
|
}
|
||
|
const httpsRequestSinglton = new HttpsRequest;
|
||
|
HttpsRequest.globalManager = httpsRequestSinglton;
|
||
|
HttpsRequest.Manager = HttpsRequest;
|
||
|
|
||
|
class HttpRequest extends HttpRequestManager{
|
||
|
constructor(){
|
||
|
super(...arguments);
|
||
|
this.Agent = http.Agent;
|
||
|
this.globalAgent = http.globalAgent;
|
||
|
this.enforceProtocol = 'http:';
|
||
|
}
|
||
|
}
|
||
|
const httpRequestSinglton = new HttpRequest;
|
||
|
HttpRequest.globalManager = httpRequestSinglton;
|
||
|
HttpRequest.Manager = HttpRequest;
|
||
|
|
||
|
const singeltonHttpManager = new HttpRequestManager();
|
||
|
singeltonHttpManager.enforceProtocol = 'detect';
|
||
|
HttpRequestManager.globalManager = singeltonHttpManager;
|
||
|
|
||
|
module.exports = {
|
||
|
HttpRequest,
|
||
|
HttpsRequest,
|
||
|
HTTP2OutgoingMessage : ClientRequest,
|
||
|
ClientRequest,
|
||
|
HttpRequestManager
|
||
|
}
|