Repository: sindresorhus/got (ts)

Issue:
Add native support for cookie persistence handling using an API compatible with tough-cookie
Adding native cookie support has been discussed in multiple issues, e.g.

* https://github.com/sindresorhus/got/issues/107

However, none of the issues cover how one is supposed to implement cookie persistence between redirects.

It is simple to implement something like [tough-cookie](https://www.npmjs.com/package/tough-cookie) for a single request with no redirects. However, I haven't figured out how to keep track of cookies when a cookie is set on a redirect and the final destination depends on the cookie being present. It probably requires handling "response" and "redirect" events and modifying the request. Point is, as a consumer of an HTTP client, I want an in-built cookie support or at least an API and a recipe that allows to quickly add cookie support. Otherwise, the learning curve for starting to use Got becomes unreasonably high compared to the available alternatives, all of which (request, axios, superagent) support cookie persistence out of the box.

Pull request description:
Add `cookieJar` option
Fixes #566

 - [x] Works
 - [x] Documentation
 - [x] Tests for invalid cookies

Patch:
diff --git a/package.json b/package.json
index 1596ec4..f746a03 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
 		"slow-stream": "0.0.4",
 		"tempfile": "^2.0.0",
 		"tempy": "^0.2.1",
+		"tough-cookie": "^2.4.3",
 		"xo": "^0.22.0"
 	},
 	"ava": {
diff --git a/readme.md b/readme.md
index d07e110..29d8057 100644
--- a/readme.md
+++ b/readme.md
@@ -159,6 +159,14 @@ If present in `options` and `options.method` is not set, `options.method` will b
 
 The `content-length` header will be automatically set if `body` is a `string` / `Buffer` / `fs.createReadStream` instance / [`form-data` instance](https://github.com/form-data/form-data), and `content-length` and `transfer-encoding` are not manually set in `options.headers`.
 
+###### cookieJar
+
+Type: [`tough.CookieJar` instance](https://github.com/salesforce/tough-cookie#cookiejar)
+
+Cookie support. You don't have to care about parsing or how to store them. [Example.](#cookies)
+
+**Note:** `options.headers.cookie` will be overridden.
+
 ###### encoding
 
 Type: `string` `null`<br>
@@ -673,26 +681,16 @@ Check out [`global-tunnel`](https://github.com/np-maintain/global-tunnel) if you
 
 ## Cookies
 
-You can use the [`cookie`](https://github.com/jshttp/cookie) package to include cookies in a request:
+You can use the [`tough-cookie`](https://github.com/salesforce/tough-cookie) package:
 
 ```js
 const got = require('got');
-const cookie = require('cookie');
+const {CookieJar} = require('tough-cookie');
 
-got('google.com', {
-	headers: {
-		cookie: cookie.serialize('foo', 'bar')
-	}
-});
+const cookieJar = new CookieJar();
+cookieJar.setCookie('foo=bar', 'https://www.google.com');
 
-got('google.com', {
-	headers: {
-		cookie: [
-			cookie.serialize('foo', 'bar'),
-			cookie.serialize('fizz', 'buzz')
-		].join(';')
-	}
-});
+got('google.com', {cookieJar});
 ```
 
 
@@ -910,6 +908,7 @@ const h2got = got.extend({request});
 | Stream API            |       ✔      |       ✔      |       ✖      |       ✖      |
 | Request cancelation   |       ✔      |       ✖      |       ✖      |       ✔      |
 | RFC compliant caching |       ✔      |       ✖      |       ✖      |       ✖      |
+| Cookies (out-of-box)  |       ✔      |       ✔      |       ✖      |       ✖      |
 | Follows redirects     |       ✔      |       ✔      |       ✔      |       ✔      |
 | Retries on failure    |       ✔      |       ✖      |       ✖      |       ✖      |
 | Progress events       |       ✔      |       ✖      |       ✖      | Browser only |
@@ -926,6 +925,7 @@ const h2got = got.extend({request});
 | Downloads             |    ![][gd]   |    ![][rd]   |    ![][nd]   |    ![][ad]   |
 | Coverage              |    ![][gc]   |    ![][rc]   |    ![][nc]   |    ![][ac]   |
 | Build                 |    ![][gb]   |    ![][rb]   |    ![][nb]   |    ![][ab]   |
+| Bugs                  |   ![][gbg]   |   ![][rbg]   |   ![][nbg]   |   ![][abg]   |
 | Dependents            |   ![][gdp]   |   ![][rdp]   |   ![][ndp]   |   ![][adp]   |
 | Install size          |   ![][gis]   |   ![][ris]   |   ![][nis]   |   ![][ais]   |
 
@@ -962,6 +962,12 @@ const h2got = got.extend({request});
 [nb]: https://travis-ci.org/bitinn/node-fetch.svg?branch=master
 [ab]: https://travis-ci.org/axios/axios.svg?branch=master
 
+<!-- BUGS -->
+[gbg]: https://badgen.net/github/label-issues/sindresorhus/got/bug/open
+[rbg]: https://badgen.net/github/label-issues/request/request/Needs%20investigation/open
+[nbg]: https://badgen.net/github/label-issues/bitinn/node-fetch/bug/open
+[abg]: https://badgen.net/github/label-issues/axios/axios/bug/open
+
 <!-- DEPENDENTS -->
 [gdp]: https://badgen.net/npm/dependents/got
 [rdp]: https://badgen.net/npm/dependents/request
diff --git a/source/request-as-event-emitter.js b/source/request-as-event-emitter.js
index 3c8bf36..e94f5dd 100644
--- a/source/request-as-event-emitter.js
+++ b/source/request-as-event-emitter.js
@@ -1,5 +1,6 @@
 'use strict';
 const {URL} = require('url'); // TODO: Use the `URL` global when targeting Node.js 10
+const util = require('util');
 const EventEmitter = require('events');
 const http = require('http');
 const https = require('https');
@@ -26,7 +27,12 @@ module.exports = options => {
 	let redirectUrl;
 	let uploadBodySize;
 
-	const get = options => {
+	const setCookie = options.cookieJar ? util.promisify(options.cookieJar.setCookie.bind(options.cookieJar)) : null;
+	const getCookieString = options.cookieJar ? util.promisify(options.cookieJar.getCookieString.bind(options.cookieJar)) : null;
+
+	const get = async options => {
+		const currentUrl = redirectUrl || requestUrl;
+
 		if (options.protocol !== 'http:' && options.protocol !== 'https:') {
 			emitter.emit('error', new UnsupportedProtocolError(options));
 			return;
@@ -50,15 +56,36 @@ module.exports = options => {
 			fn = electron.net || electron.remote.net;
 		}
 
+		if (options.cookieJar) {
+			try {
+				const cookieString = await getCookieString(currentUrl, {});
+
+				if (!is.empty(cookieString)) {
+					options.headers.cookie = cookieString;
+				}
+			} catch (error) {
+				emitter.emit('error', error);
+			}
+		}
+
 		let timings;
 		const cacheableRequest = new CacheableRequest(fn.request, options.cache);
-		const cacheReq = cacheableRequest(options, response => {
+		const cacheReq = cacheableRequest(options, async response => {
 			const {statusCode} = response;
-			response.url = redirectUrl || requestUrl;
+			response.url = currentUrl;
 			response.requestUrl = requestUrl;
 			response.retryCount = retryCount;
 			response.timings = timings;
 
+			const rawCookies = response.headers['set-cookie'];
+			if (options.cookieJar && rawCookies) {
+				try {
+					await Promise.all(rawCookies.map(rawCookie => setCookie(rawCookie, response.url)));
+				} catch (error) {
+					emitter.emit('error', error);
+				}
+			}
+
 			const followRedirect = options.followRedirect && 'location' in response.headers;
 			const redirectGet = followRedirect && getMethodRedirectCodes.has(statusCode);
 			const redirectAll = followRedirect && allMethodRedirectCodes.has(statusCode);
@@ -96,7 +123,7 @@ module.exports = options => {
 
 				emitter.emit('redirect', response, redirectOpts);
 
-				get(redirectOpts);
+				await get(redirectOpts);
 				return;
 			}
 
@@ -182,7 +209,7 @@ module.exports = options => {
 				await hook(options);
 			}
 
-			get(options);
+			await get(options);
 		} catch (error) {
 			emitter.emit('error', error);
 		}


Test patch:
diff --git a/test/cookies.js b/test/cookies.js
new file mode 100644
index 0000000..41e34fc
--- /dev/null
+++ b/test/cookies.js
@@ -0,0 +1,102 @@
+import test from 'ava';
+import tough from 'tough-cookie';
+import got from '../source';
+import {createServer} from './helpers/server';
+
+let s;
+
+test.before('setup', async () => {
+	s = await createServer();
+
+	s.on('/set-cookie', (request, response) => {
+		response.setHeader('set-cookie', 'hello=world');
+		response.end();
+	});
+
+	s.on('/set-multiple-cookies', (request, response) => {
+		response.setHeader('set-cookie', ['hello=world', 'foo=bar']);
+		response.end();
+	});
+
+	s.on('/set-cookies-then-redirect', (request, response) => {
+		response.setHeader('set-cookie', ['hello=world', 'foo=bar']);
+		response.setHeader('location', '/');
+		response.statusCode = 302;
+		response.end();
+	});
+
+	s.on('/invalid', (request, response) => {
+		response.setHeader('set-cookie', 'hello=world; domain=localhost');
+		response.end();
+	});
+
+	s.on('/', (request, response) => {
+		response.end(request.headers.cookie || '');
+	});
+
+	await s.listen(s.port);
+});
+
+test.after('cleanup', async () => {
+	await s.close();
+});
+
+test('reads a cookie', async t => {
+	const cookieJar = new tough.CookieJar();
+
+	await got(`${s.url}/set-cookie`, {cookieJar});
+
+	const cookie = cookieJar.getCookiesSync(s.url)[0];
+	t.is(cookie.key, 'hello');
+	t.is(cookie.value, 'world');
+});
+
+test('reads multiple cookies', async t => {
+	const cookieJar = new tough.CookieJar();
+
+	await got(`${s.url}/set-multiple-cookies`, {cookieJar});
+
+	const cookies = cookieJar.getCookiesSync(s.url);
+	const cookieA = cookies[0];
+	t.is(cookieA.key, 'hello');
+	t.is(cookieA.value, 'world');
+
+	const cookieB = cookies[1];
+	t.is(cookieB.key, 'foo');
+	t.is(cookieB.value, 'bar');
+});
+
+test('cookies doesn\'t break on redirects', async t => {
+	const cookieJar = new tough.CookieJar();
+
+	const {body} = await got(`${s.url}/set-cookies-then-redirect`, {cookieJar});
+	t.is(body, 'hello=world; foo=bar');
+});
+
+test('throws on invalid cookies', async t => {
+	const cookieJar = new tough.CookieJar();
+
+	await t.throwsAsync(() => got(`${s.url}/invalid`, {cookieJar}), 'Cookie has domain set to a public suffix');
+});
+
+test('catches store errors', async t => {
+	const error = 'Some error';
+	const cookieJar = new tough.CookieJar({
+		findCookies: (_, __, cb) => {
+			cb(new Error(error));
+		}
+	});
+
+	await t.throwsAsync(() => got(s.url, {cookieJar}), error);
+});
+
+test('overrides options.headers.cookie', async t => {
+	const cookieJar = new tough.CookieJar();
+	const {body} = await got(`${s.url}/set-cookies-then-redirect`, {
+		cookieJar,
+		headers: {
+			cookie: 'a=b'
+		}
+	});
+	t.is(body, 'hello=world; foo=bar');
+});