diff options
Diffstat (limited to 'node_modules/http2-wrapper/source')
-rw-r--r-- | node_modules/http2-wrapper/source/agent.js | 670 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/auto.js | 149 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/client-request.js | 445 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/incoming-message.js | 58 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/index.js | 28 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/utils/calculate-server-name.js | 27 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/utils/errors.js | 45 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/utils/is-request-pseudo-header.js | 13 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/utils/proxy-events.js | 7 | ||||
-rw-r--r-- | node_modules/http2-wrapper/source/utils/url-to-options.js | 25 |
10 files changed, 1467 insertions, 0 deletions
diff --git a/node_modules/http2-wrapper/source/agent.js b/node_modules/http2-wrapper/source/agent.js new file mode 100644 index 0000000..b2b1cff --- /dev/null +++ b/node_modules/http2-wrapper/source/agent.js @@ -0,0 +1,670 @@ +'use strict'; +const EventEmitter = require('events'); +const tls = require('tls'); +const http2 = require('http2'); +const QuickLRU = require('quick-lru'); + +const kCurrentStreamsCount = Symbol('currentStreamsCount'); +const kRequest = Symbol('request'); +const kOriginSet = Symbol('cachedOriginSet'); +const kGracefullyClosing = Symbol('gracefullyClosing'); + +const nameKeys = [ + // `http2.connect()` options + 'maxDeflateDynamicTableSize', + 'maxSessionMemory', + 'maxHeaderListPairs', + 'maxOutstandingPings', + 'maxReservedRemoteStreams', + 'maxSendHeaderBlockLength', + 'paddingStrategy', + + // `tls.connect()` options + 'localAddress', + 'path', + 'rejectUnauthorized', + 'minDHSize', + + // `tls.createSecureContext()` options + 'ca', + 'cert', + 'clientCertEngine', + 'ciphers', + 'key', + 'pfx', + 'servername', + 'minVersion', + 'maxVersion', + 'secureProtocol', + 'crl', + 'honorCipherOrder', + 'ecdhCurve', + 'dhparam', + 'secureOptions', + 'sessionIdContext' +]; + +const getSortedIndex = (array, value, compare) => { + let low = 0; + let high = array.length; + + while (low < high) { + const mid = (low + high) >>> 1; + + /* istanbul ignore next */ + if (compare(array[mid], value)) { + // This never gets called because we use descending sort. Better to have this anyway. + low = mid + 1; + } else { + high = mid; + } + } + + return low; +}; + +const compareSessions = (a, b) => { + return a.remoteSettings.maxConcurrentStreams > b.remoteSettings.maxConcurrentStreams; +}; + +// See https://tools.ietf.org/html/rfc8336 +const closeCoveredSessions = (where, session) => { + // Clients SHOULD NOT emit new requests on any connection whose Origin + // Set is a proper subset of another connection's Origin Set, and they + // SHOULD close it once all outstanding requests are satisfied. + for (const coveredSession of where) { + if ( + // The set is a proper subset when its length is less than the other set. + coveredSession[kOriginSet].length < session[kOriginSet].length && + + // And the other set includes all elements of the subset. + coveredSession[kOriginSet].every(origin => session[kOriginSet].includes(origin)) && + + // Makes sure that the session can handle all requests from the covered session. + coveredSession[kCurrentStreamsCount] + session[kCurrentStreamsCount] <= session.remoteSettings.maxConcurrentStreams + ) { + // This allows pending requests to finish and prevents making new requests. + gracefullyClose(coveredSession); + } + } +}; + +// This is basically inverted `closeCoveredSessions(...)`. +const closeSessionIfCovered = (where, coveredSession) => { + for (const session of where) { + if ( + coveredSession[kOriginSet].length < session[kOriginSet].length && + coveredSession[kOriginSet].every(origin => session[kOriginSet].includes(origin)) && + coveredSession[kCurrentStreamsCount] + session[kCurrentStreamsCount] <= session.remoteSettings.maxConcurrentStreams + ) { + gracefullyClose(coveredSession); + } + } +}; + +const getSessions = ({agent, isFree}) => { + const result = {}; + + // eslint-disable-next-line guard-for-in + for (const normalizedOptions in agent.sessions) { + const sessions = agent.sessions[normalizedOptions]; + + const filtered = sessions.filter(session => { + const result = session[Agent.kCurrentStreamsCount] < session.remoteSettings.maxConcurrentStreams; + + return isFree ? result : !result; + }); + + if (filtered.length !== 0) { + result[normalizedOptions] = filtered; + } + } + + return result; +}; + +const gracefullyClose = session => { + session[kGracefullyClosing] = true; + + if (session[kCurrentStreamsCount] === 0) { + session.close(); + } +}; + +class Agent extends EventEmitter { + constructor({timeout = 60000, maxSessions = Infinity, maxFreeSessions = 10, maxCachedTlsSessions = 100} = {}) { + super(); + + // A session is considered busy when its current streams count + // is equal to or greater than the `maxConcurrentStreams` value. + + // A session is considered free when its current streams count + // is less than the `maxConcurrentStreams` value. + + // SESSIONS[NORMALIZED_OPTIONS] = []; + this.sessions = {}; + + // The queue for creating new sessions. It looks like this: + // QUEUE[NORMALIZED_OPTIONS][NORMALIZED_ORIGIN] = ENTRY_FUNCTION + // + // The entry function has `listeners`, `completed` and `destroyed` properties. + // `listeners` is an array of objects containing `resolve` and `reject` functions. + // `completed` is a boolean. It's set to true after ENTRY_FUNCTION is executed. + // `destroyed` is a boolean. If it's set to true, the session will be destroyed if hasn't connected yet. + this.queue = {}; + + // Each session will use this timeout value. + this.timeout = timeout; + + // Max sessions in total + this.maxSessions = maxSessions; + + // Max free sessions in total + // TODO: decreasing `maxFreeSessions` should close some sessions + this.maxFreeSessions = maxFreeSessions; + + this._freeSessionsCount = 0; + this._sessionsCount = 0; + + // We don't support push streams by default. + this.settings = { + enablePush: false + }; + + // Reusing TLS sessions increases performance. + this.tlsSessionCache = new QuickLRU({maxSize: maxCachedTlsSessions}); + } + + static normalizeOrigin(url, servername) { + if (typeof url === 'string') { + url = new URL(url); + } + + if (servername && url.hostname !== servername) { + url.hostname = servername; + } + + return url.origin; + } + + normalizeOptions(options) { + let normalized = ''; + + if (options) { + for (const key of nameKeys) { + if (options[key]) { + normalized += `:${options[key]}`; + } + } + } + + return normalized; + } + + _tryToCreateNewSession(normalizedOptions, normalizedOrigin) { + if (!(normalizedOptions in this.queue) || !(normalizedOrigin in this.queue[normalizedOptions])) { + return; + } + + const item = this.queue[normalizedOptions][normalizedOrigin]; + + // The entry function can be run only once. + // BUG: The session may be never created when: + // - the first condition is false AND + // - this function is never called with the same arguments in the future. + if (this._sessionsCount < this.maxSessions && !item.completed) { + item.completed = true; + + item(); + } + } + + getSession(origin, options, listeners) { + return new Promise((resolve, reject) => { + if (Array.isArray(listeners)) { + listeners = [...listeners]; + + // Resolve the current promise ASAP, we're just moving the listeners. + // They will be executed at a different time. + resolve(); + } else { + listeners = [{resolve, reject}]; + } + + const normalizedOptions = this.normalizeOptions(options); + const normalizedOrigin = Agent.normalizeOrigin(origin, options && options.servername); + + if (normalizedOrigin === undefined) { + for (const {reject} of listeners) { + reject(new TypeError('The `origin` argument needs to be a string or an URL object')); + } + + return; + } + + if (normalizedOptions in this.sessions) { + const sessions = this.sessions[normalizedOptions]; + + let maxConcurrentStreams = -1; + let currentStreamsCount = -1; + let optimalSession; + + // We could just do this.sessions[normalizedOptions].find(...) but that isn't optimal. + // Additionally, we are looking for session which has biggest current pending streams count. + for (const session of sessions) { + const sessionMaxConcurrentStreams = session.remoteSettings.maxConcurrentStreams; + + if (sessionMaxConcurrentStreams < maxConcurrentStreams) { + break; + } + + if (session[kOriginSet].includes(normalizedOrigin)) { + const sessionCurrentStreamsCount = session[kCurrentStreamsCount]; + + if ( + sessionCurrentStreamsCount >= sessionMaxConcurrentStreams || + session[kGracefullyClosing] || + // Unfortunately the `close` event isn't called immediately, + // so `session.destroyed` is `true`, but `session.closed` is `false`. + session.destroyed + ) { + continue; + } + + // We only need set this once. + if (!optimalSession) { + maxConcurrentStreams = sessionMaxConcurrentStreams; + } + + // We're looking for the session which has biggest current pending stream count, + // in order to minimalize the amount of active sessions. + if (sessionCurrentStreamsCount > currentStreamsCount) { + optimalSession = session; + currentStreamsCount = sessionCurrentStreamsCount; + } + } + } + + if (optimalSession) { + /* istanbul ignore next: safety check */ + if (listeners.length !== 1) { + for (const {reject} of listeners) { + const error = new Error( + `Expected the length of listeners to be 1, got ${listeners.length}.\n` + + 'Please report this to https://github.com/szmarczak/http2-wrapper/' + ); + + reject(error); + } + + return; + } + + listeners[0].resolve(optimalSession); + return; + } + } + + if (normalizedOptions in this.queue) { + if (normalizedOrigin in this.queue[normalizedOptions]) { + // There's already an item in the queue, just attach ourselves to it. + this.queue[normalizedOptions][normalizedOrigin].listeners.push(...listeners); + + // This shouldn't be executed here. + // See the comment inside _tryToCreateNewSession. + this._tryToCreateNewSession(normalizedOptions, normalizedOrigin); + return; + } + } else { + this.queue[normalizedOptions] = {}; + } + + // The entry must be removed from the queue IMMEDIATELY when: + // 1. the session connects successfully, + // 2. an error occurs. + const removeFromQueue = () => { + // Our entry can be replaced. We cannot remove the new one. + if (normalizedOptions in this.queue && this.queue[normalizedOptions][normalizedOrigin] === entry) { + delete this.queue[normalizedOptions][normalizedOrigin]; + + if (Object.keys(this.queue[normalizedOptions]).length === 0) { + delete this.queue[normalizedOptions]; + } + } + }; + + // The main logic is here + const entry = () => { + const name = `${normalizedOrigin}:${normalizedOptions}`; + let receivedSettings = false; + + try { + const session = http2.connect(origin, { + createConnection: this.createConnection, + settings: this.settings, + session: this.tlsSessionCache.get(name), + ...options + }); + session[kCurrentStreamsCount] = 0; + session[kGracefullyClosing] = false; + + const isFree = () => session[kCurrentStreamsCount] < session.remoteSettings.maxConcurrentStreams; + let wasFree = true; + + session.socket.once('session', tlsSession => { + this.tlsSessionCache.set(name, tlsSession); + }); + + session.once('error', error => { + // Listeners are empty when the session successfully connected. + for (const {reject} of listeners) { + reject(error); + } + + // The connection got broken, purge the cache. + this.tlsSessionCache.delete(name); + }); + + session.setTimeout(this.timeout, () => { + // Terminates all streams owned by this session. + // TODO: Maybe the streams should have a "Session timed out" error? + session.destroy(); + }); + + session.once('close', () => { + if (receivedSettings) { + // 1. If it wasn't free then no need to decrease because + // it has been decreased already in session.request(). + // 2. `stream.once('close')` won't increment the count + // because the session is already closed. + if (wasFree) { + this._freeSessionsCount--; + } + + this._sessionsCount--; + + // This cannot be moved to the stream logic, + // because there may be a session that hadn't made a single request. + const where = this.sessions[normalizedOptions]; + where.splice(where.indexOf(session), 1); + + if (where.length === 0) { + delete this.sessions[normalizedOptions]; + } + } else { + // Broken connection + const error = new Error('Session closed without receiving a SETTINGS frame'); + error.code = 'HTTP2WRAPPER_NOSETTINGS'; + + for (const {reject} of listeners) { + reject(error); + } + + removeFromQueue(); + } + + // There may be another session awaiting. + this._tryToCreateNewSession(normalizedOptions, normalizedOrigin); + }); + + // Iterates over the queue and processes listeners. + const processListeners = () => { + if (!(normalizedOptions in this.queue) || !isFree()) { + return; + } + + for (const origin of session[kOriginSet]) { + if (origin in this.queue[normalizedOptions]) { + const {listeners} = this.queue[normalizedOptions][origin]; + + // Prevents session overloading. + while (listeners.length !== 0 && isFree()) { + // We assume `resolve(...)` calls `request(...)` *directly*, + // otherwise the session will get overloaded. + listeners.shift().resolve(session); + } + + const where = this.queue[normalizedOptions]; + if (where[origin].listeners.length === 0) { + delete where[origin]; + + if (Object.keys(where).length === 0) { + delete this.queue[normalizedOptions]; + break; + } + } + + // We're no longer free, no point in continuing. + if (!isFree()) { + break; + } + } + } + }; + + // The Origin Set cannot shrink. No need to check if it suddenly became covered by another one. + session.on('origin', () => { + session[kOriginSet] = session.originSet; + + if (!isFree()) { + // The session is full. + return; + } + + processListeners(); + + // Close covered sessions (if possible). + closeCoveredSessions(this.sessions[normalizedOptions], session); + }); + + session.once('remoteSettings', () => { + // Fix Node.js bug preventing the process from exiting + session.ref(); + session.unref(); + + this._sessionsCount++; + + // The Agent could have been destroyed already. + if (entry.destroyed) { + const error = new Error('Agent has been destroyed'); + + for (const listener of listeners) { + listener.reject(error); + } + + session.destroy(); + return; + } + + session[kOriginSet] = session.originSet; + + { + const where = this.sessions; + + if (normalizedOptions in where) { + const sessions = where[normalizedOptions]; + sessions.splice(getSortedIndex(sessions, session, compareSessions), 0, session); + } else { + where[normalizedOptions] = [session]; + } + } + + this._freeSessionsCount += 1; + receivedSettings = true; + + this.emit('session', session); + + processListeners(); + removeFromQueue(); + + // TODO: Close last recently used (or least used?) session + if (session[kCurrentStreamsCount] === 0 && this._freeSessionsCount > this.maxFreeSessions) { + session.close(); + } + + // Check if we haven't managed to execute all listeners. + if (listeners.length !== 0) { + // Request for a new session with predefined listeners. + this.getSession(normalizedOrigin, options, listeners); + listeners.length = 0; + } + + // `session.remoteSettings.maxConcurrentStreams` might get increased + session.on('remoteSettings', () => { + processListeners(); + + // In case the Origin Set changes + closeCoveredSessions(this.sessions[normalizedOptions], session); + }); + }); + + // Shim `session.request()` in order to catch all streams + session[kRequest] = session.request; + session.request = (headers, streamOptions) => { + if (session[kGracefullyClosing]) { + throw new Error('The session is gracefully closing. No new streams are allowed.'); + } + + const stream = session[kRequest](headers, streamOptions); + + // The process won't exit until the session is closed or all requests are gone. + session.ref(); + + ++session[kCurrentStreamsCount]; + + if (session[kCurrentStreamsCount] === session.remoteSettings.maxConcurrentStreams) { + this._freeSessionsCount--; + } + + stream.once('close', () => { + wasFree = isFree(); + + --session[kCurrentStreamsCount]; + + if (!session.destroyed && !session.closed) { + closeSessionIfCovered(this.sessions[normalizedOptions], session); + + if (isFree() && !session.closed) { + if (!wasFree) { + this._freeSessionsCount++; + + wasFree = true; + } + + const isEmpty = session[kCurrentStreamsCount] === 0; + + if (isEmpty) { + session.unref(); + } + + if ( + isEmpty && + ( + this._freeSessionsCount > this.maxFreeSessions || + session[kGracefullyClosing] + ) + ) { + session.close(); + } else { + closeCoveredSessions(this.sessions[normalizedOptions], session); + processListeners(); + } + } + } + }); + + return stream; + }; + } catch (error) { + for (const listener of listeners) { + listener.reject(error); + } + + removeFromQueue(); + } + }; + + entry.listeners = listeners; + entry.completed = false; + entry.destroyed = false; + + this.queue[normalizedOptions][normalizedOrigin] = entry; + this._tryToCreateNewSession(normalizedOptions, normalizedOrigin); + }); + } + + request(origin, options, headers, streamOptions) { + return new Promise((resolve, reject) => { + this.getSession(origin, options, [{ + reject, + resolve: session => { + try { + resolve(session.request(headers, streamOptions)); + } catch (error) { + reject(error); + } + } + }]); + }); + } + + createConnection(origin, options) { + return Agent.connect(origin, options); + } + + static connect(origin, options) { + options.ALPNProtocols = ['h2']; + + const port = origin.port || 443; + const host = origin.hostname || origin.host; + + if (typeof options.servername === 'undefined') { + options.servername = host; + } + + return tls.connect(port, host, options); + } + + closeFreeSessions() { + for (const sessions of Object.values(this.sessions)) { + for (const session of sessions) { + if (session[kCurrentStreamsCount] === 0) { + session.close(); + } + } + } + } + + destroy(reason) { + for (const sessions of Object.values(this.sessions)) { + for (const session of sessions) { + session.destroy(reason); + } + } + + for (const entriesOfAuthority of Object.values(this.queue)) { + for (const entry of Object.values(entriesOfAuthority)) { + entry.destroyed = true; + } + } + + // New requests should NOT attach to destroyed sessions + this.queue = {}; + } + + get freeSessions() { + return getSessions({agent: this, isFree: true}); + } + + get busySessions() { + return getSessions({agent: this, isFree: false}); + } +} + +Agent.kCurrentStreamsCount = kCurrentStreamsCount; +Agent.kGracefullyClosing = kGracefullyClosing; + +module.exports = { + Agent, + globalAgent: new Agent() +}; diff --git a/node_modules/http2-wrapper/source/auto.js b/node_modules/http2-wrapper/source/auto.js new file mode 100644 index 0000000..af4e9ba --- /dev/null +++ b/node_modules/http2-wrapper/source/auto.js @@ -0,0 +1,149 @@ +'use strict'; +const http = require('http'); +const https = require('https'); +const resolveALPN = require('resolve-alpn'); +const QuickLRU = require('quick-lru'); +const Http2ClientRequest = require('./client-request'); +const calculateServerName = require('./utils/calculate-server-name'); +const urlToOptions = require('./utils/url-to-options'); + +const cache = new QuickLRU({maxSize: 100}); +const queue = new Map(); + +const installSocket = (agent, socket, options) => { + socket._httpMessage = {shouldKeepAlive: true}; + + const onFree = () => { + agent.emit('free', socket, options); + }; + + socket.on('free', onFree); + + const onClose = () => { + agent.removeSocket(socket, options); + }; + + socket.on('close', onClose); + + const onRemove = () => { + agent.removeSocket(socket, options); + socket.off('close', onClose); + socket.off('free', onFree); + socket.off('agentRemove', onRemove); + }; + + socket.on('agentRemove', onRemove); + + agent.emit('free', socket, options); +}; + +const resolveProtocol = async options => { + const name = `${options.host}:${options.port}:${options.ALPNProtocols.sort()}`; + + if (!cache.has(name)) { + if (queue.has(name)) { + const result = await queue.get(name); + return result.alpnProtocol; + } + + const {path, agent} = options; + options.path = options.socketPath; + + const resultPromise = resolveALPN(options); + queue.set(name, resultPromise); + + try { + const {socket, alpnProtocol} = await resultPromise; + cache.set(name, alpnProtocol); + + options.path = path; + + if (alpnProtocol === 'h2') { + // https://github.com/nodejs/node/issues/33343 + socket.destroy(); + } else { + const {globalAgent} = https; + const defaultCreateConnection = https.Agent.prototype.createConnection; + + if (agent) { + if (agent.createConnection === defaultCreateConnection) { + installSocket(agent, socket, options); + } else { + socket.destroy(); + } + } else if (globalAgent.createConnection === defaultCreateConnection) { + installSocket(globalAgent, socket, options); + } else { + socket.destroy(); + } + } + + queue.delete(name); + + return alpnProtocol; + } catch (error) { + queue.delete(name); + + throw error; + } + } + + return cache.get(name); +}; + +module.exports = async (input, options, callback) => { + if (typeof input === 'string' || input instanceof URL) { + input = urlToOptions(new URL(input)); + } + + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + options = { + ALPNProtocols: ['h2', 'http/1.1'], + ...input, + ...options, + resolveSocket: true + }; + + if (!Array.isArray(options.ALPNProtocols) || options.ALPNProtocols.length === 0) { + throw new Error('The `ALPNProtocols` option must be an Array with at least one entry'); + } + + options.protocol = options.protocol || 'https:'; + const isHttps = options.protocol === 'https:'; + + options.host = options.hostname || options.host || 'localhost'; + options.session = options.tlsSession; + options.servername = options.servername || calculateServerName(options); + options.port = options.port || (isHttps ? 443 : 80); + options._defaultAgent = isHttps ? https.globalAgent : http.globalAgent; + + const agents = options.agent; + + if (agents) { + if (agents.addRequest) { + throw new Error('The `options.agent` object can contain only `http`, `https` or `http2` properties'); + } + + options.agent = agents[isHttps ? 'https' : 'http']; + } + + if (isHttps) { + const protocol = await resolveProtocol(options); + + if (protocol === 'h2') { + if (agents) { + options.agent = agents.http2; + } + + return new Http2ClientRequest(options, callback); + } + } + + return http.request(options, callback); +}; + +module.exports.protocolCache = cache; diff --git a/node_modules/http2-wrapper/source/client-request.js b/node_modules/http2-wrapper/source/client-request.js new file mode 100644 index 0000000..b712967 --- /dev/null +++ b/node_modules/http2-wrapper/source/client-request.js @@ -0,0 +1,445 @@ +'use strict'; +const http2 = require('http2'); +const {Writable} = require('stream'); +const {Agent, globalAgent} = require('./agent'); +const IncomingMessage = require('./incoming-message'); +const urlToOptions = require('./utils/url-to-options'); +const proxyEvents = require('./utils/proxy-events'); +const isRequestPseudoHeader = require('./utils/is-request-pseudo-header'); +const { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_PROTOCOL, + ERR_HTTP_HEADERS_SENT, + ERR_INVALID_HTTP_TOKEN, + ERR_HTTP_INVALID_HEADER_VALUE, + ERR_INVALID_CHAR +} = require('./utils/errors'); + +const { + HTTP2_HEADER_STATUS, + HTTP2_HEADER_METHOD, + HTTP2_HEADER_PATH, + HTTP2_METHOD_CONNECT +} = http2.constants; + +const kHeaders = Symbol('headers'); +const kOrigin = Symbol('origin'); +const kSession = Symbol('session'); +const kOptions = Symbol('options'); +const kFlushedHeaders = Symbol('flushedHeaders'); +const kJobs = Symbol('jobs'); + +const isValidHttpToken = /^[\^`\-\w!#$%&*+.|~]+$/; +const isInvalidHeaderValue = /[^\t\u0020-\u007E\u0080-\u00FF]/; + +class ClientRequest extends Writable { + constructor(input, options, callback) { + super({ + autoDestroy: false + }); + + const hasInput = typeof input === 'string' || input instanceof URL; + if (hasInput) { + input = urlToOptions(input instanceof URL ? input : new URL(input)); + } + + if (typeof options === 'function' || options === undefined) { + // (options, callback) + callback = options; + options = hasInput ? input : {...input}; + } else { + // (input, options, callback) + options = {...input, ...options}; + } + + if (options.h2session) { + this[kSession] = options.h2session; + } else if (options.agent === false) { + this.agent = new Agent({maxFreeSessions: 0}); + } else if (typeof options.agent === 'undefined' || options.agent === null) { + if (typeof options.createConnection === 'function') { + // This is a workaround - we don't have to create the session on our own. + this.agent = new Agent({maxFreeSessions: 0}); + this.agent.createConnection = options.createConnection; + } else { + this.agent = globalAgent; + } + } else if (typeof options.agent.request === 'function') { + this.agent = options.agent; + } else { + throw new ERR_INVALID_ARG_TYPE('options.agent', ['Agent-like Object', 'undefined', 'false'], options.agent); + } + + if (options.protocol && options.protocol !== 'https:') { + throw new ERR_INVALID_PROTOCOL(options.protocol, 'https:'); + } + + const port = options.port || options.defaultPort || (this.agent && this.agent.defaultPort) || 443; + const host = options.hostname || options.host || 'localhost'; + + // Don't enforce the origin via options. It may be changed in an Agent. + delete options.hostname; + delete options.host; + delete options.port; + + const {timeout} = options; + options.timeout = undefined; + + this[kHeaders] = Object.create(null); + this[kJobs] = []; + + this.socket = null; + this.connection = null; + + this.method = options.method || 'GET'; + this.path = options.path; + + this.res = null; + this.aborted = false; + this.reusedSocket = false; + + if (options.headers) { + for (const [header, value] of Object.entries(options.headers)) { + this.setHeader(header, value); + } + } + + if (options.auth && !('authorization' in this[kHeaders])) { + this[kHeaders].authorization = 'Basic ' + Buffer.from(options.auth).toString('base64'); + } + + options.session = options.tlsSession; + options.path = options.socketPath; + + this[kOptions] = options; + + // Clients that generate HTTP/2 requests directly SHOULD use the :authority pseudo-header field instead of the Host header field. + if (port === 443) { + this[kOrigin] = `https://${host}`; + + if (!(':authority' in this[kHeaders])) { + this[kHeaders][':authority'] = host; + } + } else { + this[kOrigin] = `https://${host}:${port}`; + + if (!(':authority' in this[kHeaders])) { + this[kHeaders][':authority'] = `${host}:${port}`; + } + } + + if (timeout) { + this.setTimeout(timeout); + } + + if (callback) { + this.once('response', callback); + } + + this[kFlushedHeaders] = false; + } + + get method() { + return this[kHeaders][HTTP2_HEADER_METHOD]; + } + + set method(value) { + if (value) { + this[kHeaders][HTTP2_HEADER_METHOD] = value.toUpperCase(); + } + } + + get path() { + return this[kHeaders][HTTP2_HEADER_PATH]; + } + + set path(value) { + if (value) { + this[kHeaders][HTTP2_HEADER_PATH] = value; + } + } + + get _mustNotHaveABody() { + return this.method === 'GET' || this.method === 'HEAD' || this.method === 'DELETE'; + } + + _write(chunk, encoding, callback) { + // https://github.com/nodejs/node/blob/654df09ae0c5e17d1b52a900a545f0664d8c7627/lib/internal/http2/util.js#L148-L156 + if (this._mustNotHaveABody) { + callback(new Error('The GET, HEAD and DELETE methods must NOT have a body')); + /* istanbul ignore next: Node.js 12 throws directly */ + return; + } + + this.flushHeaders(); + + const callWrite = () => this._request.write(chunk, encoding, callback); + if (this._request) { + callWrite(); + } else { + this[kJobs].push(callWrite); + } + } + + _final(callback) { + if (this.destroyed) { + return; + } + + this.flushHeaders(); + + const callEnd = () => { + // For GET, HEAD and DELETE + if (this._mustNotHaveABody) { + callback(); + return; + } + + this._request.end(callback); + }; + + if (this._request) { + callEnd(); + } else { + this[kJobs].push(callEnd); + } + } + + abort() { + if (this.res && this.res.complete) { + return; + } + + if (!this.aborted) { + process.nextTick(() => this.emit('abort')); + } + + this.aborted = true; + + this.destroy(); + } + + _destroy(error, callback) { + if (this.res) { + this.res._dump(); + } + + if (this._request) { + this._request.destroy(); + } + + callback(error); + } + + async flushHeaders() { + if (this[kFlushedHeaders] || this.destroyed) { + return; + } + + this[kFlushedHeaders] = true; + + const isConnectMethod = this.method === HTTP2_METHOD_CONNECT; + + // The real magic is here + const onStream = stream => { + this._request = stream; + + if (this.destroyed) { + stream.destroy(); + return; + } + + // Forwards `timeout`, `continue`, `close` and `error` events to this instance. + if (!isConnectMethod) { + proxyEvents(stream, this, ['timeout', 'continue', 'close', 'error']); + } + + // Wait for the `finish` event. We don't want to emit the `response` event + // before `request.end()` is called. + const waitForEnd = fn => { + return (...args) => { + if (!this.writable && !this.destroyed) { + fn(...args); + } else { + this.once('finish', () => { + fn(...args); + }); + } + }; + }; + + // This event tells we are ready to listen for the data. + stream.once('response', waitForEnd((headers, flags, rawHeaders) => { + // If we were to emit raw request stream, it would be as fast as the native approach. + // Note that wrapping the raw stream in a Proxy instance won't improve the performance (already tested it). + const response = new IncomingMessage(this.socket, stream.readableHighWaterMark); + this.res = response; + + response.req = this; + response.statusCode = headers[HTTP2_HEADER_STATUS]; + response.headers = headers; + response.rawHeaders = rawHeaders; + + response.once('end', () => { + if (this.aborted) { + response.aborted = true; + response.emit('aborted'); + } else { + response.complete = true; + + // Has no effect, just be consistent with the Node.js behavior + response.socket = null; + response.connection = null; + } + }); + + if (isConnectMethod) { + response.upgrade = true; + + // The HTTP1 API says the socket is detached here, + // but we can't do that so we pass the original HTTP2 request. + if (this.emit('connect', response, stream, Buffer.alloc(0))) { + this.emit('close'); + } else { + // No listeners attached, destroy the original request. + stream.destroy(); + } + } else { + // Forwards data + stream.on('data', chunk => { + if (!response._dumped && !response.push(chunk)) { + stream.pause(); + } + }); + + stream.once('end', () => { + response.push(null); + }); + + if (!this.emit('response', response)) { + // No listeners attached, dump the response. + response._dump(); + } + } + })); + + // Emits `information` event + stream.once('headers', waitForEnd( + headers => this.emit('information', {statusCode: headers[HTTP2_HEADER_STATUS]}) + )); + + stream.once('trailers', waitForEnd((trailers, flags, rawTrailers) => { + const {res} = this; + + // Assigns trailers to the response object. + res.trailers = trailers; + res.rawTrailers = rawTrailers; + })); + + const {socket} = stream.session; + this.socket = socket; + this.connection = socket; + + for (const job of this[kJobs]) { + job(); + } + + this.emit('socket', this.socket); + }; + + // Makes a HTTP2 request + if (this[kSession]) { + try { + onStream(this[kSession].request(this[kHeaders])); + } catch (error) { + this.emit('error', error); + } + } else { + this.reusedSocket = true; + + try { + onStream(await this.agent.request(this[kOrigin], this[kOptions], this[kHeaders])); + } catch (error) { + this.emit('error', error); + } + } + } + + getHeader(name) { + if (typeof name !== 'string') { + throw new ERR_INVALID_ARG_TYPE('name', 'string', name); + } + + return this[kHeaders][name.toLowerCase()]; + } + + get headersSent() { + return this[kFlushedHeaders]; + } + + removeHeader(name) { + if (typeof name !== 'string') { + throw new ERR_INVALID_ARG_TYPE('name', 'string', name); + } + + if (this.headersSent) { + throw new ERR_HTTP_HEADERS_SENT('remove'); + } + + delete this[kHeaders][name.toLowerCase()]; + } + + setHeader(name, value) { + if (this.headersSent) { + throw new ERR_HTTP_HEADERS_SENT('set'); + } + + if (typeof name !== 'string' || (!isValidHttpToken.test(name) && !isRequestPseudoHeader(name))) { + throw new ERR_INVALID_HTTP_TOKEN('Header name', name); + } + + if (typeof value === 'undefined') { + throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name); + } + + if (isInvalidHeaderValue.test(value)) { + throw new ERR_INVALID_CHAR('header content', name); + } + + this[kHeaders][name.toLowerCase()] = value; + } + + setNoDelay() { + // HTTP2 sockets cannot be malformed, do nothing. + } + + setSocketKeepAlive() { + // HTTP2 sockets cannot be malformed, do nothing. + } + + setTimeout(ms, callback) { + const applyTimeout = () => this._request.setTimeout(ms, callback); + + if (this._request) { + applyTimeout(); + } else { + this[kJobs].push(applyTimeout); + } + + return this; + } + + get maxHeadersCount() { + if (!this.destroyed && this._request) { + return this._request.session.localSettings.maxHeaderListSize; + } + + return undefined; + } + + set maxHeadersCount(_value) { + // Updating HTTP2 settings would affect all requests, do nothing. + } +} + +module.exports = ClientRequest; diff --git a/node_modules/http2-wrapper/source/incoming-message.js b/node_modules/http2-wrapper/source/incoming-message.js new file mode 100644 index 0000000..7d5c0d3 --- /dev/null +++ b/node_modules/http2-wrapper/source/incoming-message.js @@ -0,0 +1,58 @@ +'use strict'; +const {Readable} = require('stream'); + +class IncomingMessage extends Readable { + constructor(socket, highWaterMark) { + super({ + highWaterMark, + autoDestroy: false + }); + + this.statusCode = null; + this.statusMessage = ''; + this.httpVersion = '2.0'; + this.httpVersionMajor = 2; + this.httpVersionMinor = 0; + this.headers = {}; + this.trailers = {}; + this.req = null; + + this.aborted = false; + this.complete = false; + this.upgrade = null; + + this.rawHeaders = []; + this.rawTrailers = []; + + this.socket = socket; + this.connection = socket; + + this._dumped = false; + } + + _destroy(error) { + this.req._request.destroy(error); + } + + setTimeout(ms, callback) { + this.req.setTimeout(ms, callback); + return this; + } + + _dump() { + if (!this._dumped) { + this._dumped = true; + + this.removeAllListeners('data'); + this.resume(); + } + } + + _read() { + if (this.req) { + this.req._request.resume(); + } + } +} + +module.exports = IncomingMessage; diff --git a/node_modules/http2-wrapper/source/index.js b/node_modules/http2-wrapper/source/index.js new file mode 100644 index 0000000..fb83349 --- /dev/null +++ b/node_modules/http2-wrapper/source/index.js @@ -0,0 +1,28 @@ +'use strict'; +const http2 = require('http2'); +const agent = require('./agent'); +const ClientRequest = require('./client-request'); +const IncomingMessage = require('./incoming-message'); +const auto = require('./auto'); + +const request = (url, options, callback) => { + return new ClientRequest(url, options, callback); +}; + +const get = (url, options, callback) => { + // eslint-disable-next-line unicorn/prevent-abbreviations + const req = new ClientRequest(url, options, callback); + req.end(); + + return req; +}; + +module.exports = { + ...http2, + ClientRequest, + IncomingMessage, + ...agent, + request, + get, + auto +}; diff --git a/node_modules/http2-wrapper/source/utils/calculate-server-name.js b/node_modules/http2-wrapper/source/utils/calculate-server-name.js new file mode 100644 index 0000000..b05c099 --- /dev/null +++ b/node_modules/http2-wrapper/source/utils/calculate-server-name.js @@ -0,0 +1,27 @@ +'use strict'; +const net = require('net'); +/* istanbul ignore file: https://github.com/nodejs/node/blob/v13.0.1/lib/_http_agent.js */ + +module.exports = options => { + let servername = options.host; + const hostHeader = options.headers && options.headers.host; + + if (hostHeader) { + if (hostHeader.startsWith('[')) { + const index = hostHeader.indexOf(']'); + if (index === -1) { + servername = hostHeader; + } else { + servername = hostHeader.slice(1, -1); + } + } else { + servername = hostHeader.split(':', 1)[0]; + } + } + + if (net.isIP(servername)) { + return ''; + } + + return servername; +}; diff --git a/node_modules/http2-wrapper/source/utils/errors.js b/node_modules/http2-wrapper/source/utils/errors.js new file mode 100644 index 0000000..5018283 --- /dev/null +++ b/node_modules/http2-wrapper/source/utils/errors.js @@ -0,0 +1,45 @@ +'use strict'; +/* istanbul ignore file: https://github.com/nodejs/node/blob/master/lib/internal/errors.js */ + +const makeError = (Base, key, getMessage) => { + module.exports[key] = class NodeError extends Base { + constructor(...args) { + super(typeof getMessage === 'string' ? getMessage : getMessage(args)); + this.name = `${super.name} [${key}]`; + this.code = key; + } + }; +}; + +makeError(TypeError, 'ERR_INVALID_ARG_TYPE', args => { + const type = args[0].includes('.') ? 'property' : 'argument'; + + let valid = args[1]; + const isManyTypes = Array.isArray(valid); + + if (isManyTypes) { + valid = `${valid.slice(0, -1).join(', ')} or ${valid.slice(-1)}`; + } + + return `The "${args[0]}" ${type} must be ${isManyTypes ? 'one of' : 'of'} type ${valid}. Received ${typeof args[2]}`; +}); + +makeError(TypeError, 'ERR_INVALID_PROTOCOL', args => { + return `Protocol "${args[0]}" not supported. Expected "${args[1]}"`; +}); + +makeError(Error, 'ERR_HTTP_HEADERS_SENT', args => { + return `Cannot ${args[0]} headers after they are sent to the client`; +}); + +makeError(TypeError, 'ERR_INVALID_HTTP_TOKEN', args => { + return `${args[0]} must be a valid HTTP token [${args[1]}]`; +}); + +makeError(TypeError, 'ERR_HTTP_INVALID_HEADER_VALUE', args => { + return `Invalid value "${args[0]} for header "${args[1]}"`; +}); + +makeError(TypeError, 'ERR_INVALID_CHAR', args => { + return `Invalid character in ${args[0]} [${args[1]}]`; +}); diff --git a/node_modules/http2-wrapper/source/utils/is-request-pseudo-header.js b/node_modules/http2-wrapper/source/utils/is-request-pseudo-header.js new file mode 100644 index 0000000..bed31cd --- /dev/null +++ b/node_modules/http2-wrapper/source/utils/is-request-pseudo-header.js @@ -0,0 +1,13 @@ +'use strict'; + +module.exports = header => { + switch (header) { + case ':method': + case ':scheme': + case ':authority': + case ':path': + return true; + default: + return false; + } +}; diff --git a/node_modules/http2-wrapper/source/utils/proxy-events.js b/node_modules/http2-wrapper/source/utils/proxy-events.js new file mode 100644 index 0000000..35e2ae0 --- /dev/null +++ b/node_modules/http2-wrapper/source/utils/proxy-events.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = (from, to, events) => { + for (const event of events) { + from.on(event, (...args) => to.emit(event, ...args)); + } +}; diff --git a/node_modules/http2-wrapper/source/utils/url-to-options.js b/node_modules/http2-wrapper/source/utils/url-to-options.js new file mode 100644 index 0000000..36d1580 --- /dev/null +++ b/node_modules/http2-wrapper/source/utils/url-to-options.js @@ -0,0 +1,25 @@ +'use strict'; +/* istanbul ignore file: https://github.com/nodejs/node/blob/a91293d4d9ab403046ab5eb022332e4e3d249bd3/lib/internal/url.js#L1257 */ + +module.exports = url => { + const options = { + protocol: url.protocol, + hostname: typeof url.hostname === 'string' && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname, + host: url.host, + hash: url.hash, + search: url.search, + pathname: url.pathname, + href: url.href, + path: `${url.pathname || ''}${url.search || ''}` + }; + + if (typeof url.port === 'string' && url.port.length !== 0) { + options.port = Number(url.port); + } + + if (url.username || url.password) { + options.auth = `${url.username || ''}:${url.password || ''}`; + } + + return options; +}; |