index.ts
  1  import net from 'net';
  2  import http from 'http';
  3  import https from 'https';
  4  import { Duplex } from 'stream';
  5  import { EventEmitter } from 'events';
  6  import createDebug from 'debug';
  7  import promisify from './promisify';
  8  
  9  const debug = createDebug('agent-base');
 10  
 11  function isAgent(v: any): v is createAgent.AgentLike {
 12  	return Boolean(v) && typeof v.addRequest === 'function';
 13  }
 14  
 15  function isSecureEndpoint(): boolean {
 16  	const { stack } = new Error();
 17  	if (typeof stack !== 'string') return false;
 18  	return stack.split('\n').some(l => l.indexOf('(https.js:') !== -1  || l.indexOf('node:https:') !== -1);
 19  }
 20  
 21  function createAgent(opts?: createAgent.AgentOptions): createAgent.Agent;
 22  function createAgent(
 23  	callback: createAgent.AgentCallback,
 24  	opts?: createAgent.AgentOptions
 25  ): createAgent.Agent;
 26  function createAgent(
 27  	callback?: createAgent.AgentCallback | createAgent.AgentOptions,
 28  	opts?: createAgent.AgentOptions
 29  ) {
 30  	return new createAgent.Agent(callback, opts);
 31  }
 32  
 33  namespace createAgent {
 34  	export interface ClientRequest extends http.ClientRequest {
 35  		_last?: boolean;
 36  		_hadError?: boolean;
 37  		method: string;
 38  	}
 39  
 40  	export interface AgentRequestOptions {
 41  		host?: string;
 42  		path?: string;
 43  		// `port` on `http.RequestOptions` can be a string or undefined,
 44  		// but `net.TcpNetConnectOpts` expects only a number
 45  		port: number;
 46  	}
 47  
 48  	export interface HttpRequestOptions
 49  		extends AgentRequestOptions,
 50  			Omit<http.RequestOptions, keyof AgentRequestOptions> {
 51  		secureEndpoint: false;
 52  	}
 53  
 54  	export interface HttpsRequestOptions
 55  		extends AgentRequestOptions,
 56  			Omit<https.RequestOptions, keyof AgentRequestOptions> {
 57  		secureEndpoint: true;
 58  	}
 59  
 60  	export type RequestOptions = HttpRequestOptions | HttpsRequestOptions;
 61  
 62  	export type AgentLike = Pick<createAgent.Agent, 'addRequest'> | http.Agent;
 63  
 64  	export type AgentCallbackReturn = Duplex | AgentLike;
 65  
 66  	export type AgentCallbackCallback = (
 67  		err?: Error | null,
 68  		socket?: createAgent.AgentCallbackReturn
 69  	) => void;
 70  
 71  	export type AgentCallbackPromise = (
 72  		req: createAgent.ClientRequest,
 73  		opts: createAgent.RequestOptions
 74  	) =>
 75  		| createAgent.AgentCallbackReturn
 76  		| Promise<createAgent.AgentCallbackReturn>;
 77  
 78  	export type AgentCallback = typeof Agent.prototype.callback;
 79  
 80  	export type AgentOptions = {
 81  		timeout?: number;
 82  	};
 83  
 84  	/**
 85  	 * Base `http.Agent` implementation.
 86  	 * No pooling/keep-alive is implemented by default.
 87  	 *
 88  	 * @param {Function} callback
 89  	 * @api public
 90  	 */
 91  	export class Agent extends EventEmitter {
 92  		public timeout: number | null;
 93  		public maxFreeSockets: number;
 94  		public maxTotalSockets: number;
 95  		public maxSockets: number;
 96  		public sockets: {
 97  			[key: string]: net.Socket[];
 98  		};
 99  		public freeSockets: {
100  			[key: string]: net.Socket[];
101  		};
102  		public requests: {
103  			[key: string]: http.IncomingMessage[];
104  		};
105  		public options: https.AgentOptions;
106  		private promisifiedCallback?: createAgent.AgentCallbackPromise;
107  		private explicitDefaultPort?: number;
108  		private explicitProtocol?: string;
109  
110  		constructor(
111  			callback?: createAgent.AgentCallback | createAgent.AgentOptions,
112  			_opts?: createAgent.AgentOptions
113  		) {
114  			super();
115  
116  			let opts = _opts;
117  			if (typeof callback === 'function') {
118  				this.callback = callback;
119  			} else if (callback) {
120  				opts = callback;
121  			}
122  
123  			// Timeout for the socket to be returned from the callback
124  			this.timeout = null;
125  			if (opts && typeof opts.timeout === 'number') {
126  				this.timeout = opts.timeout;
127  			}
128  
129  			// These aren't actually used by `agent-base`, but are required
130  			// for the TypeScript definition files in `@types/node` :/
131  			this.maxFreeSockets = 1;
132  			this.maxSockets = 1;
133  			this.maxTotalSockets = Infinity;
134  			this.sockets = {};
135  			this.freeSockets = {};
136  			this.requests = {};
137  			this.options = {};
138  		}
139  
140  		get defaultPort(): number {
141  			if (typeof this.explicitDefaultPort === 'number') {
142  				return this.explicitDefaultPort;
143  			}
144  			return isSecureEndpoint() ? 443 : 80;
145  		}
146  
147  		set defaultPort(v: number) {
148  			this.explicitDefaultPort = v;
149  		}
150  
151  		get protocol(): string {
152  			if (typeof this.explicitProtocol === 'string') {
153  				return this.explicitProtocol;
154  			}
155  			return isSecureEndpoint() ? 'https:' : 'http:';
156  		}
157  
158  		set protocol(v: string) {
159  			this.explicitProtocol = v;
160  		}
161  
162  		callback(
163  			req: createAgent.ClientRequest,
164  			opts: createAgent.RequestOptions,
165  			fn: createAgent.AgentCallbackCallback
166  		): void;
167  		callback(
168  			req: createAgent.ClientRequest,
169  			opts: createAgent.RequestOptions
170  		):
171  			| createAgent.AgentCallbackReturn
172  			| Promise<createAgent.AgentCallbackReturn>;
173  		callback(
174  			req: createAgent.ClientRequest,
175  			opts: createAgent.AgentOptions,
176  			fn?: createAgent.AgentCallbackCallback
177  		):
178  			| createAgent.AgentCallbackReturn
179  			| Promise<createAgent.AgentCallbackReturn>
180  			| void {
181  			throw new Error(
182  				'"agent-base" has no default implementation, you must subclass and override `callback()`'
183  			);
184  		}
185  
186  		/**
187  		 * Called by node-core's "_http_client.js" module when creating
188  		 * a new HTTP request with this Agent instance.
189  		 *
190  		 * @api public
191  		 */
192  		addRequest(req: ClientRequest, _opts: RequestOptions): void {
193  			const opts: RequestOptions = { ..._opts };
194  
195  			if (typeof opts.secureEndpoint !== 'boolean') {
196  				opts.secureEndpoint = isSecureEndpoint();
197  			}
198  
199  			if (opts.host == null) {
200  				opts.host = 'localhost';
201  			}
202  
203  			if (opts.port == null) {
204  				opts.port = opts.secureEndpoint ? 443 : 80;
205  			}
206  
207  			if (opts.protocol == null) {
208  				opts.protocol = opts.secureEndpoint ? 'https:' : 'http:';
209  			}
210  
211  			if (opts.host && opts.path) {
212  				// If both a `host` and `path` are specified then it's most
213  				// likely the result of a `url.parse()` call... we need to
214  				// remove the `path` portion so that `net.connect()` doesn't
215  				// attempt to open that as a unix socket file.
216  				delete opts.path;
217  			}
218  
219  			delete opts.agent;
220  			delete opts.hostname;
221  			delete opts._defaultAgent;
222  			delete opts.defaultPort;
223  			delete opts.createConnection;
224  
225  			// Hint to use "Connection: close"
226  			// XXX: non-documented `http` module API :(
227  			req._last = true;
228  			req.shouldKeepAlive = false;
229  
230  			let timedOut = false;
231  			let timeoutId: ReturnType<typeof setTimeout> | null = null;
232  			const timeoutMs = opts.timeout || this.timeout;
233  
234  			const onerror = (err: NodeJS.ErrnoException) => {
235  				if (req._hadError) return;
236  				req.emit('error', err);
237  				// For Safety. Some additional errors might fire later on
238  				// and we need to make sure we don't double-fire the error event.
239  				req._hadError = true;
240  			};
241  
242  			const ontimeout = () => {
243  				timeoutId = null;
244  				timedOut = true;
245  				const err: NodeJS.ErrnoException = new Error(
246  					`A "socket" was not created for HTTP request before ${timeoutMs}ms`
247  				);
248  				err.code = 'ETIMEOUT';
249  				onerror(err);
250  			};
251  
252  			const callbackError = (err: NodeJS.ErrnoException) => {
253  				if (timedOut) return;
254  				if (timeoutId !== null) {
255  					clearTimeout(timeoutId);
256  					timeoutId = null;
257  				}
258  				onerror(err);
259  			};
260  
261  			const onsocket = (socket: AgentCallbackReturn) => {
262  				if (timedOut) return;
263  				if (timeoutId != null) {
264  					clearTimeout(timeoutId);
265  					timeoutId = null;
266  				}
267  
268  				if (isAgent(socket)) {
269  					// `socket` is actually an `http.Agent` instance, so
270  					// relinquish responsibility for this `req` to the Agent
271  					// from here on
272  					debug(
273  						'Callback returned another Agent instance %o',
274  						socket.constructor.name
275  					);
276  					(socket as createAgent.Agent).addRequest(req, opts);
277  					return;
278  				}
279  
280  				if (socket) {
281  					socket.once('free', () => {
282  						this.freeSocket(socket as net.Socket, opts);
283  					});
284  					req.onSocket(socket as net.Socket);
285  					return;
286  				}
287  
288  				const err = new Error(
289  					`no Duplex stream was returned to agent-base for \`${req.method} ${req.path}\``
290  				);
291  				onerror(err);
292  			};
293  
294  			if (typeof this.callback !== 'function') {
295  				onerror(new Error('`callback` is not defined'));
296  				return;
297  			}
298  
299  			if (!this.promisifiedCallback) {
300  				if (this.callback.length >= 3) {
301  					debug('Converting legacy callback function to promise');
302  					this.promisifiedCallback = promisify(this.callback);
303  				} else {
304  					this.promisifiedCallback = this.callback;
305  				}
306  			}
307  
308  			if (typeof timeoutMs === 'number' && timeoutMs > 0) {
309  				timeoutId = setTimeout(ontimeout, timeoutMs);
310  			}
311  
312  			if ('port' in opts && typeof opts.port !== 'number') {
313  				opts.port = Number(opts.port);
314  			}
315  
316  			try {
317  				debug(
318  					'Resolving socket for %o request: %o',
319  					opts.protocol,
320  					`${req.method} ${req.path}`
321  				);
322  				Promise.resolve(this.promisifiedCallback(req, opts)).then(
323  					onsocket,
324  					callbackError
325  				);
326  			} catch (err) {
327  				Promise.reject(err).catch(callbackError);
328  			}
329  		}
330  
331  		freeSocket(socket: net.Socket, opts: AgentOptions) {
332  			debug('Freeing socket %o %o', socket.constructor.name, opts);
333  			socket.destroy();
334  		}
335  
336  		destroy() {
337  			debug('Destroying agent %o', this.constructor.name);
338  		}
339  	}
340  
341  	// So that `instanceof` works correctly
342  	createAgent.prototype = createAgent.Agent.prototype;
343  }
344  
345  export = createAgent;