Repository: sindresorhus/got
Pull request #683 (2018-12-22 21:08:00)

Add `init` hook
Introduces a new hook: `init`. It gets called right before option normalization - very useful when you need to handle some things on your own.
For example, this fixes the [`jsonReplacer` example in `migration-guides.md`](https://github.com/sindresorhus/got/pull/683/files#diff-a54dbc53336e27b44b3bfab2b8c9b48f).

#### Checklist

- [x] I have read the documentation.
- [x] I have included a pull request description of my changes.
- [x] I have included some tests.
- [x] If it's a new feature, I have included documentation updates.



Patch:
diff --git a/advanced-creation.md b/advanced-creation.md
index 86de39a..67a0b60 100644
--- a/advanced-creation.md
+++ b/advanced-creation.md
@@ -98,6 +98,7 @@ const defaults = {
 			'user-agent': `${pkg.name}/${pkg.version} (https://github.com/sindresorhus/got)`
 		},
 		hooks: {
+			init: [],
 			beforeRequest: [],
 			beforeRedirect: [],
 			beforeRetry: [],
diff --git a/migration-guides.md b/migration-guides.md
index 0c59f31..c82de73 100644
--- a/migration-guides.md
+++ b/migration-guides.md
@@ -103,31 +103,23 @@ gotInstance(url, options);
 ```js
 const gotInstance = got.extend({
 	hooks: {
-		beforeRequest: [
+		init: [
 			options => {
+				// Save the original option, so we can look at it in the `afterResponse` hook
+				options.originalJson = options.json;
+
 				if (options.json && options.jsonReplacer) {
-					// #1 solution (faster)
-					const newBody = {};
-					for (const [key, value] of Object.entries(options.body)) {
-						let newValue = value;
-						do {
-							newValue = options.jsonReplacer(key, newValue);
-						} while (typeof newValue === 'object');
-
-						newBody[key] = newValue;
-					}
-					options.body = newBody;
-
-					// #2 solution (slower)
-					options.body = JSON.parse(JSON.stringify(options.body, options.jsonReplacer));
+					options.body = JSON.stringify(options.body, options.jsonReplacer);
+					options.json = false; // We've handled that on our own
 				}
 			}
 		],
 		afterResponse: [
 			response => {
 				const options = response.request.gotOptions;
-				if (options.json && options.jsonReviver) {
-					response.body = JSON.stringify(JSON.parse(response.body, options.jsonReviver));
+				if (options.originalJson && options.jsonReviver) {
+					response.body = JSON.parse(response.body, options.jsonReviver);
+					options.json = false; // We've handled that on our own
 				}
 
 				return response;
diff --git a/package.json b/package.json
index bd9b890..f603786 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "got",
-	"version": "9.5.0",
+	"version": "9.5.1",
 	"description": "Simplified HTTP requests",
 	"license": "MIT",
 	"repository": "sindresorhus/got",
@@ -47,7 +47,7 @@
 		"url-parse-lax": "^3.0.0"
 	},
 	"devDependencies": {
-		"ava": "^1.0.1",
+		"ava": "^1.1.0",
 		"coveralls": "^3.0.0",
 		"delay": "^4.1.0",
 		"form-data": "^2.3.3",
@@ -61,8 +61,8 @@
 		"slow-stream": "0.0.4",
 		"tempfile": "^2.0.0",
 		"tempy": "^0.2.1",
-		"tough-cookie": "^2.4.3",
-		"xo": "^0.23.0"
+		"tough-cookie": "^3.0.0",
+		"xo": "^0.24.0"
 	},
 	"ava": {
 		"concurrency": 4
diff --git a/readme.md b/readme.md
index ab1ce44..f4b4e10 100644
--- a/readme.md
+++ b/readme.md
@@ -35,7 +35,7 @@ Got is for Node.js. For browsers, we recommend [Ky](https://github.com/sindresor
 - [Errors with metadata](#errors)
 - [JSON mode](#json)
 - [WHATWG URL support](#url)
-- [Hooks](https://github.com/sindresorhus/got#hooks)
+- [Hooks](#hooks)
 - [Instances with custom defaults](#instances)
 - [Composable](advanced-creation.md#merging-instances)
 - [Electron support](#useelectronnet)
@@ -351,6 +351,17 @@ Type: `Object<string, Function[]>`
 
 Hooks allow modifications during the request lifecycle. Hook functions may be async and are run serially.
 
+###### hooks.init
+
+Type: `Function[]`<br>
+Default: `[]`
+
+Called with plain [request options](#options), right before their normalization. This is especially useful in conjunction with [`got.extend()`](#instances) and [`got.create()`](advanced-creation.md) when the input needs custom handling.
+
+See the [Request migration guide](migration-guides.md#breaking-changes) for an example.
+
+**Note**: This hook must be synchronous!
+
 ###### hooks.beforeRequest
 
 Type: `Function[]`<br>
diff --git a/source/as-promise.js b/source/as-promise.js
index cd11c5a..c502325 100644
--- a/source/as-promise.js
+++ b/source/as-promise.js
@@ -81,6 +81,7 @@ const asPromise = options => {
 
 					resolve(response);
 				}
+
 				return;
 			}
 
diff --git a/source/as-stream.js b/source/as-stream.js
index 09da656..98c5342 100644
--- a/source/as-stream.js
+++ b/source/as-stream.js
@@ -83,6 +83,7 @@ module.exports = options => {
 
 		return result;
 	};
+
 	proxy.unpipe = stream => {
 		piped.delete(stream);
 		return unpipe(stream);
diff --git a/source/errors.js b/source/errors.js
index fcf9548..b6cbadc 100644
--- a/source/errors.js
+++ b/source/errors.js
@@ -69,6 +69,7 @@ module.exports.HTTPError = class extends GotError {
 		} else {
 			statusMessage = http.STATUS_CODES[statusCode];
 		}
+
 		super(`Response code ${statusCode} (${statusMessage})`, {}, options);
 		this.name = 'HTTPError';
 		this.statusCode = statusCode;
diff --git a/source/known-hook-events.js b/source/known-hook-events.js
index 2fa0381..a991073 100644
--- a/source/known-hook-events.js
+++ b/source/known-hook-events.js
@@ -1,6 +1,7 @@
 'use strict';
 
 module.exports = [
+	'init',
 	'beforeRequest',
 	'beforeRedirect',
 	'beforeRetry',
diff --git a/source/normalize-arguments.js b/source/normalize-arguments.js
index 2aa756c..665cbce 100644
--- a/source/normalize-arguments.js
+++ b/source/normalize-arguments.js
@@ -11,8 +11,15 @@ const knownHookEvents = require('./known-hook-events');
 
 const retryAfterStatusCodes = new Set([413, 429, 503]);
 
-// `preNormalize` handles static things (lowercasing headers; normalizing baseUrl, timeout, retry)
-// While `normalize` does `preNormalize` + handles things which need to be reworked when user changes them
+// `preNormalize` handles static options (e.g. headers).
+// For example, when you create a custom instance and make a request
+// with no static changes, they won't be normalized again.
+//
+// `normalize` operates on dynamic options - they cannot be saved.
+// For example, `body` is everytime different per request.
+// When it's done normalizing the new options, it performs merge()
+// on the prenormalized options and the normalized ones.
+
 const preNormalize = (options, defaults) => {
 	if (is.nullOrUndefined(options.headers)) {
 		options.headers = {};
@@ -49,6 +56,7 @@ const preNormalize = (options, defaults) => {
 	} else if (is.object(options.timeout)) {
 		options.gotTimeout = options.timeout;
 	}
+
 	delete options.timeout;
 
 	const {retry} = options;
@@ -125,6 +133,14 @@ const normalize = (url, options, defaults) => {
 	// Override both null/undefined with default protocol
 	options = merge({path: ''}, url, {protocol: url.protocol || 'https:'}, options);
 
+	for (const hook of options.hooks.init) {
+		const called = hook(options);
+
+		if (is.promise(called)) {
+			throw new TypeError('The `init` hook must be a synchronous function');
+		}
+	}
+
 	const {baseUrl} = options;
 	Object.defineProperty(options, 'baseUrl', {
 		set: () => {
@@ -138,6 +154,7 @@ const normalize = (url, options, defaults) => {
 		if (!is.string(query)) {
 			options.query = (new URLSearchParams(query)).toString();
 		}
+
 		options.path = `${options.path.split('?')[0]}?${options.query}`;
 		delete options.query;
 	}
diff --git a/source/utils/timed-out.js b/source/utils/timed-out.js
index a0efde4..33611a7 100644
--- a/source/utils/timed-out.js
+++ b/source/utils/timed-out.js
@@ -82,11 +82,16 @@ module.exports = (request, delays, options) => {
 	}
 
 	if (delays.socket !== undefined) {
-		request.setTimeout(delays.socket, () => {
+		const socketTimeoutHandler = () => {
 			timeoutHandler(delays.socket, 'socket');
-		});
+		};
+
+		request.setTimeout(delays.socket, socketTimeoutHandler);
 
-		cancelers.push(() => request.setTimeout(0));
+		// `request.setTimeout(0)` causes a memory leak.
+		// We can just remove the listener and forget about the timer - it's unreffed.
+		// See https://github.com/sindresorhus/got/issues/690
+		cancelers.push(() => request.removeListener('timeout', socketTimeoutHandler));
 	}
 
 	if (delays.lookup !== undefined && !request.socketPath && !net.isIP(hostname || host)) {


Test patch:
diff --git a/test/arguments.js b/test/arguments.js
index db62167..5533739 100644
--- a/test/arguments.js
+++ b/test/arguments.js
@@ -1,3 +1,4 @@
+/* eslint-disable node/no-deprecated-api */
 import {URL, URLSearchParams, parse} from 'url';
 import test from 'ava';
 import pEvent from 'p-event';
diff --git a/test/cache.js b/test/cache.js
index 979f102..7fd961b 100644
--- a/test/cache.js
+++ b/test/cache.js
@@ -40,6 +40,7 @@ test.before('setup', async () => {
 			response.setHeader('Location', `${s.url}/302`);
 			response.statusCode = 301;
 		}
+
 		response.end();
 		status301Index++;
 	});
@@ -51,6 +52,7 @@ test.before('setup', async () => {
 			response.setHeader('Location', `${s.url}/cache`);
 			response.statusCode = 302;
 		}
+
 		response.end();
 		status302Index++;
 	});
diff --git a/test/error.js b/test/error.js
index cb81312..749b91f 100644
--- a/test/error.js
+++ b/test/error.js
@@ -175,6 +175,7 @@ test('catch error in mimicResponse', async t => {
 	const mimicResponse = () => {
 		throw new Error('Error in mimic-response');
 	};
+
 	mimicResponse['@global'] = true;
 
 	const proxiedGot = proxyquire('..', {
diff --git a/test/hooks.js b/test/hooks.js
index b402a24..bc11587 100644
--- a/test/hooks.js
+++ b/test/hooks.js
@@ -1,5 +1,6 @@
 import test from 'ava';
 import delay from 'delay';
+import getStream from 'get-stream';
 import {createServer} from './helpers/server';
 import got from '..';
 
@@ -18,6 +19,9 @@ test.before('setup', async () => {
 	};
 
 	s.on('/', echoHeaders);
+	s.on('/body', async (request, response) => {
+		response.end(await getStream(request));
+	});
 	s.on('/redirect', (request, response) => {
 		response.statusCode = 302;
 		response.setHeader('location', '/');
@@ -75,29 +79,65 @@ test('async hooks', async t => {
 	t.is(body.foo, 'bar');
 });
 
-test('catches thrown errors', async t => {
+test('catches init thrown errors', async t => {
 	await t.throwsAsync(() => got(s.url, {
 		hooks: {
-			beforeRequest: [
-				() => {
-					throw error;
-				}
-			]
+			init: [() => {
+				throw error;
+			}]
 		}
 	}), errorString);
 });
 
-test('catches promise rejections', async t => {
+test('catches beforeRequest thrown errors', async t => {
 	await t.throwsAsync(() => got(s.url, {
 		hooks: {
-			beforeRequest: [
-				() => Promise.reject(error)
-			]
+			beforeRequest: [() => {
+				throw error;
+			}]
 		}
 	}), errorString);
 });
 
-test('catches beforeRequest errors', async t => {
+test('catches beforeRedirect thrown errors', async t => {
+	await t.throwsAsync(() => got(`${s.url}/redirect`, {
+		hooks: {
+			beforeRedirect: [() => {
+				throw error;
+			}]
+		}
+	}), errorString);
+});
+
+test('catches beforeRetry thrown errors', async t => {
+	await t.throwsAsync(() => got(`${s.url}/retry`, {
+		hooks: {
+			beforeRetry: [() => {
+				throw error;
+			}]
+		}
+	}), errorString);
+});
+
+test('catches afterResponse thrown errors', async t => {
+	await t.throwsAsync(() => got(s.url, {
+		hooks: {
+			afterResponse: [() => {
+				throw error;
+			}]
+		}
+	}), errorString);
+});
+
+test('throws a helpful error when passing async function as init hook', async t => {
+	await t.throwsAsync(() => got(s.url, {
+		hooks: {
+			init: [() => Promise.resolve()]
+		}
+	}), 'The `init` hook must be a synchronous function');
+});
+
+test('catches beforeRequest promise rejections', async t => {
 	await t.throwsAsync(() => got(s.url, {
 		hooks: {
 			beforeRequest: [() => Promise.reject(error)]
@@ -105,7 +145,7 @@ test('catches beforeRequest errors', async t => {
 	}), errorString);
 });
 
-test('catches beforeRedirect errors', async t => {
+test('catches beforeRedirect promise rejections', async t => {
 	await t.throwsAsync(() => got(`${s.url}/redirect`, {
 		hooks: {
 			beforeRedirect: [() => Promise.reject(error)]
@@ -113,7 +153,7 @@ test('catches beforeRedirect errors', async t => {
 	}), errorString);
 });
 
-test('catches beforeRetry errors', async t => {
+test('catches beforeRetry promise rejections', async t => {
 	await t.throwsAsync(() => got(`${s.url}/retry`, {
 		hooks: {
 			beforeRetry: [() => Promise.reject(error)]
@@ -121,7 +161,7 @@ test('catches beforeRetry errors', async t => {
 	}), errorString);
 });
 
-test('catches afterResponse errors', async t => {
+test('catches afterResponse promise rejections', async t => {
 	await t.throwsAsync(() => got(s.url, {
 		hooks: {
 			afterResponse: [() => Promise.reject(error)]
@@ -129,7 +169,34 @@ test('catches afterResponse errors', async t => {
 	}), errorString);
 });
 
-test('beforeRequest', async t => {
+test('init is called with options', async t => {
+	await got(s.url, {
+		json: true,
+		hooks: {
+			init: [
+				options => {
+					t.is(options.path, '/');
+					t.is(options.hostname, 'localhost');
+				}
+			]
+		}
+	});
+});
+
+test('init allows modifications', async t => {
+	const {body} = await got(`${s.url}/body`, {
+		hooks: {
+			init: [
+				options => {
+					options.body = 'foobar';
+				}
+			]
+		}
+	});
+	t.is(body, 'foobar');
+});
+
+test('beforeRequest is called with options', async t => {
 	await got(s.url, {
 		json: true,
 		hooks: {
@@ -157,7 +224,7 @@ test('beforeRequest allows modifications', async t => {
 	t.is(body.foo, 'bar');
 });
 
-test('beforeRedirect', async t => {
+test('beforeRedirect is called with options', async t => {
 	await got(`${s.url}/redirect`, {
 		json: true,
 		hooks: {
@@ -185,15 +252,17 @@ test('beforeRedirect allows modifications', async t => {
 	t.is(body.foo, 'bar');
 });
 
-test('beforeRetry', async t => {
+test('beforeRetry is called with options', async t => {
 	await got(`${s.url}/retry`, {
 		json: true,
 		retry: 1,
 		throwHttpErrors: false,
 		hooks: {
 			beforeRetry: [
-				options => {
+				(options, error, retryCount) => {
 					t.is(options.hostname, 'localhost');
+					t.truthy(error);
+					t.true(retryCount >= 1);
 				}
 			]
 		}
@@ -214,7 +283,7 @@ test('beforeRetry allows modifications', async t => {
 	t.is(body.foo, 'bar');
 });
 
-test('afterResponse', async t => {
+test('afterResponse is called with response', async t => {
 	await got(`${s.url}`, {
 		json: true,
 		hooks: {
diff --git a/test/redirects.js b/test/redirects.js
index dbff25f..4f09503 100644
--- a/test/redirects.js
+++ b/test/redirects.js
@@ -10,6 +10,7 @@ test.before('setup', async () => {
 	const reached = (request, response) => {
 		response.end('reached');
 	};
+
 	https = await createSSLServer();
 	http = await createServer();
 
diff --git a/test/socket-destroyed.js b/test/socket-destroyed.js
index e504a3c..038d2bb 100644
--- a/test/socket-destroyed.js
+++ b/test/socket-destroyed.js
@@ -1,6 +1,7 @@
 import test from 'ava';
 import got from '../source';
 
+// eslint-disable-next-line node/no-deprecated-api
 const {Timer} = process.binding('timer_wrap');
 
 test.serial('clear the progressInterval if the socket has been destroyed', async t => {
diff --git a/test/timeout.js b/test/timeout.js
index c36f6df..d98e955 100644
--- a/test/timeout.js
+++ b/test/timeout.js
@@ -18,6 +18,7 @@ const slowDataStream = () => {
 		if (count++ < 10) {
 			return slowStream.push('data\n'.repeat(100));
 		}
+
 		clearInterval(interval);
 		slowStream.push(null);
 	}, 100);
@@ -473,3 +474,21 @@ test('socket timeout is canceled on error', async t => {
 	// Wait a bit more to check if there are any unhandled errors
 	await delay(10);
 });
+
+test('no memory leak when using socket timeout and keepalive agent', async t => {
+	const promise = got(s.url, {
+		agent: keepAliveAgent,
+		timeout: {socket: requestDelay * 2}
+	});
+
+	let socket;
+	promise.on('request', request => {
+		request.on('socket', () => {
+			socket = request.socket;
+		});
+	});
+
+	await promise;
+
+	t.is(socket.listenerCount('timeout'), 0);
+});
diff --git a/test/url-to-options.js b/test/url-to-options.js
index ccffbe5..4692b4d 100644
--- a/test/url-to-options.js
+++ b/test/url-to-options.js
@@ -1,3 +1,4 @@
+/* eslint-disable node/no-deprecated-api */
 import url from 'url';
 import test from 'ava';
 import urlToOptions from '../source/utils/url-to-options';