1'use strict' 2 3const net = require('net') 4const tls = require('tls') 5const { once } = require('events') 6const timers = require('timers/promises') 7const { normalizeOptions, cacheOptions } = require('./options') 8const { getProxy, getProxyAgent, proxyCache } = require('./proxy.js') 9const Errors = require('./errors.js') 10const { Agent: AgentBase } = require('agent-base') 11 12module.exports = class Agent extends AgentBase { 13 #options 14 #timeouts 15 #proxy 16 #noProxy 17 #ProxyAgent 18 19 constructor (options = {}) { 20 const { timeouts, proxy, noProxy, ...normalizedOptions } = normalizeOptions(options) 21 22 super(normalizedOptions) 23 24 this.#options = normalizedOptions 25 this.#timeouts = timeouts 26 27 if (proxy) { 28 this.#proxy = new URL(proxy) 29 this.#noProxy = noProxy 30 this.#ProxyAgent = getProxyAgent(proxy) 31 } 32 } 33 34 get proxy () { 35 return this.#proxy ? { url: this.#proxy } : {} 36 } 37 38 #getProxy (options) { 39 if (!this.#proxy) { 40 return 41 } 42 43 const proxy = getProxy(`${options.protocol}//${options.host}:${options.port}`, { 44 proxy: this.#proxy, 45 noProxy: this.#noProxy, 46 }) 47 48 if (!proxy) { 49 return 50 } 51 52 const cacheKey = cacheOptions({ 53 ...options, 54 ...this.#options, 55 timeouts: this.#timeouts, 56 proxy, 57 }) 58 59 if (proxyCache.has(cacheKey)) { 60 return proxyCache.get(cacheKey) 61 } 62 63 let ProxyAgent = this.#ProxyAgent 64 if (Array.isArray(ProxyAgent)) { 65 ProxyAgent = this.isSecureEndpoint(options) ? ProxyAgent[1] : ProxyAgent[0] 66 } 67 68 const proxyAgent = new ProxyAgent(proxy, this.#options) 69 proxyCache.set(cacheKey, proxyAgent) 70 71 return proxyAgent 72 } 73 74 // takes an array of promises and races them against the connection timeout 75 // which will throw the necessary error if it is hit. This will return the 76 // result of the promise race. 77 async #timeoutConnection ({ promises, options, timeout }, ac = new AbortController()) { 78 if (timeout) { 79 const connectionTimeout = timers.setTimeout(timeout, null, { signal: ac.signal }) 80 .then(() => { 81 throw new Errors.ConnectionTimeoutError(`${options.host}:${options.port}`) 82 }).catch((err) => { 83 if (err.name === 'AbortError') { 84 return 85 } 86 throw err 87 }) 88 promises.push(connectionTimeout) 89 } 90 91 let result 92 try { 93 result = await Promise.race(promises) 94 ac.abort() 95 } catch (err) { 96 ac.abort() 97 throw err 98 } 99 return result 100 } 101 102 async connect (request, options) { 103 // if the connection does not have its own lookup function 104 // set, then use the one from our options 105 options.lookup ??= this.#options.lookup 106 107 let socket 108 let timeout = this.#timeouts.connection 109 const isSecureEndpoint = this.isSecureEndpoint(options) 110 111 const proxy = this.#getProxy(options) 112 if (proxy) { 113 // some of the proxies will wait for the socket to fully connect before 114 // returning so we have to await this while also racing it against the 115 // connection timeout. 116 const start = Date.now() 117 socket = await this.#timeoutConnection({ 118 options, 119 timeout, 120 promises: [proxy.connect(request, options)], 121 }) 122 // see how much time proxy.connect took and subtract it from 123 // the timeout 124 if (timeout) { 125 timeout = timeout - (Date.now() - start) 126 } 127 } else { 128 socket = (isSecureEndpoint ? tls : net).connect(options) 129 } 130 131 socket.setKeepAlive(this.keepAlive, this.keepAliveMsecs) 132 socket.setNoDelay(this.keepAlive) 133 134 const abortController = new AbortController() 135 const { signal } = abortController 136 137 const connectPromise = socket[isSecureEndpoint ? 'secureConnecting' : 'connecting'] 138 ? once(socket, isSecureEndpoint ? 'secureConnect' : 'connect', { signal }) 139 : Promise.resolve() 140 141 await this.#timeoutConnection({ 142 options, 143 timeout, 144 promises: [ 145 connectPromise, 146 once(socket, 'error', { signal }).then((err) => { 147 throw err[0] 148 }), 149 ], 150 }, abortController) 151 152 if (this.#timeouts.idle) { 153 socket.setTimeout(this.#timeouts.idle, () => { 154 socket.destroy(new Errors.IdleTimeoutError(`${options.host}:${options.port}`)) 155 }) 156 } 157 158 return socket 159 } 160 161 addRequest (request, options) { 162 const proxy = this.#getProxy(options) 163 // it would be better to call proxy.addRequest here but this causes the 164 // http-proxy-agent to call its super.addRequest which causes the request 165 // to be added to the agent twice. since we only support 3 agents 166 // currently (see the required agents in proxy.js) we have manually 167 // checked that the only public methods we need to call are called in the 168 // next block. this could change in the future and presumably we would get 169 // failing tests until we have properly called the necessary methods on 170 // each of our proxy agents 171 if (proxy?.setRequestProps) { 172 proxy.setRequestProps(request, options) 173 } 174 175 request.setHeader('connection', this.keepAlive ? 'keep-alive' : 'close') 176 177 if (this.#timeouts.response) { 178 let responseTimeout 179 request.once('finish', () => { 180 setTimeout(() => { 181 request.destroy(new Errors.ResponseTimeoutError(request, this.#proxy)) 182 }, this.#timeouts.response) 183 }) 184 request.once('response', () => { 185 clearTimeout(responseTimeout) 186 }) 187 } 188 189 if (this.#timeouts.transfer) { 190 let transferTimeout 191 request.once('response', (res) => { 192 setTimeout(() => { 193 res.destroy(new Errors.TransferTimeoutError(request, this.#proxy)) 194 }, this.#timeouts.transfer) 195 res.once('close', () => { 196 clearTimeout(transferTimeout) 197 }) 198 }) 199 } 200 201 return super.addRequest(request, options) 202 } 203} 204