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