Repository: node-fetch/node-fetch
Pull request #964 (2020-09-24 11:10:52)

fix: Add missing insecureHTTPParser in RequestInit interface
<!--
Please read and follow these instructions before creating and submitting a pull request:

- If you're fixing a bug, ensure you add unit tests to prove that it works.
- Before adding a feature, it is best to create an issue explaining it first. It would save you some effort in case we don't consider it should be included in node-fetch.
- If you are reporting a bug, adding failing units tests can be a good idea.
-->

**What is the purpose of this pull request?**
`insecureHTTPParser` was missing from the typings. This pull request adds it in.

- [ ] Documentation update
- [x] Bug fix
- [ ] New feature
- [ ] Other, please explain:

**What changes did you make? (provide an overview)**
Added `insecureHTTPParser` to the `RequestInit` interface.

**Which issue (if any) does this pull request address?**
#953.

**Is there anything you'd like reviewers to know?**
No.


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 6ab7be0..4f10ab3 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,7 +1,6 @@
 ---
 name: 🐞 Bug report
 about: Create a report to help us improve node-fetch
-title: "Bug: "
 labels: bug
 ---
 
diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md
index d9227cd..cd179d3 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.md
+++ b/.github/ISSUE_TEMPLATE/feature-request.md
@@ -1,10 +1,7 @@
 ---
 name: '✨ Feature Request'
 about: Suggest an idea or feature
-title: 'Feature request: '
 labels: feature
-assignees: ''
-
 ---
 
 **Is your feature request related to a problem? Please describe.**
diff --git a/.github/ISSUE_TEMPLATE/support-or-usage.md b/.github/ISSUE_TEMPLATE/support-or-usage.md
index 02b8d54..bc8389f 100644
--- a/.github/ISSUE_TEMPLATE/support-or-usage.md
+++ b/.github/ISSUE_TEMPLATE/support-or-usage.md
@@ -1,10 +1,7 @@
 ---
 name: "\U0001F914 Support or Usage Question"
 about: Get help using node-fetch
-title: 'Question: '
 labels: question
-assignees: ''
-
 ---
 
 <!--
diff --git a/@types/index.d.ts b/@types/index.d.ts
index b4d16ec..3f666dd 100644
--- a/@types/index.d.ts
+++ b/@types/index.d.ts
@@ -85,6 +85,7 @@ interface RequestInit {
 	protocol?: string;
 	size?: number;
 	highWaterMark?: number;
+	insecureHTTPParser?: boolean;
 }
 
 interface ResponseInit {
diff --git a/README.md b/README.md
index 8d0741a..7b68bb3 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 <div align="center">
-  	<img src="docs/media/Banner.svg" alt="Node Fetch"/>
-  	<br>
-  	<p>A light-weight module that brings <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">Fetch API</a> to Node.js.</p>
+	<img src="docs/media/Banner.svg" alt="Node Fetch"/>
+	<br>
+	<p>A light-weight module that brings <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">Fetch API</a> to Node.js.</p>
 	<a href="https://github.com/node-fetch/node-fetch/actions"><img src="https://github.com/node-fetch/node-fetch/workflows/CI/badge.svg?branch=master" alt="Build status"></a>
 	<a href="https://coveralls.io/github/node-fetch/node-fetch"><img src="https://img.shields.io/coveralls/github/node-fetch/node-fetch" alt="Coverage status"></a>
 	<a href="https://packagephobia.now.sh/result?p=node-fetch"><img src="https://badgen.net/packagephobia/install/node-fetch" alt="Current version"></a>
@@ -104,7 +104,7 @@ See Jason Miller's [isomorphic-unfetch](https://www.npmjs.com/package/isomorphic
 Current stable release (`3.x`)
 
 ```sh
-$ npm install node-fetch
+npm install node-fetch
 ```
 
 ## Loading and configuring the module
@@ -123,7 +123,7 @@ If you want to patch the global object in node:
 const fetch = require('node-fetch');
 
 if (!globalThis.fetch) {
-    globalThis.fetch = fetch;
+	globalThis.fetch = fetch;
 }
 ```
 
@@ -146,12 +146,10 @@ NOTE: The documentation below is up-to-date with `3.x` releases, if you are usin
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://github.com/');
-	const body = await response.text();
+const response = await fetch('https://github.com/');
+const body = await response.text();
 
-	console.log(body);
-})();
+console.log(body);
 ```
 
 ### JSON
@@ -159,12 +157,10 @@ const fetch = require('node-fetch');
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://api.github.com/users/github');
-	const json = await response.json();
+const response = await fetch('https://api.github.com/users/github');
+const data = await response.json();
 
-	console.log(json);
-})();
+console.log(data);
 ```
 
 ### Simple Post
@@ -172,12 +168,10 @@ const fetch = require('node-fetch');
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://httpbin.org/post', {method: 'POST', body: 'a=1'});
-	const json = await response.json();
+const response = await fetch('https://httpbin.org/post', {method: 'POST', body: 'a=1'});
+const data = await response.json();
 
-	console.log(json);
-})();
+console.log(data);
 ```
 
 ### Post with JSON
@@ -185,18 +179,16 @@ const fetch = require('node-fetch');
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const body = {a: 1};
+const body = {a: 1};
 
-	const response = await fetch('https://httpbin.org/post', {
-		method: 'post',
-		body: JSON.stringify(body),
-		headers: {'Content-Type': 'application/json'}
-	});
-	const json = await response.json();
+const response = await fetch('https://httpbin.org/post', {
+	method: 'post',
+	body: JSON.stringify(body),
+	headers: {'Content-Type': 'application/json'}
+});
+const data = await response.json();
 
-	console.log(json);
-})();
+console.log(data);
 ```
 
 ### Post with form parameters
@@ -211,12 +203,10 @@ const fetch = require('node-fetch');
 const params = new URLSearchParams();
 params.append('a', 1);
 
-(async () => {
-	const response = await fetch('https://httpbin.org/post', {method: 'POST', body: params});
-	const json = await response.json();
+const response = await fetch('https://httpbin.org/post', {method: 'POST', body: params});
+const data = await response.json();
 
-	console.log(json);
-})();
+console.log(data);
 ```
 
 ### Handling exceptions
@@ -229,7 +219,7 @@ Wrapping the fetch function into a `try/catch` block will catch _all_ exceptions
 const fetch = require('node-fetch');
 
 try {
-	fetch('https://domain.invalid/');
+	await fetch('https://domain.invalid/');
 } catch (error) {
 	console.log(error);
 }
@@ -242,21 +232,32 @@ It is common to create a helper function to check that the response contains no
 ```js
 const fetch = require('node-fetch');
 
-const checkStatus = res => {
-	if (res.ok) {
-		// res.status >= 200 && res.status < 300
-		return res;
+class HTTPResponseError extends Error {
+	constructor(response, ...args) {
+		this.response = response;
+		super(`HTTP Error Response: ${response.status} ${response.statusText}`, ...args);
+	}
+}
+
+const checkStatus = response => {
+	if (response.ok) {
+		// response.status >= 200 && response.status < 300
+		return response;
 	} else {
-		throw MyCustomError(res.statusText);
+		throw new HTTPResponseError(response);
 	}
 }
 
-(async () => {
-	const response = await fetch('https://httpbin.org/status/400');
-	const data = checkStatus(response);
+const response = await fetch('https://httpbin.org/status/400');
 
-	console.log(data); //=> MyCustomError
-})();
+try {
+	checkStatus(response);
+} catch (error) {
+	console.error(error);
+
+	const errorBody = await error.response.text();
+	console.error(`Error body: ${errorBody}`);
+}
 ```
 
 ### Handling cookies
@@ -270,19 +271,67 @@ Cookies are not stored by default. However, cookies can be extracted and passed
 The "Node.js way" is to use streams when possible. You can pipe `res.body` to another stream. This example uses [stream.pipeline](https://nodejs.org/api/stream.html#stream_stream_pipeline_streams_callback) to attach stream error handlers and wait for the download to complete.
 
 ```js
-const util = require('util');
-const fs = require('fs');
-const streamPipeline = util.promisify(require('stream').pipeline);
+const {createWriteStream} = require('fs');
+const {pipeline} = require('stream');
+const {promisify} = require('util');
+const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png');
-	
-	if (response.ok) {
-		return streamPipeline(response.body, fs.createWriteStream('./octocat.png'));
+const streamPipeline = promisify(pipeline);
+
+const response = await fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png');
+
+if (!response.ok) throw new Error(`unexpected response ${response.statusText}`);
+
+await streamPipeline(response.body, createWriteStream('./octocat.png'));
+```
+
+In Node.js 14 you can also use async iterators to read `body`; however, be careful to catch
+errors -- the longer a response runs, the more likely it is to encounter an error.
+
+```js
+const fetch = require('node-fetch');
+
+const response = await fetch('https://httpbin.org/stream/3');
+
+try {
+	for await (const chunk of response.body) {
+		console.dir(JSON.parse(chunk.toString()));
+	}
+} catch (err) {
+	console.error(err.stack);
+}
+```
+
+In Node.js 12 you can also use async iterators to read `body`; however, async iterators with streams
+did not mature until Node.js 14, so you need to do some extra work to ensure you handle errors
+directly from the stream and wait on it response to fully close.
+
+```js
+const fetch = require('node-fetch');
+
+const read = async body => {
+	let error;
+	body.on('error', err => {
+		error = err;
+	});
+
+	for await (const chunk of body) {
+		console.dir(JSON.parse(chunk.toString()));
 	}
 
-	throw new Error(`unexpected response ${response.statusText}`);
-})();
+	return new Promise((resolve, reject) => {
+		body.on('close', () => {
+			error ? reject(error) : resolve();
+		});
+	});
+};
+
+try {
+	const response = await fetch('https://httpbin.org/stream/3');
+	await read(response.body);
+} catch (err) {
+	console.error(err.stack);
+}
 ```
 
 ### Buffer
@@ -293,13 +342,11 @@ If you prefer to cache binary data in full, use buffer(). (NOTE: buffer() is a `
 const fetch = require('node-fetch');
 const fileType = require('file-type');
 
-(async () => {
-	const response = await fetch('https://octodex.github.com/images/Fintechtocat.png');
-	const buffer = await response.buffer();
-	const type = fileType.fromBuffer(buffer)
-	
-	console.log(type);
-})();
+const response = await fetch('https://octodex.github.com/images/Fintechtocat.png');
+const buffer = await response.buffer();
+const type = await fileType.fromBuffer(buffer)
+
+console.log(type);
 ```
 
 ### Accessing Headers and other Meta data
@@ -307,15 +354,13 @@ const fileType = require('file-type');
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://github.com/');
-	
-	console.log(response.ok);
-	console.log(response.status);
-	console.log(response.statusText);
-	console.log(response.headers.raw());
-	console.log(response.headers.get('content-type'));
-})();
+const response = await fetch('https://github.com/');
+
+console.log(response.ok);
+console.log(response.status);
+console.log(response.statusText);
+console.log(response.headers.raw());
+console.log(response.headers.get('content-type'));
 ```
 
 ### Extract Set-Cookie Header
@@ -325,12 +370,10 @@ Unlike browsers, you can access raw `Set-Cookie` headers manually using `Headers
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://example.com');
-	
-	// Returns an array of values, instead of a string of comma-separated values
-	console.log(response.headers.raw()['set-cookie']);
-})();
+const response = await fetch('https://example.com');
+
+// Returns an array of values, instead of a string of comma-separated values
+console.log(response.headers.raw()['set-cookie']);
 ```
 
 ### Post data using a file stream
@@ -341,12 +384,10 @@ const fetch = require('node-fetch');
 
 const stream = createReadStream('input.txt');
 
-(async () => {
-	const response = await fetch('https://httpbin.org/post', {method: 'POST', body: stream});
-	const json = await response.json();
-	
-	console.log(json)
-})();
+const response = await fetch('https://httpbin.org/post', {method: 'POST', body: stream});
+const data = await response.json();
+
+console.log(data)
 ```
 
 ### Post with form-data (detect multipart)
@@ -358,12 +399,10 @@ const FormData = require('form-data');
 const form = new FormData();
 form.append('a', 1);
 
-(async () => {
-	const response = await fetch('https://httpbin.org/post', {method: 'POST', body: form});
-	const json = await response.json();
-	
-	console.log(json)
-})();
+const response = await fetch('https://httpbin.org/post', {method: 'POST', body: form});
+const data = await response.json();
+
+console.log(data)
 
 // OR, using custom headers
 // NOTE: getHeaders() is non-standard API
@@ -374,15 +413,13 @@ const options = {
 	headers: form.getHeaders()
 };
 
-(async () => {
-	const response = await fetch('https://httpbin.org/post', options);
-	const json = await response.json();
-	
-	console.log(json)
-})();
+const response = await fetch('https://httpbin.org/post', options);
+const data = await response.json();
+
+console.log(data)
 ```
 
-node-fetch also supports spec-compliant FormData implementations such as [formdata-node](https://github.com/octet-stream/form-data):
+node-fetch also supports spec-compliant FormData implementations such as [form-data](https://github.com/form-data/form-data) and [formdata-node](https://github.com/octet-stream/form-data):
 
 ```js
 const fetch = require('node-fetch');
@@ -391,9 +428,10 @@ const FormData = require('formdata-node');
 const form = new FormData();
 form.set('greeting', 'Hello, world!');
 
-fetch('https://httpbin.org/post', {method: 'POST', body: form})
-	.then(res => res.json())
-	.then(json => console.log(json));
+const response = await fetch('https://httpbin.org/post', {method: 'POST', body: form});
+const data = await response.json();
+
+console.log(data);
 ```
 
 ### Request cancellation with AbortSignal
@@ -411,20 +449,16 @@ const timeout = setTimeout(() => {
 	controller.abort();
 }, 150);
 
-(async () => {
-	try {
-		const response = await fetch('https://example.com', {signal: controller.signal});
-		const data = await response.json();
-		
-		useData(data);
-	} catch (error) {
-		if (error.name === 'AbortError') {
-            console.log('request was aborted');
-		}
-	} finally {
-		clearTimeout(timeout);
+try {
+	const response = await fetch('https://example.com', {signal: controller.signal});
+	const data = await response.json();
+} catch (error) {
+	if (error instanceof fetch.AbortError) {
+		console.log('request was aborted');
 	}
-})();
+} finally {
+	clearTimeout(timeout);
+}
 ```
 
 See [test cases](https://github.com/node-fetch/node-fetch/blob/master/test/) for more examples.
@@ -449,20 +483,20 @@ The default values are shown after each option key.
 
 ```js
 {
-    // These properties are part of the Fetch Standard
-    method: 'GET',
-    headers: {},            // Request headers. format is the identical to that accepted by the Headers constructor (see below)
-    body: null,             // Request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream
-    redirect: 'follow',     // Set to `manual` to extract redirect headers, `error` to reject redirect
-    signal: null,           // Pass an instance of AbortSignal to optionally abort requests
-
-    // The following properties are node-fetch extensions
-    follow: 20,             // maximum redirect count. 0 to not follow redirect
-    compress: true,         // support gzip/deflate content encoding. false to disable
-    size: 0,                // maximum response body size in bytes. 0 to disable
-    agent: null,            // http(s).Agent instance or function that returns an instance (see below)
-    highWaterMark: 16384,   // the maximum number of bytes to store in the internal buffer before ceasing to read from the underlying resource.
-    insecureHTTPParser: false	// Use an insecure HTTP parser that accepts invalid HTTP headers when `true`.
+	// These properties are part of the Fetch Standard
+	method: 'GET',
+	headers: {},            // Request headers. format is the identical to that accepted by the Headers constructor (see below)
+	body: null,             // Request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream
+	redirect: 'follow',     // Set to `manual` to extract redirect headers, `error` to reject redirect
+	signal: null,           // Pass an instance of AbortSignal to optionally abort requests
+
+	// The following properties are node-fetch extensions
+	follow: 20,             // maximum redirect count. 0 to not follow redirect
+	compress: true,         // support gzip/deflate content encoding. false to disable
+	size: 0,                // maximum response body size in bytes. 0 to disable
+	agent: null,            // http(s).Agent instance or function that returns an instance (see below)
+	highWaterMark: 16384,   // the maximum number of bytes to store in the internal buffer before ceasing to read from the underlying resource.
+	insecureHTTPParser: false	// Use an insecure HTTP parser that accepts invalid HTTP headers when `true`.
 }
 ```
 
@@ -527,15 +561,13 @@ The recommended way to fix this problem is to resolve cloned response in paralle
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://example.com');
-	const r1 = await response.clone();
-	
-	return Promise.all([res.json(), r1.text()]).then(results => {
-		console.log(results[0]);
-		console.log(results[1]);
-	});
-})();
+const response = await fetch('https://example.com');
+const r1 = await response.clone();
+
+const results = await Promise.all([response.json(), r1.text()]);
+
+console.log(results[0]);
+console.log(results[1]);
 ```
 
 If for some reason you don't like the solution above, since `3.x` you are able to modify the `highWaterMark` option:
@@ -543,14 +575,13 @@ If for some reason you don't like the solution above, since `3.x` you are able t
 ```js
 const fetch = require('node-fetch');
 
-(async () => {
-	const response = await fetch('https://example.com', {
-		// About 1MB
-		highWaterMark: 1024 * 1024
-	});
-	
-	return res.clone().buffer();
-})();
+const response = await fetch('https://example.com', {
+	// About 1MB
+	highWaterMark: 1024 * 1024
+});
+
+const result = await res.clone().buffer();
+console.dir(result);
 ```
 
 #### Insecure HTTP Parser
@@ -649,7 +680,7 @@ Construct a new `Headers` object. `init` can be either `null`, a `Headers` objec
 
 ```js
 // Example adapted from https://fetch.spec.whatwg.org/#example-headers-class
-const { Headers } = require('node-fetch');
+const {Headers} = require('node-fetch');
 
 const meta = {
 	'Content-Type': 'text/xml',
@@ -740,7 +771,7 @@ An Error thrown when the request is aborted in response to an `AbortSignal`'s `a
 For older versions please use the type definitions from [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped):
 
 ```sh
-$ npm install --save-dev @types/node-fetch
+npm install --save-dev @types/node-fetch
 ```
 
 ## Acknowledgement
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 987881a..eb8a8c6 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -177,7 +177,7 @@ Fix packaging errors in v2.1.0.
 
 ## v2.0.0
 
-This is a major release. Check [our upgrade guide](https://github.com/node-fetch/node-fetch/blob/master/UPGRADE-GUIDE.md) for an overview on some key differences between v1 and v2.
+This is a major release. Check [our upgrade guide](https://github.com/node-fetch/node-fetch/blob/master/docs/v2-UPGRADE-GUIDE.md) for an overview on some key differences between v1 and v2.
 
 ### General changes
 
diff --git a/docs/ERROR-HANDLING.md b/docs/ERROR-HANDLING.md
index be29764..85b1f0d 100644
--- a/docs/ERROR-HANDLING.md
+++ b/docs/ERROR-HANDLING.md
@@ -12,13 +12,13 @@ The basics:
 const fetch = require('node-fetch');
 
 (async () => {
-		try {
-				await fetch(url, {signal});
-		} catch (error) {
-				if (error.name === 'AbortError') {
-    				console.log('request was aborted');
-  			}
+	try {
+		await fetch(url, {signal});
+	} catch (error) {
+		if (error.name === 'AbortError') {
+			console.log('request was aborted');
 		}
+	}
 })();
 ```
 
diff --git a/package.json b/package.json
index 03f569a..efb081a 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
   },
   "devDependencies": {
     "abort-controller": "^3.0.0",
-    "abortcontroller-polyfill": "^1.5.0",
+    "abortcontroller-polyfill": "^1.7.1",
     "busboy": "^0.3.1",
     "c8": "^7.3.0",
     "chai": "^4.2.0",
diff --git a/src/headers.js b/src/headers.js
index 0a3e250..ec8ada1 100644
--- a/src/headers.js
+++ b/src/headers.js
@@ -167,9 +167,9 @@ export default class Headers extends URLSearchParams {
 		return value;
 	}
 
-	forEach(callback) {
+	forEach(callback, thisArg = undefined) {
 		for (const name of this.keys()) {
-			callback(this.get(name), name);
+			Reflect.apply(callback, thisArg, [this.get(name), name, this]);
 		}
 	}
 
diff --git a/src/index.js b/src/index.js
index f26da14..a46e65f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -95,6 +95,30 @@ export default async function fetch(url, options_) {
 			finalize();
 		});
 
+		fixResponseChunkedTransferBadEnding(request_, err => {
+			response.body.destroy(err);
+		});
+
+		/* c8 ignore next 18 */
+		if (process.version < 'v14') {
+			// Before Node.js 14, pipeline() does not fully support async iterators and does not always
+			// properly handle when the socket close/end events are out of order.
+			request_.on('socket', s => {
+				let endedWithEventsCount;
+				s.prependListener('end', () => {
+					endedWithEventsCount = s._eventsCount;
+				});
+				s.prependListener('close', hadError => {
+					// if end happened before close but the socket didn't emit an error, do it now
+					if (response && endedWithEventsCount < s._eventsCount && !hadError) {
+						const err = new Error('Premature close');
+						err.code = 'ERR_STREAM_PREMATURE_CLOSE';
+						response.body.emit('error', err);
+					}
+				});
+			});
+		}
+
 		request_.on('response', response_ => {
 			request_.setTimeout(0);
 			const headers = fromRawHeaders(response_.rawHeaders);
@@ -116,13 +140,7 @@ export default async function fetch(url, options_) {
 					case 'manual':
 						// Node-fetch-specific step: make manual redirect a bit easier to use by setting the Location header value to the resolved URL.
 						if (locationURL !== null) {
-							// Handle corrupted header
-							try {
-								headers.set('Location', locationURL);
-								/* c8 ignore next 3 */
-							} catch (error) {
-								reject(error);
-							}
+							headers.set('Location', locationURL);
 						}
 
 						break;
@@ -174,7 +192,7 @@ export default async function fetch(url, options_) {
 					}
 
 					default:
-					// Do nothing
+						return reject(new TypeError(`Redirect option '${request.redirect}' is not a valid value of RequestRedirect`));
 				}
 			}
 
@@ -271,3 +289,31 @@ export default async function fetch(url, options_) {
 		writeToStream(request_, request);
 	});
 }
+
+function fixResponseChunkedTransferBadEnding(request, errorCallback) {
+	const LAST_CHUNK = Buffer.from('0\r\n');
+	let socket;
+
+	request.on('socket', s => {
+		socket = s;
+	});
+
+	request.on('response', response => {
+		const {headers} = response;
+		if (headers['transfer-encoding'] === 'chunked' && !headers['content-length']) {
+			let properLastChunkReceived = false;
+
+			socket.on('data', buf => {
+				properLastChunkReceived = Buffer.compare(buf.slice(-3), LAST_CHUNK) === 0;
+			});
+
+			socket.prependListener('close', () => {
+				if (!properLastChunkReceived) {
+					const err = new Error('Premature close');
+					err.code = 'ERR_STREAM_PREMATURE_CLOSE';
+					errorCallback(err);
+				}
+			});
+		}
+	});
+}
diff --git a/src/request.js b/src/request.js
index 83e5d0a..05999fa 100644
--- a/src/request.js
+++ b/src/request.js
@@ -82,8 +82,9 @@ export default class Request extends Body {
 			signal = init.signal;
 		}
 
-		if (signal !== null && !isAbortSignal(signal)) {
-			throw new TypeError('Expected signal to be an instanceof AbortSignal');
+		// eslint-disable-next-line no-eq-null, eqeqeq
+		if (signal != null && !isAbortSignal(signal)) {
+			throw new TypeError('Expected signal to be an instanceof AbortSignal or EventTarget');
 		}
 
 		this[INTERNALS] = {
diff --git a/src/utils/is.js b/src/utils/is.js
index e48165d..fa8d159 100644
--- a/src/utils/is.js
+++ b/src/utils/is.js
@@ -74,8 +74,10 @@ export function isFormData(object) {
  */
 export const isAbortSignal = object => {
 	return (
-		typeof object === 'object' &&
-		object[NAME] === 'AbortSignal'
+		typeof object === 'object' && (
+			object[NAME] === 'AbortSignal' ||
+			object[NAME] === 'EventTarget'
+		)
 	);
 };
 


Test patch:
diff --git a/test/headers.js b/test/headers.js
index 48a731e..749ccb9 100644
--- a/test/headers.js
+++ b/test/headers.js
@@ -1,3 +1,4 @@
+import util from 'util';
 import {Headers} from '../src/index.js';
 import chai from 'chai';
 import chaiIterator from 'chai-iterator';
@@ -52,6 +53,37 @@ describe('Headers', () => {
 		]);
 	});
 
+	it('should be iterable with forEach', () => {
+		const headers = new Headers();
+		headers.append('Accept', 'application/json');
+		headers.append('Accept', 'text/plain');
+		headers.append('Content-Type', 'text/html');
+
+		const results = [];
+		headers.forEach((value, key, object) => {
+			results.push({value, key, object});
+		});
+
+		expect(results.length).to.equal(2);
+		expect({key: 'accept', value: 'application/json, text/plain', object: headers}).to.deep.equal(results[0]);
+		expect({key: 'content-type', value: 'text/html', object: headers}).to.deep.equal(results[1]);
+	});
+
+	it('should set "this" to undefined by default on forEach', () => {
+		const headers = new Headers({Accept: 'application/json'});
+		headers.forEach(function () {
+			expect(this).to.be.undefined;
+		});
+	});
+
+	it('should accept thisArg as a second argument for forEach', () => {
+		const headers = new Headers({Accept: 'application/json'});
+		const thisArg = {};
+		headers.forEach(function () {
+			expect(this).to.equal(thisArg);
+		}, thisArg);
+	});
+
 	it('should allow iterating through all headers with for-of loop', () => {
 		const headers = new Headers([
 			['b', '2'],
@@ -232,4 +264,17 @@ describe('Headers', () => {
 		expect(() => new Headers('b2')).to.throw(TypeError);
 		expect(() => new Headers({[Symbol.iterator]: 42})).to.throw(TypeError);
 	});
+
+	it('should use a custom inspect function', () => {
+		const headers = new Headers([
+			['Host', 'thehost'],
+			['Host', 'notthehost'],
+			['a', '1'],
+			['b', '2'],
+			['a', '3']
+		]);
+
+		// eslint-disable-next-line quotes
+		expect(util.format(headers)).to.equal("{ a: [ '1', '3' ], b: '2', host: 'thehost' }");
+	});
 });
diff --git a/test/main.js b/test/main.js
index 1239850..49e6e7e 100644
--- a/test/main.js
+++ b/test/main.js
@@ -15,10 +15,9 @@ import chaiString from 'chai-string';
 import FormData from 'form-data';
 import FormDataNode from 'formdata-node';
 import delay from 'delay';
-import AbortControllerPolyfill from 'abortcontroller-polyfill/dist/abortcontroller.js';
-import AbortController2 from 'abort-controller';
-
-const {AbortController} = AbortControllerPolyfill;
+import AbortControllerMysticatea from 'abort-controller';
+import abortControllerPolyfill from 'abortcontroller-polyfill/dist/abortcontroller.js';
+const AbortControllerPolyfill = abortControllerPolyfill.AbortController;
 
 // Test subjects
 import Blob from 'fetch-blob';
@@ -456,6 +455,18 @@ describe('node-fetch', () => {
 		});
 	});
 
+	it('should support redirect mode, manual flag, broken Location header', () => {
+		const url = `${base}redirect/bad-location`;
+		const options = {
+			redirect: 'manual'
+		};
+		return fetch(url, options).then(res => {
+			expect(res.url).to.equal(url);
+			expect(res.status).to.equal(301);
+			expect(res.headers.get('location')).to.equal(`${base}redirect/%C3%A2%C2%98%C2%83`);
+		});
+	});
+
 	it('should support redirect mode, error flag', () => {
 		const url = `${base}redirect/301`;
 		const options = {
@@ -512,6 +523,19 @@ describe('node-fetch', () => {
 		});
 	});
 
+	it('should throw a TypeError on an invalid redirect option', () => {
+		const url = `${base}redirect/301`;
+		const options = {
+			redirect: 'foobar'
+		};
+		return fetch(url, options).then(() => {
+			expect.fail();
+		}, error => {
+			expect(error).to.be.an.instanceOf(TypeError);
+			expect(error.message).to.equal('Redirect option \'foobar\' is not a valid value of RequestRedirect');
+		});
+	});
+
 	it('should set redirected property on response when redirect', () => {
 		const url = `${base}redirect/301`;
 		return fetch(url).then(res => {
@@ -588,6 +612,74 @@ describe('node-fetch', () => {
 		});
 	});
 
+	it('should handle network-error in chunked response', () => {
+		const url = `${base}error/premature/chunked`;
+		return fetch(url).then(res => {
+			expect(res.status).to.equal(200);
+			expect(res.ok).to.be.true;
+
+			return expect(new Promise((resolve, reject) => {
+				res.body.on('error', reject);
+				res.body.on('close', resolve);
+			})).to.eventually.be.rejectedWith(Error, 'Premature close')
+				.and.have.property('code', 'ERR_STREAM_PREMATURE_CLOSE');
+		});
+	});
+
+	it('should handle network-error in chunked response async iterator', () => {
+		const url = `${base}error/premature/chunked`;
+		return fetch(url).then(res => {
+			expect(res.status).to.equal(200);
+			expect(res.ok).to.be.true;
+
+			const read = async body => {
+				const chunks = [];
+
+				if (process.version < 'v14') {
+					// In Node.js 12, some errors don't come out in the async iterator; we have to pick
+					// them up from the event-emitter and then throw them after the async iterator
+					let error;
+					body.on('error', err => {
+						error = err;
+					});
+
+					for await (const chunk of body) {
+						chunks.push(chunk);
+					}
+
+					if (error) {
+						throw error;
+					}
+
+					return new Promise(resolve => {
+						body.on('close', () => resolve(chunks));
+					});
+				}
+
+				for await (const chunk of body) {
+					chunks.push(chunk);
+				}
+
+				return chunks;
+			};
+
+			return expect(read(res.body))
+				.to.eventually.be.rejectedWith(Error, 'Premature close')
+				.and.have.property('code', 'ERR_STREAM_PREMATURE_CLOSE');
+		});
+	});
+
+	it('should handle network-error in chunked response in consumeBody', () => {
+		const url = `${base}error/premature/chunked`;
+		return fetch(url).then(res => {
+			expect(res.status).to.equal(200);
+			expect(res.ok).to.be.true;
+
+			return expect(res.text())
+				.to.eventually.be.rejectedWith(Error, 'Premature close');
+		});
+	});
+
 	it('should handle DNS-error response', () => {
 		const url = 'http://domain.invalid';
 		return expect(fetch(url)).to.eventually.be.rejected
@@ -603,6 +695,13 @@ describe('node-fetch', () => {
 		});
 	});
 
+	it('should handle response with no status text', () => {
+		const url = `${base}no-status-text`;
+		return fetch(url).then(res => {
+			expect(res.statusText).to.equal('');
+		});
+	});
+
 	it('should handle no content response', () => {
 		const url = `${base}no-content`;
 		return fetch(url).then(res => {
@@ -828,200 +927,240 @@ describe('node-fetch', () => {
 		});
 	});
 
-	it('should support request cancellation with signal', function () {
-		this.timeout(500);
-		const controller = new AbortController();
-		const controller2 = new AbortController2();
-
-		const fetches = [
-			fetch(`${base}timeout`, {signal: controller.signal}),
-			fetch(`${base}timeout`, {signal: controller2.signal}),
-			fetch(
-				`${base}timeout`,
-				{
-					method: 'POST',
-					signal: controller.signal,
-					headers: {
-						'Content-Type': 'application/json',
-						body: JSON.stringify({hello: 'world'})
-					}
-				}
-			)
-		];
-		setTimeout(() => {
-			controller.abort();
-			controller2.abort();
-		}, 100);
-
-		return Promise.all(fetches.map(fetched => expect(fetched)
-			.to.eventually.be.rejected
-			.and.be.an.instanceOf(Error)
-			.and.include({
-				type: 'aborted',
-				name: 'AbortError'
-			})
-		));
-	});
+	const testAbortController = (name, buildAbortController, moreTests = null) => {
+		describe(`AbortController (${name})`, () => {
+			let controller;
 
-	it('should reject immediately if signal has already been aborted', () => {
-		const url = `${base}timeout`;
-		const controller = new AbortController();
-		const options = {
-			signal: controller.signal
-		};
-		controller.abort();
-		const fetched = fetch(url, options);
-		return expect(fetched).to.eventually.be.rejected
-			.and.be.an.instanceOf(Error)
-			.and.include({
-				type: 'aborted',
-				name: 'AbortError'
+			beforeEach(() => {
+				controller = buildAbortController();
 			});
-	});
 
-	it('should remove internal AbortSignal event listener after request is aborted', () => {
-		const controller = new AbortController();
-		const {signal} = controller;
-		const promise = fetch(
-			`${base}timeout`,
-			{signal}
-		);
-		const result = expect(promise).to.eventually.be.rejected
-			.and.be.an.instanceof(Error)
-			.and.have.property('name', 'AbortError')
-			.then(() => {
-				expect(signal.listeners.abort.length).to.equal(0);
-			});
-		controller.abort();
-		return result;
-	});
+			it('should support request cancellation with signal', () => {
+				const fetches = [
+					fetch(
+						`${base}timeout`,
+						{
+							method: 'POST',
+							signal: controller.signal,
+							headers: {
+								'Content-Type': 'application/json',
+								body: JSON.stringify({hello: 'world'})
+							}
+						}
+					)
+				];
+				setTimeout(() => {
+					controller.abort();
+				}, 100);
 
-	it('should allow redirects to be aborted', () => {
-		const abortController = new AbortController();
-		const request = new Request(`${base}redirect/slow`, {
-			signal: abortController.signal
-		});
-		setTimeout(() => {
-			abortController.abort();
-		}, 20);
-		return expect(fetch(request)).to.be.eventually.rejected
-			.and.be.an.instanceOf(Error)
-			.and.have.property('name', 'AbortError');
-	});
+				return Promise.all(fetches.map(fetched => expect(fetched)
+					.to.eventually.be.rejected
+					.and.be.an.instanceOf(Error)
+					.and.include({
+						type: 'aborted',
+						name: 'AbortError'
+					})
+				));
+			});
 
-	it('should allow redirected response body to be aborted', () => {
-		const abortController = new AbortController();
-		const request = new Request(`${base}redirect/slow-stream`, {
-			signal: abortController.signal
-		});
-		return expect(fetch(request).then(res => {
-			expect(res.headers.get('content-type')).to.equal('text/plain');
-			const result = res.text();
-			abortController.abort();
-			return result;
-		})).to.be.eventually.rejected
-			.and.be.an.instanceOf(Error)
-			.and.have.property('name', 'AbortError');
-	});
-
-	it('should remove internal AbortSignal event listener after request and response complete without aborting', () => {
-		const controller = new AbortController();
-		const {signal} = controller;
-		const fetchHtml = fetch(`${base}html`, {signal})
-			.then(res => res.text());
-		const fetchResponseError = fetch(`${base}error/reset`, {signal});
-		const fetchRedirect = fetch(`${base}redirect/301`, {signal}).then(res => res.json());
-		return Promise.all([
-			expect(fetchHtml).to.eventually.be.fulfilled.and.equal('<html></html>'),
-			expect(fetchResponseError).to.be.eventually.rejected,
-			expect(fetchRedirect).to.eventually.be.fulfilled
-		]).then(() => {
-			expect(signal.listeners.abort.length).to.equal(0);
-		});
-	});
+			it('should support multiple request cancellation with signal', () => {
+				const fetches = [
+					fetch(`${base}timeout`, {signal: controller.signal}),
+					fetch(
+						`${base}timeout`,
+						{
+							method: 'POST',
+							signal: controller.signal,
+							headers: {
+								'Content-Type': 'application/json',
+								body: JSON.stringify({hello: 'world'})
+							}
+						}
+					)
+				];
+				setTimeout(() => {
+					controller.abort();
+				}, 100);
 
-	it('should reject response body with AbortError when aborted before stream has been read completely', () => {
-		const controller = new AbortController();
-		return expect(fetch(
-			`${base}slow`,
-			{signal: controller.signal}
-		))
-			.to.eventually.be.fulfilled
-			.then(res => {
-				const promise = res.text();
-				controller.abort();
-				return expect(promise)
+				return Promise.all(fetches.map(fetched => expect(fetched)
 					.to.eventually.be.rejected
-					.and.be.an.instanceof(Error)
-					.and.have.property('name', 'AbortError');
+					.and.be.an.instanceOf(Error)
+					.and.include({
+						type: 'aborted',
+						name: 'AbortError'
+					})
+				));
 			});
-	});
 
-	it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', () => {
-		const controller = new AbortController();
-		return expect(fetch(
-			`${base}slow`,
-			{signal: controller.signal}
-		))
-			.to.eventually.be.fulfilled
-			.then(res => {
+			it('should reject immediately if signal has already been aborted', () => {
+				const url = `${base}timeout`;
+				const options = {
+					signal: controller.signal
+				};
 				controller.abort();
-				return expect(res.text())
-					.to.eventually.be.rejected
-					.and.be.an.instanceof(Error)
+				const fetched = fetch(url, options);
+				return expect(fetched).to.eventually.be.rejected
+					.and.be.an.instanceOf(Error)
+					.and.include({
+						type: 'aborted',
+						name: 'AbortError'
+					});
+			});
+
+			it('should allow redirects to be aborted', () => {
+				const request = new Request(`${base}redirect/slow`, {
+					signal: controller.signal
+				});
+				setTimeout(() => {
+					controller.abort();
+				}, 20);
+				return expect(fetch(request)).to.be.eventually.rejected
+					.and.be.an.instanceOf(Error)
 					.and.have.property('name', 'AbortError');
 			});
-	});
 
-	it('should emit error event to response body with an AbortError when aborted before underlying stream is closed', done => {
-		const controller = new AbortController();
-		expect(fetch(
-			`${base}slow`,
-			{signal: controller.signal}
-		))
-			.to.eventually.be.fulfilled
-			.then(res => {
-				res.body.once('error', err => {
-					expect(err)
-						.to.be.an.instanceof(Error)
-						.and.have.property('name', 'AbortError');
-					done();
+			it('should allow redirected response body to be aborted', () => {
+				const request = new Request(`${base}redirect/slow-stream`, {
+					signal: controller.signal
 				});
+				return expect(fetch(request).then(res => {
+					expect(res.headers.get('content-type')).to.equal('text/plain');
+					const result = res.text();
+					controller.abort();
+					return result;
+				})).to.be.eventually.rejected
+					.and.be.an.instanceOf(Error)
+					.and.have.property('name', 'AbortError');
+			});
+
+			it('should reject response body with AbortError when aborted before stream has been read completely', () => {
+				return expect(fetch(
+					`${base}slow`,
+					{signal: controller.signal}
+				))
+					.to.eventually.be.fulfilled
+					.then(res => {
+						const promise = res.text();
+						controller.abort();
+						return expect(promise)
+							.to.eventually.be.rejected
+							.and.be.an.instanceof(Error)
+							.and.have.property('name', 'AbortError');
+					});
+			});
+
+			it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', () => {
+				return expect(fetch(
+					`${base}slow`,
+					{signal: controller.signal}
+				))
+					.to.eventually.be.fulfilled
+					.then(res => {
+						controller.abort();
+						return expect(res.text())
+							.to.eventually.be.rejected
+							.and.be.an.instanceof(Error)
+							.and.have.property('name', 'AbortError');
+					});
+			});
+
+			it('should emit error event to response body with an AbortError when aborted before underlying stream is closed', done => {
+				expect(fetch(
+					`${base}slow`,
+					{signal: controller.signal}
+				))
+					.to.eventually.be.fulfilled
+					.then(res => {
+						res.body.once('error', err => {
+							expect(err)
+								.to.be.an.instanceof(Error)
+								.and.have.property('name', 'AbortError');
+							done();
+						});
+						controller.abort();
+					});
+			});
+
+			it('should cancel request body of type Stream with AbortError when aborted', () => {
+				const body = new stream.Readable({objectMode: true});
+				body._read = () => { };
+				const promise = fetch(
+					`${base}slow`,
+					{signal: controller.signal, body, method: 'POST'}
+				);
+
+				const result = Promise.all([
+					new Promise((resolve, reject) => {
+						body.on('error', error => {
+							try {
+								expect(error).to.be.an.instanceof(Error).and.have.property('name', 'AbortError');
+								resolve();
+							} catch (error_) {
+								reject(error_);
+							}
+						});
+					}),
+					expect(promise).to.eventually.be.rejected
+						.and.be.an.instanceof(Error)
+						.and.have.property('name', 'AbortError')
+				]);
+
 				controller.abort();
+
+				return result;
 			});
-	});
 
-	it('should cancel request body of type Stream with AbortError when aborted', () => {
-		const controller = new AbortController();
-		const body = new stream.Readable({objectMode: true});
-		body._read = () => { };
-		const promise = fetch(
-			`${base}slow`,
-			{signal: controller.signal, body, method: 'POST'}
-		);
+			if (moreTests) {
+				moreTests();
+			}
+		});
+	};
 
-		const result = Promise.all([
-			new Promise((resolve, reject) => {
-				body.on('error', error => {
-					try {
-						expect(error).to.be.an.instanceof(Error).and.have.property('name', 'AbortError');
-						resolve();
-					} catch (error_) {
-						reject(error_);
-					}
+	testAbortController('polyfill',
+		() => new AbortControllerPolyfill(),
+		() => {
+			it('should remove internal AbortSignal event listener after request is aborted', () => {
+				const controller = new AbortControllerPolyfill();
+				const {signal} = controller;
+
+				setTimeout(() => {
+					controller.abort();
+				}, 20);
+
+				return expect(fetch(`${base}timeout`, {signal}))
+					.to.eventually.be.rejected
+					.and.be.an.instanceof(Error)
+					.and.have.property('name', 'AbortError')
+					.then(() => {
+						return expect(signal.listeners.abort.length).to.equal(0);
+					});
+			});
+
+			it('should remove internal AbortSignal event listener after request and response complete without aborting', () => {
+				const controller = new AbortControllerPolyfill();
+				const {signal} = controller;
+				const fetchHtml = fetch(`${base}html`, {signal})
+					.then(res => res.text());
+				const fetchResponseError = fetch(`${base}error/reset`, {signal});
+				const fetchRedirect = fetch(`${base}redirect/301`, {signal}).then(res => res.json());
+				return Promise.all([
+					expect(fetchHtml).to.eventually.be.fulfilled.and.equal('<html></html>'),
+					expect(fetchResponseError).to.be.eventually.rejected,
+					expect(fetchRedirect).to.eventually.be.fulfilled
+				]).then(() => {
+					expect(signal.listeners.abort.length).to.equal(0);
 				});
-			}),
-			expect(promise).to.eventually.be.rejected
-				.and.be.an.instanceof(Error)
-				.and.have.property('name', 'AbortError')
-		]);
+			});
+		}
+	);
 
-		controller.abort();
+	testAbortController('mysticatea', () => new AbortControllerMysticatea());
 
-		return result;
-	});
+	if (process.version > 'v15') {
+		testAbortController('native', () => new AbortController());
+	}
 
-	it('should throw a TypeError if a signal is not of type AbortSignal', () => {
+	it('should throw a TypeError if a signal is not of type AbortSignal or EventTarget', () => {
 		return Promise.all([
 			expect(fetch(`${base}inspect`, {signal: {}}))
 				.to.be.eventually.rejected
@@ -1038,6 +1177,17 @@ describe('node-fetch', () => {
 		]);
 	});
 
+	it('should gracefully handle a nullish signal', () => {
+		return Promise.all([
+			fetch(`${base}hello`, {signal: null}).then(res => {
+				return expect(res.ok).to.be.true;
+			}),
+			fetch(`${base}hello`, {signal: undefined}).then(res => {
+				return expect(res.ok).to.be.true;
+			})
+		]);
+	});
+
 	it('should set default User-Agent', () => {
 		const url = `${base}inspect`;
 		return fetch(url).then(res => res.json()).then(res => {
diff --git a/test/utils/server.js b/test/utils/server.js
index f4c2a5f..310dce4 100644
--- a/test/utils/server.js
+++ b/test/utils/server.js
@@ -70,6 +70,10 @@ export default class TestServer {
 			res.end('text');
 		}
 
+		if (p === '/no-status-text') {
+			res.writeHead(200, '', {}).end();
+		}
+
 		if (p === '/options') {
 			res.statusCode = 200;
 			res.setHeader('Allow', 'GET, HEAD, OPTIONS');
@@ -288,6 +292,11 @@ export default class TestServer {
 			res.end();
 		}
 
+		if (p === '/redirect/bad-location') {
+			res.socket.write('HTTP/1.1 301\r\nLocation: ☃\r\nContent-Length: 0\r\n');
+			res.socket.end('\r\n');
+		}
+
 		if (p === '/error/400') {
 			res.statusCode = 400;
 			res.setHeader('Content-Type', 'text/plain');
@@ -318,6 +327,23 @@ export default class TestServer {
 			}, 100);
 		}
 
+		if (p === '/error/premature/chunked') {
+			res.writeHead(200, {
+				'Content-Type': 'application/json',
+				'Transfer-Encoding': 'chunked'
+			});
+
+			res.write(`${JSON.stringify({data: 'hi'})}\n`);
+
+			setTimeout(() => {
+				res.write(`${JSON.stringify({data: 'bye'})}\n`);
+			}, 200);
+
+			setTimeout(() => {
+				res.destroy();
+			}, 400);
+		}
+
 		if (p === '/error/json') {
 			res.statusCode = 200;
 			res.setHeader('Content-Type', 'application/json');