Repository: node-fetch/node-fetch
Pull request #842 (2020-05-27 18:57:23)

chore: remove code duplication in custom errors
In the current implementation `AbortError` and `FetchError` pretty much repeating the same code (plus some magic strings). This PR moves common functionality into a base class and removes code duplication.

It also removes `isAbortError` and uses `instanceof FetchBaseError` as those errors are our internal classes and always instantiating from it.

tinovyatkin: @Richienb The abort error is just two lines... Import statement will adds up the same amount of code as implementation....

Richienb: > @Richienb The abort error is just two lines... Import statement will adds up the same amount of code as implementation....

I still think they should be in separate files since it would make it easier for future maintenance.
LinusU: I personally don't think that there is a problem with keeping the errors in the same file...

Although, have you checked that the stack traces actually looks identical after making this change? I might be wrong here, but I think that when you do `Error.captureStackTrace` in the parent class, referencing `this.constructor` might be referencing the parent constructor, which will leave the leaf class in the stack trace? Not too sure about this though, it's a shame that it's not more straight forward to `extends Error` 😅 
tinovyatkin: @Richienb 
> I still think they should be in separate files since it would make it easier for future maintenance.

So, for best maintenance in the future you expect to have 3 files right: `errors/base.js`, `errros/abort.js`, and `errors/fetch.js` - right?  A lot of boilerplate, harder imports, and slower load doesn't matter?

Richienb: Using seperate files will allow them to be self-documenting so the future maintainers don't have to manually check the exports to find the use.
tinovyatkin: @Richienb Adjusted as per your suggestion.
tinovyatkin: @LinusU 
> Although, have you checked that the stack traces actually looks identical after making this change? I might be wrong here, but I think that when you do `Error.captureStackTrace` in the parent class, referencing `this.constructor` might be referencing the parent constructor, which will leave the leaf class in the stack trace?

Not, it will be referencing the actual class constructor. This is how `super` works.



Patch:
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 55f1844..17f583a 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,4 +1,4 @@
-blank_issues_enabled: false
+blank_issues_enabled: true
 contact_links:
   - name: Discord Server 
     url: https://discord.gg/Zxbndcm
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c1192f5..0ec8f60 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,46 +2,64 @@ name: CI
 
 on:
   push:
-    branches: [ master ]
+    branches: [master]
   pull_request:
     paths:
-      - '**.js'
-      - 'package.json'
-      - '.github/workflows/ci.yml'
+      - "**.js"
+      - "package.json"
+      - ".github/workflows/ci.yml"
 
 jobs:
   test:
     strategy:
       matrix:
         os: [ubuntu-latest, windows-latest, macOS-latest]
-        node: [14.x, 12.x, 10.x]
+        node: ["14", "12", engines]
         exclude:
           # On Windows, run tests with only the LTS environments.
           - os: windows-latest
-            node: 10.x
+            node: engines
           - os: windows-latest
-            node: 14.x
+            node: "14"
           # On macOS, run tests with only the LTS environments.
           - os: macOS-latest
-            node: 10.x
+            node: engines
           - os: macOS-latest
-            node: 14.x
+            node: "14"
 
     runs-on: ${{ matrix.os }}
     steps:
       - uses: actions/checkout@v2
-      - uses: actions/setup-node@v1
+
+      - name: Get Node.JS version from package.json
+        if: matrix.node == 'engines'
+        id: get-version
+        run: echo ::set-output name=node::$(npx --q minimum-node-version)
+
+      - uses: actions/setup-node@v2-beta
+        if: matrix.node != 'engines'
+        with:
+          node-version: ${{ matrix.node }}
+
+      - uses: actions/setup-node@v2-beta
+        if: matrix.node == 'engines'
         with:
-          node-version: ${{ matrix.node-version }}
+          node-version: ${{steps.get-version.outputs.node}}
 
       - run: npm install
 
       - run: npm test -- --colors
+        if: matrix.node != 'engines'
+
+      - name: Test without coverage
+        if: matrix.node == 'engines'
+        run: |
+          npm i esm
+          npx mocha -r esm --colors
 
       # upload coverage only once
       - name: Coveralls
         uses: coverallsapp/github-action@master
-        if: matrix.node == '14.x' && matrix.os == 'ubuntu-latest'
+        if: matrix.node == '12' && matrix.os == 'ubuntu-latest'
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
-
diff --git a/.github/workflows/commonjs.yml b/.github/workflows/commonjs.yml
index 4d1082a..77818e2 100644
--- a/.github/workflows/commonjs.yml
+++ b/.github/workflows/commonjs.yml
@@ -18,12 +18,12 @@ jobs:
       - uses: actions/checkout@v2
 
       - name: Get Node.JS version from package.json
-        id: get-versions
-        run: echo ::set-output name=node::$(jq -r .engines.node ./package.json | sed 's/[^0-9.]//g')
+        id: get-version
+        run: echo ::set-output name=node::$(npx --q minimum-node-version)
 
-      - uses: actions/setup-node@v1
+      - uses: actions/setup-node@v2-beta
         with:
-          node-version: ${{steps.get-versions.outputs.node}}
+          node-version: ${{steps.get-version.outputs.node}}
 
       - run: npm install
 
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 083b825..1ce559e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -3,18 +3,18 @@ name: CI
 on:
   pull_request:
     paths:
-      - '**.js'
-      - '**eslint**'
-      - 'package.json'
-      - '.github/workflows/lint.yml'
+      - "**.js"
+      - "**eslint**"
+      - "package.json"
+      - ".github/workflows/lint.yml"
 jobs:
   lint:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v2
-    - name: Use Node.js
-      uses: actions/setup-node@v1
-      with:
-        node-version: 14
-    - run: npm install
-    - run: npm run lint
+      - uses: actions/checkout@v2
+      - name: Use Node.js
+        uses: actions/setup-node@v2-beta
+        with:
+          node-version: 14
+      - run: npm install
+      - run: npm run lint
diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml
index d26f2c9..9c530a9 100644
--- a/.github/workflows/types.yml
+++ b/.github/workflows/types.yml
@@ -3,7 +3,7 @@ name: CI
 on:
   pull_request:
     paths:
-      - '**.ts'
+      - "**.ts"
       - package.json
       - .github/workflows/types.yml
 
@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
-      - uses: actions/setup-node@v1
+      - uses: actions/setup-node@v2-beta
 
       - run: npm install
 
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..2336057
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at jimmy@warting.se. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/README.md b/README.md
index 9026b75..0460306 100644
--- a/README.md
+++ b/README.md
@@ -277,10 +277,10 @@ const streamPipeline = util.promisify(require('stream').pipeline);
 	const response = await fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png');
 	
 	if (response.ok) {
-		return streamPipeline(res.body, fs.createWriteStream('./octocat.png'));
+		return streamPipeline(response.body, fs.createWriteStream('./octocat.png'));
 	}
 
-	throw new Error(`unexpected response ${res.statusText}`);
+	throw new Error(`unexpected response ${response.statusText}`);
 })();
 ```
 
@@ -309,11 +309,11 @@ const fetch = require('node-fetch');
 (async () => {
 	const response = await fetch('https://github.com/');
 	
-	console.log(res.ok);
-	console.log(res.status);
-	console.log(res.statusText);
-	console.log(res.headers.raw());
-	console.log(res.headers.get('content-type'));
+	console.log(response.ok);
+	console.log(response.status);
+	console.log(response.statusText);
+	console.log(response.headers.raw());
+	console.log(response.headers.get('content-type'));
 })();
 ```
 
@@ -328,7 +328,7 @@ const fetch = require('node-fetch');
 	const response = await fetch('https://example.com');
 	
 	// Returns an array of values, instead of a string of comma-separated values
-	console.log(res.headers.raw()['set-cookie']);
+	console.log(response.headers.raw()['set-cookie']);
 })();
 ```
 
@@ -446,7 +446,8 @@ The default values are shown after each option key.
     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.
+    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`.
 }
 ```
 
@@ -537,6 +538,11 @@ const fetch = require('node-fetch');
 })();
 ```
 
+#### Insecure HTTP Parser
+
+Passed through to the `insecureHTTPParser` option on http(s).request. See [`http.request`](https://nodejs.org/api/http.html#http_http_request_url_options_callback) for more information.
+
+
 <a id="class-request"></a>
 
 ### Class: Request
@@ -628,7 +634,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',
diff --git a/docs/v3-UPGRADE-GUIDE.md b/docs/v3-UPGRADE-GUIDE.md
index 0cc541a..ad9848f 100644
--- a/docs/v3-UPGRADE-GUIDE.md
+++ b/docs/v3-UPGRADE-GUIDE.md
@@ -126,6 +126,6 @@ Since v3.x you no longer need to install `@types/node-fetch` package in order to
 [cross-fetch]: https://github.com/lquixada/cross-fetch
 [fetch-charset-detection]: https://github.com/Richienb/fetch-charset-detection
 [fetch-charset-detection-docs]: https://richienb.github.io/fetch-charset-detection/globals.html#convertbody
-[fetch-blob]: https://github.com/bitinn/fetch-blob#readme
+[fetch-blob]: https://github.com/node-fetch/fetch-blob#readme
 [whatwg-nodejs-url]: https://nodejs.org/api/url.html#url_the_whatwg_url_api
 [changelog]: CHANGELOG.md
diff --git a/package.json b/package.json
index a3bf790..31c5f61 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
 	],
 	"types": "./@types/index.d.ts",
 	"engines": {
-		"node": ">=10.16"
+		"node": ">=10.17"
 	},
 	"scripts": {
 		"build": "rollup -c",
@@ -57,20 +57,22 @@
 		"coveralls": "^3.1.0",
 		"delay": "^4.3.0",
 		"form-data": "^3.0.0",
-		"mocha": "^7.1.2",
+		"mocha": "^7.2.0",
 		"p-timeout": "^3.2.0",
 		"parted": "^0.1.1",
-		"resumer": "0.0.0",
-		"rollup": "^2.10.8",
+		"rollup": "^2.15.0",
 		"string-to-arraybuffer": "^1.0.2",
-		"tsc": "^1.20150623.0",
 		"tsd": "^0.11.0",
 		"xo": "^0.30.0"
 	},
 	"dependencies": {
-		"data-uri-to-buffer": "^3.0.0",
+		"data-uri-to-buffer": "^3.0.1",
 		"fetch-blob": "^1.0.6"
 	},
+	"esm": {
+		"sourceMap": true,
+		"cjs": false
+	},
 	"tsd": {
 		"cwd": "@types",
 		"compilerOptions": {
diff --git a/src/body.js b/src/body.js
index ad1c040..a415a95 100644
--- a/src/body.js
+++ b/src/body.js
@@ -9,8 +9,9 @@ import Stream, {PassThrough} from 'stream';
 import {types} from 'util';
 
 import Blob from 'fetch-blob';
-import FetchError from './errors/fetch-error.js';
-import {isBlob, isURLSearchParameters, isAbortError} from './utils/is.js';
+import {FetchError} from './errors/fetch-error.js';
+import {FetchBaseError} from './errors/base.js';
+import {isBlob, isURLSearchParameters} from './utils/is.js';
 
 const INTERNALS = Symbol('Body internals');
 
@@ -60,7 +61,7 @@ export default class Body {
 
 		if (body instanceof Stream) {
 			body.on('error', err => {
-				const error = isAbortError(err) ?
+				const error = err instanceof FetchBaseError ?
 					err :
 					new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err);
 				this[INTERNALS].error = error;
@@ -198,7 +199,7 @@ async function consumeBody(data) {
 			accum.push(chunk);
 		}
 	} catch (error) {
-		if (isAbortError(error) || error instanceof FetchError) {
+		if (error instanceof FetchBaseError) {
 			throw error;
 		} else {
 			// Other errors, such as incorrect content-encoding
@@ -208,6 +209,10 @@ async function consumeBody(data) {
 
 	if (body.readableEnded === true || body._readableState.ended === true) {
 		try {
+			if (accum.every(c => typeof c === 'string')) {
+				return Buffer.from(accum.join(''));
+			}
+
 			return Buffer.concat(accum, accumBytes);
 		} catch (error) {
 			throw new FetchError(`Could not create Buffer from response body for ${data.url}: ${error.message}`, 'system', error);
diff --git a/src/errors/abort-error.js b/src/errors/abort-error.js
index 27c7545..0b62f1c 100644
--- a/src/errors/abort-error.js
+++ b/src/errors/abort-error.js
@@ -1,27 +1,10 @@
-/**
- * Abort-error.js
- *
- * AbortError interface for cancelled requests
- */
+import {FetchBaseError} from './base.js';
 
 /**
- * Create AbortError instance
- *
- * @param   String      message      Error message for human
- * @param   String      type         Error type for machine
- * @param   String      systemError  For Node.js system error
- * @return  AbortError
+ * AbortError interface for cancelled requests
  */
-export default class AbortError extends Error {
-	constructor(message) {
-		super(message);
-
-		this.type = 'aborted';
-		this.message = message;
-		this.name = 'AbortError';
-		this[Symbol.toStringTag] = 'AbortError';
-
-		// Hide custom error implementation details from end-users
-		Error.captureStackTrace(this, this.constructor);
+export class AbortError extends FetchBaseError {
+	constructor(message, type = 'aborted') {
+		super(message, type);
 	}
 }
diff --git a/src/errors/base.js b/src/errors/base.js
new file mode 100644
index 0000000..95fb1b2
--- /dev/null
+++ b/src/errors/base.js
@@ -0,0 +1,20 @@
+'use strict';
+
+export class FetchBaseError extends Error {
+	constructor(message, type) {
+		super(message);
+		// Hide custom error implementation details from end-users
+		Error.captureStackTrace(this, this.constructor);
+
+		this.type = type;
+	}
+
+	get name() {
+		return this.constructor.name;
+	}
+
+	get [Symbol.toStringTag]() {
+		return this.constructor.name;
+	}
+}
+
diff --git a/src/errors/fetch-error.js b/src/errors/fetch-error.js
index 87b696a..f7ae5cc 100644
--- a/src/errors/fetch-error.js
+++ b/src/errors/fetch-error.js
@@ -1,34 +1,26 @@
+
+import {FetchBaseError} from './base.js';
+
 /**
- * Fetch-error.js
- *
- * FetchError interface for operational errors
- */
+ * @typedef {{ address?: string, code: string, dest?: string, errno: number, info?: object, message: string, path?: string, port?: number, syscall: string}} SystemError
+*/
 
 /**
- * Create FetchError instance
- *
- * @param   String      message      Error message for human
- * @param   String      type         Error type for machine
- * @param   Object      systemError  For Node.js system error
- * @return  FetchError
+ * FetchError interface for operational errors
  */
-export default class FetchError extends Error {
+export class FetchError extends FetchBaseError {
+	/**
+	 * @param  {string} message -      Error message for human
+	 * @param  {string} [type] -        Error type for machine
+	 * @param  {SystemError} [systemError] - For Node.js system error
+	 */
 	constructor(message, type, systemError) {
-		super(message);
-
-		this.message = message;
-		this.type = type;
-		this.name = 'FetchError';
-		this[Symbol.toStringTag] = 'FetchError';
-
+		super(message, type);
 		// When err.type is `system`, err.erroredSysCall contains system error and err.code contains system error code
 		if (systemError) {
 			// eslint-disable-next-line no-multi-assign
 			this.code = this.errno = systemError.code;
-			this.erroredSysCall = systemError;
+			this.erroredSysCall = systemError.syscall;
 		}
-
-		// Hide custom error implementation details from end-users
-		Error.captureStackTrace(this, this.constructor);
 	}
 }
diff --git a/src/headers.js b/src/headers.js
index 20424a8..0a3e250 100644
--- a/src/headers.js
+++ b/src/headers.js
@@ -5,23 +5,27 @@
  */
 
 import {types} from 'util';
+import http from 'http';
 
-const invalidTokenRegex = /[^`\-\w!#$%&'*+.|~]/;
-const invalidHeaderCharRegex = /[^\t\u0020-\u007E\u0080-\u00FF]/;
-
-function validateName(name) {
-	name = String(name);
-	if (invalidTokenRegex.test(name) || name === '') {
-		throw new TypeError(`'${name}' is not a legal HTTP header name`);
-	}
-}
+const validateHeaderName = typeof http.validateHeaderName === 'function' ?
+	http.validateHeaderName :
+	name => {
+		if (!/^[\^`\-\w!#$%&'*+.|~]+$/.test(name)) {
+			const err = new TypeError(`Header name must be a valid HTTP token [${name}]`);
+			Object.defineProperty(err, 'code', {value: 'ERR_INVALID_HTTP_TOKEN'});
+			throw err;
+		}
+	};
 
-function validateValue(value) {
-	value = String(value);
-	if (invalidHeaderCharRegex.test(value)) {
-		throw new TypeError(`'${value}' is not a legal HTTP header value`);
-	}
-}
+const validateHeaderValue = typeof http.validateHeaderValue === 'function' ?
+	http.validateHeaderValue :
+	(name, value) => {
+		if (/[^\t\u0020-\u007E\u0080-\u00FF]/.test(value)) {
+			const err = new TypeError(`Invalid character in header content ["${name}"]`);
+			Object.defineProperty(err, 'code', {value: 'ERR_INVALID_CHAR'});
+			throw err;
+		}
+	};
 
 /**
  * @typedef {Headers | Record<string, string> | Iterable<readonly [string, string]> | Iterable<Iterable<string>>} HeadersInit
@@ -91,9 +95,9 @@ export default class Headers extends URLSearchParams {
 		result =
 			result.length > 0 ?
 				result.map(([name, value]) => {
-					validateName(name);
-					validateValue(value);
-					return [String(name).toLowerCase(), value];
+					validateHeaderName(name);
+					validateHeaderValue(name, String(value));
+					return [String(name).toLowerCase(), String(value)];
 				}) :
 				undefined;
 
@@ -107,12 +111,12 @@ export default class Headers extends URLSearchParams {
 					case 'append':
 					case 'set':
 						return (name, value) => {
-							validateName(name);
-							validateValue(value);
+							validateHeaderName(name);
+							validateHeaderValue(name, String(value));
 							return URLSearchParams.prototype[p].call(
 								receiver,
 								String(name).toLowerCase(),
-								value
+								String(value)
 							);
 						};
 
@@ -120,7 +124,7 @@ export default class Headers extends URLSearchParams {
 					case 'has':
 					case 'getAll':
 						return name => {
-							validateName(name);
+							validateHeaderName(name);
 							return URLSearchParams.prototype[p].call(
 								receiver,
 								String(name).toLowerCase()
@@ -142,7 +146,7 @@ export default class Headers extends URLSearchParams {
 	}
 
 	get [Symbol.toStringTag]() {
-		return 'Headers';
+		return this.constructor.name;
 	}
 
 	toString() {
@@ -247,7 +251,15 @@ export function fromRawHeaders(headers = []) {
 
 				return result;
 			}, [])
-			.filter(([name, value]) => !(invalidTokenRegex.test(name) || invalidHeaderCharRegex.test(value)))
+			.filter(([name, value]) => {
+				try {
+					validateHeaderName(name);
+					validateHeaderValue(name, String(value));
+					return true;
+				} catch {
+					return false;
+				}
+			})
 
 	);
 }
diff --git a/src/index.js b/src/index.js
index 95f4283..c6cdff0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,48 +10,44 @@ import http from 'http';
 import https from 'https';
 import zlib from 'zlib';
 import Stream, {PassThrough, pipeline as pump} from 'stream';
-import dataURIToBuffer from 'data-uri-to-buffer';
+import dataUriToBuffer from 'data-uri-to-buffer';
 
-import {writeToStream, getTotalBytes} from './body.js';
+import {writeToStream} from './body.js';
 import Response from './response.js';
 import Headers, {fromRawHeaders} from './headers.js';
 import Request, {getNodeRequestOptions} from './request.js';
-import FetchError from './errors/fetch-error.js';
-import AbortError from './errors/abort-error.js';
+import {FetchError} from './errors/fetch-error.js';
+import {AbortError} from './errors/abort-error.js';
 import {isRedirect} from './utils/is-redirect.js';
 
 export {Headers, Request, Response, FetchError, AbortError, isRedirect};
 
+const supportedSchemas = new Set(['data:', 'http:', 'https:']);
+
 /**
  * Fetch function
  *
- * @param   Mixed    url   Absolute url or Request instance
- * @param   Object   opts  Fetch options
- * @return  Promise
+ * @param   {string | URL | import('./request').default} url - Absolute url or Request instance
+ * @param   {*} [options_] - Fetch options
+ * @return  {Promise<import('./response').default>}
  */
 export default async function fetch(url, options_) {
-	// Regex for data uri
-	const dataUriRegex = /^\s*data:([a-z]+\/[a-z]+(;[a-z-]+=[a-z-]+)?)?(;base64)?,[\w!$&',()*+;=\-.~:@/?%\s]*\s*$/i;
-
-	// If valid data uri
-	if (dataUriRegex.test(url)) {
-		const data = dataURIToBuffer(url);
-		const response = new Response(data, {headers: {'Content-Type': data.type}});
-		return response;
-	}
-
-	// If invalid data uri
-	if (url.toString().startsWith('data:')) {
-		const request = new Request(url, options_);
-		throw new FetchError(`[${request.method}] ${request.url} invalid URL`, 'system');
-	}
-
-	// Wrap http.request into fetch
 	return new Promise((resolve, reject) => {
 		// Build request object
 		const request = new Request(url, options_);
 		const options = getNodeRequestOptions(request);
+		if (!supportedSchemas.has(options.protocol)) {
+			throw new TypeError(`node-fetch cannot load ${url}. URL scheme "${options.protocol.replace(/:$/, '')}" is not supported.`);
+		}
+
+		if (options.protocol === 'data:') {
+			const data = dataUriToBuffer(request.url);
+			const response = new Response(data, {headers: {'Content-Type': data.typeFull}});
+			resolve(response);
+			return;
+		}
 
+		// Wrap http.request into fetch
 		const send = (options.protocol === 'https:' ? https : http).request;
 		const {signal} = request;
 		let response = null;
@@ -157,7 +153,7 @@ export default async function fetch(url, options_) {
 						};
 
 						// HTTP-redirect fetch step 9
-						if (response_.statusCode !== 303 && request.body && getTotalBytes(request) === null) {
+						if (response_.statusCode !== 303 && request.body && options_.body instanceof Stream.Readable) {
 							reject(new FetchError('Cannot follow redirect with body being a readable stream', 'unsupported-redirect'));
 							finalize();
 							return;
@@ -191,6 +187,10 @@ export default async function fetch(url, options_) {
 			let body = pump(response_, new PassThrough(), error => {
 				reject(error);
 			});
+			// see https://github.com/nodejs/node/pull/29376
+			if (process.version < 'v12.10') {
+				response_.on('aborted', abortAndFinalize);
+			}
 
 			const responseOptions = {
 				url: request.url,
@@ -282,4 +282,3 @@ export default async function fetch(url, options_) {
 		writeToStream(request_, request);
 	});
 }
-
diff --git a/src/request.js b/src/request.js
index 67fb393..bb0df34 100644
--- a/src/request.js
+++ b/src/request.js
@@ -28,26 +28,6 @@ const isRequest = object => {
 	);
 };
 
-/**
- * Wrapper around `new URL` to handle relative URLs (https://github.com/nodejs/node/issues/12682)
- *
- * @param  {string} urlStr
- * @return {void}
- */
-const parseURL = urlString => {
-	/*
-		Check whether the URL is absolute or not
-
-		Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
-		Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
-	*/
-	if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.exec(urlString)) {
-		return new URL(urlString);
-	}
-
-	throw new TypeError('Only absolute URLs are supported');
-};
-
 /**
  * Request class
  *
@@ -59,20 +39,11 @@ export default class Request extends Body {
 	constructor(input, init = {}) {
 		let parsedURL;
 
-		// Normalize input and force URL to be encoded as UTF-8 (https://github.com/bitinn/node-fetch/issues/245)
+		// Normalize input and force URL to be encoded as UTF-8 (https://github.com/node-fetch/node-fetch/issues/245)
 		if (isRequest(input)) {
-			parsedURL = parseURL(input.url);
+			parsedURL = new URL(input.url);
 		} else {
-			if (input && input.href) {
-				// In order to support Node.js' Url objects; though WHATWG's URL objects
-				// will fall into this branch also (since their `toString()` will return
-				// `href` property anyway)
-				parsedURL = parseURL(input.href);
-			} else {
-				// Coerce input to a string before attempting to parse
-				parsedURL = parseURL(`${input}`);
-			}
-
+			parsedURL = new URL(input);
 			input = {};
 		}
 
@@ -129,6 +100,7 @@ export default class Request extends Body {
 		this.counter = init.counter || input.counter || 0;
 		this.agent = init.agent || input.agent;
 		this.highWaterMark = init.highWaterMark || input.highWaterMark || 16384;
+		this.insecureHTTPParser = init.insecureHTTPParser || input.insecureHTTPParser || false;
 	}
 
 	get method() {
@@ -189,10 +161,6 @@ export const getNodeRequestOptions = request => {
 		headers.set('Accept', '*/*');
 	}
 
-	if (!/^https?:$/.test(parsedURL.protocol)) {
-		throw new TypeError('Only HTTP(S) protocols are supported');
-	}
-
 	// HTTP-network-or-cache fetch steps 2.4-2.7
 	let contentLengthValue = null;
 	if (request.body === null && /^(post|put)$/i.test(request.method)) {
@@ -247,6 +215,7 @@ export const getNodeRequestOptions = request => {
 		href: parsedURL.href,
 		method: request.method,
 		headers: headers[Symbol.for('nodejs.util.inspect.custom')](),
+		insecureHTTPParser: request.insecureHTTPParser,
 		agent
 	};
 
diff --git a/src/utils/is.js b/src/utils/is.js
index b3bbbf4..ea74a00 100644
--- a/src/utils/is.js
+++ b/src/utils/is.js
@@ -57,12 +57,3 @@ export const isAbortSignal = object => {
 	);
 };
 
-/**
- * Check if `obj` is an instance of AbortError.
- *
- * @param  {*} obj
- * @return {boolean}
- */
-export const isAbortError = object => {
-	return object[NAME] === 'AbortError';
-};


Test patch:
diff --git a/test/external-encoding.js b/test/external-encoding.js
index 3bfd1d8..caf0ffd 100644
--- a/test/external-encoding.js
+++ b/test/external-encoding.js
@@ -19,7 +19,7 @@ describe('external encoding', () => {
 		it('should accept data uri of plain text', () => {
 			return fetch('data:,Hello%20World!').then(r => {
 				expect(r.status).to.equal(200);
-				expect(r.headers.get('Content-Type')).to.equal('text/plain');
+				expect(r.headers.get('Content-Type')).to.equal('text/plain;charset=US-ASCII');
 				return r.text().then(t => expect(t).to.equal('Hello World!'));
 			});
 		});
@@ -27,7 +27,7 @@ describe('external encoding', () => {
 		it('should reject invalid data uri', () => {
 			return fetch('data:@@@@').catch(error => {
 				expect(error).to.exist;
-				expect(error.message).to.include('invalid URL');
+				expect(error.message).to.include('malformed data: URI');
 			});
 		});
 	});
diff --git a/test/main.js b/test/main.js
index b1500b8..e013c52 100644
--- a/test/main.js
+++ b/test/main.js
@@ -1,4 +1,5 @@
 // Test tools
+/* eslint-disable node/no-unsupported-features/node-builtins */
 import zlib from 'zlib';
 import crypto from 'crypto';
 import http from 'http';
@@ -10,7 +11,6 @@ import chai from 'chai';
 import chaiPromised from 'chai-as-promised';
 import chaiIterator from 'chai-iterator';
 import chaiString from 'chai-string';
-import resumer from 'resumer';
 import FormData from 'form-data';
 import stringToArrayBuffer from 'string-to-arraybuffer';
 import delay from 'delay';
@@ -28,7 +28,7 @@ import fetch, {
 	Request,
 	Response
 } from '../src/index.js';
-import FetchErrorOrig from '../src/errors/fetch-error.js';
+import {FetchError as FetchErrorOrig} from '../src/errors/fetch-error.js';
 import HeadersOrig, {fromRawHeaders} from '../src/headers.js';
 import RequestOrig from '../src/request.js';
 import ResponseOrig from '../src/response.js';
@@ -95,17 +95,17 @@ describe('node-fetch', () => {
 
 	it('should reject with error if url is protocol relative', () => {
 		const url = '//example.com/';
-		return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, 'Only absolute URLs are supported');
+		return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, /Invalid URL/);
 	});
 
 	it('should reject with error if url is relative path', () => {
 		const url = '/some/path';
-		return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, 'Only absolute URLs are supported');
+		return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, /Invalid URL/);
 	});
 
 	it('should reject with error if protocol is unsupported', () => {
 		const url = 'ftp://example.com/';
-		return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, 'Only HTTP(S) protocols are supported');
+		return expect(fetch(url)).to.eventually.be.rejectedWith(TypeError, /URL scheme "ftp" is not supported/);
 	});
 
 	itIf(process.platform !== 'win32')('should reject with error on network failure', () => {
@@ -404,7 +404,7 @@ describe('node-fetch', () => {
 		const url = `${base}redirect/307`;
 		const options = {
 			method: 'PATCH',
-			body: resumer().queue('a=1').end()
+			body: stream.Readable.from('tada')
 		};
 		return expect(fetch(url, options)).to.eventually.be.rejected
 			.and.be.an.instanceOf(FetchError)
@@ -582,7 +582,7 @@ describe('node-fetch', () => {
 			expect(res.status).to.equal(200);
 			expect(res.ok).to.be.true;
 			return expect(res.text()).to.eventually.be.rejectedWith(Error)
-				.and.have.property('message').includes('Premature close');
+				.and.have.property('message').matches(/Premature close|The operation was aborted/);
 		});
 	});
 
@@ -590,7 +590,7 @@ describe('node-fetch', () => {
 		const url = 'http://domain.invalid';
 		return expect(fetch(url)).to.eventually.be.rejected
 			.and.be.an.instanceOf(FetchError)
-			.and.have.property('code', 'ENOTFOUND');
+			.and.have.property('code').that.matches(/ENOTFOUND|EAI_AGAIN/);
 	});
 
 	it('should reject invalid json response', () => {
@@ -1250,13 +1250,10 @@ describe('node-fetch', () => {
 	});
 
 	it('should allow POST request with readable stream as body', () => {
-		let body = resumer().queue('a=1').end();
-		body = body.pipe(new stream.PassThrough());
-
 		const url = `${base}inspect`;
 		const options = {
 			method: 'POST',
-			body
+			body: stream.Readable.from('a=1')
 		};
 		return fetch(url, options).then(res => {
 			return res.json();
@@ -1971,27 +1968,16 @@ describe('node-fetch', () => {
 
 	// Issue #414
 	it('should reject if attempt to accumulate body stream throws', () => {
-		let body = resumer().queue('a=1').end();
-		body = body.pipe(new stream.PassThrough());
-		const res = new Response(body);
-		const bufferConcat = Buffer.concat;
-		const restoreBufferConcat = () => {
-			Buffer.concat = bufferConcat;
-		};
-
-		Buffer.concat = () => {
-			throw new Error('embedded error');
-		};
+		const res = new Response(stream.Readable.from((async function * () {
+			yield Buffer.from('tada');
+			await new Promise(resolve => setTimeout(resolve, 200));
+			yield {tada: 'yes'};
+		})()));
 
-		const textPromise = res.text();
-		// Ensure that `Buffer.concat` is always restored:
-		textPromise.then(restoreBufferConcat, restoreBufferConcat);
-
-		return expect(textPromise).to.eventually.be.rejected
+		return expect(res.text()).to.eventually.be.rejected
 			.and.be.an.instanceOf(FetchError)
 			.and.include({type: 'system'})
-			.and.have.property('message').that.includes('Could not create Buffer')
-			.and.that.includes('embedded error');
+			.and.have.property('message').that.include('Could not create Buffer');
 	});
 
 	it('supports supplying a lookup function to the agent', () => {
@@ -2053,8 +2039,7 @@ describe('node-fetch', () => {
 		const url = `${base}hello`;
 		const bodyContent = 'a=1';
 
-		let streamBody = resumer().queue(bodyContent).end();
-		streamBody = streamBody.pipe(new stream.PassThrough());
+		const streamBody = stream.Readable.from(bodyContent);
 		const streamRequest = new Request(url, {
 			method: 'POST',
 			body: streamBody,
@@ -2132,6 +2117,15 @@ describe('node-fetch', () => {
 			});
 		});
 
+		it('should accept data uri 2', async () => {
+			const r = await fetch('data:text/plain;charset=UTF-8;page=21,the%20data:1234,5678');
+			expect(r.status).to.equal(200);
+			expect(r.headers.get('Content-Type')).to.equal('text/plain;charset=UTF-8;page=21');
+
+			const b = await r.text();
+			expect(b).to.equal('the data:1234,5678');
+		});
+
 		it('should reject invalid data uri', () => {
 			return fetch(invalidDataUrl).catch(error => {
 				console.assert(error);
diff --git a/test/request.js b/test/request.js
index 9b38d5d..502b86a 100644
--- a/test/request.js
+++ b/test/request.js
@@ -1,16 +1,16 @@
+/* eslint-disable node/no-unsupported-features/node-builtins */
+
 import stream from 'stream';
 import http from 'http';
-import polyfill from 'abortcontroller-polyfill/dist/abortcontroller.js';
+import AbortController from 'abort-controller';
 import chai from 'chai';
 import FormData from 'form-data';
 import Blob from 'fetch-blob';
-import resumer from 'resumer';
 import stringToArrayBuffer from 'string-to-arraybuffer';
 
 import TestServer from './utils/server.js';
 import {Request} from '../src/index.js';
 
-const {AbortController} = polyfill;
 const {expect} = chai;
 
 const local = new TestServer();
@@ -201,8 +201,7 @@ describe('Request', () => {
 
 	it('should support clone() method', () => {
 		const url = base;
-		let body = resumer().queue('a=1').end();
-		body = body.pipe(new stream.PassThrough());
+		const body = stream.Readable.from('a=1');
 		const agent = new http.Agent();
 		const {signal} = new AbortController();
 		const request = new Request(url, {
diff --git a/test/response.js b/test/response.js
index 1e523b8..7126eb9 100644
--- a/test/response.js
+++ b/test/response.js
@@ -1,6 +1,7 @@
+/* eslint-disable node/no-unsupported-features/node-builtins */
+
 import * as stream from 'stream';
 import chai from 'chai';
-import resumer from 'resumer';
 import stringToArrayBuffer from 'string-to-arraybuffer';
 import Blob from 'fetch-blob';
 import {Response} from '../src/index.js';
@@ -54,9 +55,7 @@ describe('Response', () => {
 	});
 
 	it('should support empty options', () => {
-		let body = resumer().queue('a=1').end();
-		body = body.pipe(new stream.PassThrough());
-		const res = new Response(body);
+		const res = new Response(stream.Readable.from('a=1'));
 		return res.text().then(result => {
 			expect(result).to.equal('a=1');
 		});
@@ -107,8 +106,7 @@ describe('Response', () => {
 	});
 
 	it('should support clone() method', () => {
-		let body = resumer().queue('a=1').end();
-		body = body.pipe(new stream.PassThrough());
+		const body = stream.Readable.from('a=1');
 		const res = new Response(body, {
 			headers: {
 				a: '1'
@@ -131,8 +129,7 @@ describe('Response', () => {
 	});
 
 	it('should support stream as body', () => {
-		let body = resumer().queue('a=1').end();
-		body = body.pipe(new stream.PassThrough());
+		const body = stream.Readable.from('a=1');
 		const res = new Response(body);
 		return res.text().then(result => {
 			expect(result).to.equal('a=1');