Repository: node-fetch/node-fetch
Pull request #1169 (2021-05-25 00:11:55)

Add typing for Response.redirect(url, status)
**What is the purpose of this pull request?**

- [x] Documentation update

**What changes did you make?**

In https://github.com/node-fetch/node-fetch/pull/1078, the static `Response.redirect(url, status)` was added to the project but without typings which can be confusing for users. This pull request provides the typings for the method.


tkrotoff: btw #1078 did not add Response.redirect() ;-)

Patch:
diff --git a/@types/index.d.ts b/@types/index.d.ts
index 9854261..c7207c4 100644
--- a/@types/index.d.ts
+++ b/@types/index.d.ts
@@ -71,6 +71,14 @@ export interface RequestInit {
 	 * An AbortSignal to set request's signal.
 	 */
 	signal?: AbortSignal | null;
+	/**
+	 * A string whose value is a same-origin URL, "about:client", or the empty string, to set request’s referrer.
+	 */
+	referrer?: string;
+	/**
+	 * A referrer policy to set request’s referrerPolicy.
+	 */
+	referrerPolicy?: ReferrerPolicy;
 
 	// Node-fetch extensions to the whatwg/fetch spec
 	agent?: Agent | ((parsedUrl: URL) => Agent);
@@ -104,6 +112,9 @@ declare class BodyMixin {
 	readonly bodyUsed: boolean;
 	readonly size: number;
 
+	/**
+ 	* @deprecated Please use 'response.arrayBuffer()' instead of 'response.buffer()
+ 	*/
 	buffer(): Promise<Buffer>;
 	arrayBuffer(): Promise<ArrayBuffer>;
 	blob(): Promise<Blob>;
@@ -115,6 +126,7 @@ declare class BodyMixin {
 export interface Body extends Pick<BodyMixin, keyof BodyMixin> {}
 
 export type RequestRedirect = 'error' | 'follow' | 'manual';
+export type ReferrerPolicy = '' | 'no-referrer' | 'no-referrer-when-downgrade' | 'same-origin' | 'origin' | 'strict-origin' | 'origin-when-cross-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
 export type RequestInfo = string | Request;
 export class Request extends BodyMixin {
 	constructor(input: RequestInfo, init?: RequestInit);
@@ -139,6 +151,14 @@ export class Request extends BodyMixin {
 	 * Returns the URL of request as a string.
 	 */
 	readonly url: string;
+	/**
+	 * A string whose value is a same-origin URL, "about:client", or the empty string, to set request’s referrer.
+	 */
+	readonly referrer: string;
+	/**
+	 * A referrer policy to set request’s referrerPolicy.
+	 */
+	readonly referrerPolicy: ReferrerPolicy;
 	clone(): Request;
 }
 
@@ -157,6 +177,7 @@ export class Response extends BodyMixin {
 	clone(): Response;
 
 	static error(): Response;
+	static redirect(url: string, status?: number): Response;
 }
 
 export class FetchError extends Error {
diff --git a/README.md b/README.md
index 08dfbf8..2c1198f 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@
 
 ---
 
+**You might be looking for the [v2 docs](https://github.com/node-fetch/node-fetch/tree/2.x#readme)**
+
 <!-- TOC -->
 
 - [Motivation](#motivation)
@@ -124,6 +126,15 @@ if (!globalThis.fetch) {
 }
 ```
 
+`node-fetch` is an ESM-only module - you are not able to import it with `require`. We recommend you stay on v2 which is built with CommonJS unless you use ESM yourself. We will continue to publish critical bug fixes for it.
+
+Alternatively, you can use the async `import()` function from CommonJS to load `node-fetch` asynchronously:
+
+```js
+// mod.cjs
+const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
+```
+
 ## Upgrading
 
 Using an old version of node-fetch? Check out the following files:
@@ -229,8 +240,8 @@ import fetch from 'node-fetch';
 
 class HTTPResponseError extends Error {
 	constructor(response, ...args) {
-		this.response = response;
 		super(`HTTP Error Response: ${response.status} ${response.statusText}`, ...args);
+		this.response = response;
 	}
 }
 
@@ -273,7 +284,7 @@ import fetch from 'node-fetch';
 
 const streamPipeline = promisify(pipeline);
 
-const response = await fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png');
+const response = await fetch('https://github.githubassets.com/images/modules/logos_page/Octocat.png');
 
 if (!response.ok) throw new Error(`unexpected response ${response.statusText}`);
 
@@ -443,7 +454,7 @@ See [test cases](https://github.com/node-fetch/node-fetch/blob/master/test/) for
 
 Perform an HTTP(S) fetch.
 
-`url` should be an absolute url, such as `https://example.com/`. A path-relative URL (`/file/under/root`) or protocol-relative URL (`//can-be-http-or-https.com/`) will result in a rejected `Promise`.
+`url` should be an absolute URL, such as `https://example.com/`. A path-relative URL (`/file/under/root`) or protocol-relative URL (`//can-be-http-or-https.com/`) will result in a rejected `Promise`.
 
 <a id="fetch-options"></a>
 
@@ -480,6 +491,7 @@ If no values are set, the following request headers will be sent automatically:
 | `Accept`            | `*/*`                                                  |
 | `Connection`        | `close` _(when no `options.agent` is present)_         |
 | `Content-Length`    | _(automatically calculated, if possible)_              |
+| `Host`              | _(host and port information from the target URI)_      |
 | `Transfer-Encoding` | `chunked` _(when `req.body` is a stream)_              |
 | `User-Agent`        | `node-fetch`                                           |
 
@@ -569,8 +581,6 @@ Due to the nature of Node.js, the following properties are not implemented at th
 
 - `type`
 - `destination`
-- `referrer`
-- `referrerPolicy`
 - `mode`
 - `credentials`
 - `cache`
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 10d3892..781801b 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,11 +1,24 @@
-Changelog
-=========
+# Changelog
+All notable changes will be recorded here.
 
-# 3.x release
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## unreleased
+
+- other: Deprecated/Discourage [form-data](https://www.npmjs.com/package/form-data) and `body.buffer()` (#1212)
+- fix: Normalize `Body.body` into a `node:stream` (#924)
+- fix: Pass url string to `http.request` for parsing IPv6 urls (#1268)
+- fix: Throw error when constructing Request with urls including basic auth (#1268)
+
+## v3.0.0
+
+- other: Marking v3 as stable
+- docs: Add example for loading ESM from CommonJS (#1236)
 
 ## v3.0.0-beta.10
 
-- **Breaking:** minimum supported Node.js version is now 12.8.
+- **Breaking:** minimum supported Node.js version is now 12.20.
 - **Breaking:** node-fetch is now a pure ESM module.
 - Other: update readme to inform users about ESM.
 - Other: update dependencies.
@@ -374,3 +387,5 @@ See [changelog on 1.x branch](https://github.com/node-fetch/node-fetch/blob/1.x/
 ## v0.1
 
 - Major: initial public release
+
+[Unreleased]: https://github.com/node-fetch/node-fetch/compare/v3.0.0-beta.10...HEAD
diff --git a/docs/v3-UPGRADE-GUIDE.md b/docs/v3-UPGRADE-GUIDE.md
index 70120ad..4e9eada 100644
--- a/docs/v3-UPGRADE-GUIDE.md
+++ b/docs/v3-UPGRADE-GUIDE.md
@@ -19,17 +19,29 @@ other comparatively minor modifications.
 
 # Breaking Changes
 
-## Minimum supported Node.js version is now 10.16
+## Minimum supported Node.js version is now 12.20
 
-Since Node.js will deprecate version 8 at the end of 2019, we decided that node-fetch v3.x will not only drop support for Node.js 4 and 6 (which were supported in v2.x), but also for Node.js 8. We strongly encourage you to upgrade, if you still haven't done so. Check out Node.js' official [LTS plan] for more information on Node.js' support lifetime.
+Since Node.js 10 has been deprecated since May 2020, we have decided that node-fetch v3 will drop support for Node.js 4, 6, 8, and 10 (which were previously supported). We strongly encourage you to upgrade if you still haven't done so. Check out the Node.js official [LTS plan] for more information.
+
+## Converted to ES Module
+
+This module was converted to be a ESM only package in version `3.0.0-beta.10`.
+`node-fetch` is an ESM-only module - you are not able to import it with `require`. We recommend you stay on v2 which is built with CommonJS unless you use ESM yourself. We will continue to publish critical bug fixes for it.
+
+Alternatively, you can use the async `import()` function from CommonJS to load `node-fetch` asynchronously:
+
+```js
+// mod.cjs
+const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
+```
 
 ## The `timeout` option was removed.
 
-Since this was never part of the fetch specification, it was removed. AbortSignal offers a more finegrained control of request timeouts, and is standardized in the Fetch spec. For convenience, you can use [timeout-signal](https://github.com/Richienb/timeout-signal) as a workaround:
+Since this was never part of the fetch specification, it was removed. AbortSignal offers more fine grained control of request timeouts, and is standardized in the Fetch spec. For convenience, you can use [timeout-signal](https://github.com/node-fetch/timeout-signal) as a workaround:
 
 ```js
-const timeoutSignal = require('timeout-signal');
-const fetch = require('node-fetch');
+import timeoutSignal from 'timeout-signal';
+import fetch from 'node-fetch';
 
 const {AbortError} = fetch
 
@@ -57,11 +69,12 @@ Prior to v3.x, we included a `browser` field in the package.json file. Since nod
 If you want charset encoding detection, please use the [fetch-charset-detection] package ([documentation][fetch-charset-detection-docs]).
 
 ```js
-const fetch = require('node-fetch');
-const convertBody = require('fetch-charset-detection');
+import fetch from 'node-fetch';
+import convertBody from 'fetch-charset-detection';
 
-fetch('https://somewebsite.com').then(res => {
-	const text = convertBody(res.buffer(), res.headers);
+fetch('https://somewebsite.com').then(async res => {
+    const buf = await res.arrayBuffer();
+    const text = convertBody(buf, res.headers);
 });
 ```
 
@@ -70,7 +83,7 @@ fetch('https://somewebsite.com').then(res => {
 When attempting to parse invalid json via `res.json()`, a `SyntaxError` will now be thrown instead of a `FetchError` to align better with the spec.
 
 ```js
-const fetch = require('node-fetch');
+import fetch from 'node-fetch';
 
 fetch('https://somewebsitereturninginvalidjson.com').then(res => res.json())
 // Throws 'Uncaught SyntaxError: Unexpected end of JSON input' or similar.
diff --git a/package.json b/package.json
index 4ea91fa..189b181 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "node-fetch",
-  "version": "3.0.0-beta.10",
+  "version": "3.0.0",
   "description": "A light-weight module that brings Fetch API to node.js",
   "main": "./src/index.js",
   "sideEffects": false,
@@ -56,13 +56,13 @@
     "delay": "^5.0.0",
     "form-data": "^4.0.0",
     "formdata-node": "^3.5.4",
-    "mocha": "^8.3.2",
+    "mocha": "^9.1.3",
     "p-timeout": "^5.0.0",
     "tsd": "^0.14.0",
     "xo": "^0.39.1"
   },
   "dependencies": {
-    "data-uri-to-buffer": "^3.0.1",
+    "data-uri-to-buffer": "^4.0.0",
     "fetch-blob": "^3.1.2"
   },
   "tsd": {
diff --git a/src/body.js b/src/body.js
index c923a8c..83357f6 100644
--- a/src/body.js
+++ b/src/body.js
@@ -5,8 +5,8 @@
  * Body interface provides common methods for Request and Response
  */
 
-import Stream, {PassThrough} from 'stream';
-import {types} from 'util';
+import Stream, {PassThrough} from 'node:stream';
+import {types, deprecate} from 'node:util';
 
 import Blob from 'fetch-blob';
 
@@ -36,7 +36,7 @@ export default class Body {
 			// Body is undefined or null
 			body = null;
 		} else if (isURLSearchParameters(body)) {
-		// Body is a URLSearchParams
+			// Body is a URLSearchParams
 			body = Buffer.from(body.toString());
 		} else if (isBlob(body)) {
 			// Body is blob
@@ -52,7 +52,7 @@ export default class Body {
 			// Body is stream
 		} else if (isFormData(body)) {
 			// Body is an instance of formdata-node
-			boundary = `NodeFetchFormDataBoundary${getBoundary()}`;
+			boundary = `nodefetchformdataboundary${getBoundary()}`;
 			body = Stream.Readable.from(formDataIterator(body, boundary));
 		} else {
 			// None of the above
@@ -60,8 +60,17 @@ export default class Body {
 			body = Buffer.from(String(body));
 		}
 
+		let stream = body;
+
+		if (Buffer.isBuffer(body)) {
+			stream = Stream.Readable.from(body);
+		} else if (isBlob(body)) {
+			stream = Stream.Readable.from(body.stream());
+		}
+
 		this[INTERNALS] = {
 			body,
+			stream,
 			boundary,
 			disturbed: false,
 			error: null
@@ -79,7 +88,7 @@ export default class Body {
 	}
 
 	get body() {
-		return this[INTERNALS].body;
+		return this[INTERNALS].stream;
 	}
 
 	get bodyUsed() {
@@ -140,6 +149,8 @@ export default class Body {
 	}
 }
 
+Body.prototype.buffer = deprecate(Body.prototype.buffer, 'Please use \'response.arrayBuffer()\' instead of \'response.buffer()\'', 'node-fetch#buffer');
+
 // In browsers, all properties are enumerable.
 Object.defineProperties(Body.prototype, {
 	body: {enumerable: true},
@@ -168,23 +179,13 @@ async function consumeBody(data) {
 		throw data[INTERNALS].error;
 	}
 
-	let {body} = data;
+	const {body} = data;
 
 	// Body is null
 	if (body === null) {
 		return Buffer.alloc(0);
 	}
 
-	// Body is blob
-	if (isBlob(body)) {
-		body = Stream.Readable.from(body.stream());
-	}
-
-	// Body is buffer
-	if (Buffer.isBuffer(body)) {
-		return body;
-	}
-
 	/* c8 ignore next 3 */
 	if (!(body instanceof Stream)) {
 		return Buffer.alloc(0);
@@ -236,7 +237,7 @@ async function consumeBody(data) {
 export const clone = (instance, highWaterMark) => {
 	let p1;
 	let p2;
-	let {body} = instance;
+	let {body} = instance[INTERNALS];
 
 	// Don't allow cloning a used body
 	if (instance.bodyUsed) {
@@ -252,13 +253,19 @@ export const clone = (instance, highWaterMark) => {
 		body.pipe(p1);
 		body.pipe(p2);
 		// Set instance body to teed body and return the other teed body
-		instance[INTERNALS].body = p1;
+		instance[INTERNALS].stream = p1;
 		body = p2;
 	}
 
 	return body;
 };
 
+const getNonSpecFormDataBoundary = deprecate(
+	body => body.getBoundary(),
+	'form-data doesn\'t follow the spec and requires special treatment. Use alternative package',
+	'https://github.com/node-fetch/node-fetch/issues/1167'
+);
+
 /**
  * Performs the operation "extract a `Content-Type` value from |object|" as
  * specified in the specification:
@@ -295,15 +302,15 @@ export const extractContentType = (body, request) => {
 		return null;
 	}
 
-	// Detect form data input from form-data module
-	if (body && typeof body.getBoundary === 'function') {
-		return `multipart/form-data;boundary=${body.getBoundary()}`;
-	}
-
 	if (isFormData(body)) {
 		return `multipart/form-data; boundary=${request[INTERNALS].boundary}`;
 	}
 
+	// Detect form data input from form-data module
+	if (body && typeof body.getBoundary === 'function') {
+		return `multipart/form-data;boundary=${getNonSpecFormDataBoundary(body)}`;
+	}
+
 	// Body is stream - can't really do much about this
 	if (body instanceof Stream) {
 		return null;
@@ -323,7 +330,7 @@ export const extractContentType = (body, request) => {
  * @returns {number | null}
  */
 export const getTotalBytes = request => {
-	const {body} = request;
+	const {body} = request[INTERNALS];
 
 	// Body is null or undefined
 	if (body === null) {
@@ -345,7 +352,7 @@ export const getTotalBytes = request => {
 		return body.hasKnownLength && body.hasKnownLength() ? body.getLengthSync() : null;
 	}
 
-	// Body is a spec-compliant form-data
+	// Body is a spec-compliant FormData
 	if (isFormData(body)) {
 		return getFormDataLength(request[INTERNALS].boundary);
 	}
@@ -365,13 +372,6 @@ export const writeToStream = (dest, {body}) => {
 	if (body === null) {
 		// Body is null
 		dest.end();
-	} else if (isBlob(body)) {
-		// Body is Blob
-		Stream.Readable.from(body.stream()).pipe(dest);
-	} else if (Buffer.isBuffer(body)) {
-		// Body is buffer
-		dest.write(body);
-		dest.end();
 	} else {
 		// Body is stream
 		body.pipe(dest);
diff --git a/src/headers.js b/src/headers.js
index 694d22c..66ea303 100644
--- a/src/headers.js
+++ b/src/headers.js
@@ -4,8 +4,8 @@
  * Headers class offers convenient helpers
  */
 
-import {types} from 'util';
-import http from 'http';
+import {types} from 'node:util';
+import http from 'node:http';
 
 const validateHeaderName = typeof http.validateHeaderName === 'function' ?
 	http.validateHeaderName :
diff --git a/src/index.js b/src/index.js
index 2cad269..f8686be 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,19 +6,20 @@
  * All spec algorithm step numbers are based on https://fetch.spec.whatwg.org/commit-snapshots/ae716822cb3a61843226cd090eefc6589446c1d2/.
  */
 
-import http from 'http';
-import https from 'https';
-import zlib from 'zlib';
-import Stream, {PassThrough, pipeline as pump} from 'stream';
+import http from 'node:http';
+import https from 'node:https';
+import zlib from 'node:zlib';
+import Stream, {PassThrough, pipeline as pump} from 'node:stream';
 import dataUriToBuffer from 'data-uri-to-buffer';
 
-import {writeToStream} from './body.js';
+import {writeToStream, clone} 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 {isRedirect} from './utils/is-redirect.js';
+import {parseReferrerPolicyFromHeader} from './utils/referrer.js';
 
 export {Headers, Request, Response, FetchError, AbortError, isRedirect};
 
@@ -35,12 +36,12 @@ export default async function fetch(url, options_) {
 	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.`);
+		const {parsedURL, options} = getNodeRequestOptions(request);
+		if (!supportedSchemas.has(parsedURL.protocol)) {
+			throw new TypeError(`node-fetch cannot load ${url}. URL scheme "${parsedURL.protocol.replace(/:$/, '')}" is not supported.`);
 		}
 
-		if (options.protocol === 'data:') {
+		if (parsedURL.protocol === 'data:') {
 			const data = dataUriToBuffer(request.url);
 			const response = new Response(data, {headers: {'Content-Type': data.typeFull}});
 			resolve(response);
@@ -48,7 +49,7 @@ export default async function fetch(url, options_) {
 		}
 
 		// Wrap http.request into fetch
-		const send = (options.protocol === 'https:' ? https : http).request;
+		const send = (parsedURL.protocol === 'https:' ? https : http).request;
 		const {signal} = request;
 		let response = null;
 
@@ -77,7 +78,7 @@ export default async function fetch(url, options_) {
 		};
 
 		// Send request
-		const request_ = send(options);
+		const request_ = send(parsedURL, options);
 
 		if (signal) {
 			signal.addEventListener('abort', abortAndFinalize);
@@ -166,9 +167,11 @@ export default async function fetch(url, options_) {
 							agent: request.agent,
 							compress: request.compress,
 							method: request.method,
-							body: request.body,
+							body: clone(request),
 							signal: request.signal,
-							size: request.size
+							size: request.size,
+							referrer: request.referrer,
+							referrerPolicy: request.referrerPolicy
 						};
 
 						// HTTP-redirect fetch step 9
@@ -185,6 +188,12 @@ export default async function fetch(url, options_) {
 							requestOptions.headers.delete('content-length');
 						}
 
+						// HTTP-redirect fetch step 14
+						const responseReferrerPolicy = parseReferrerPolicyFromHeader(headers);
+						if (responseReferrerPolicy) {
+							requestOptions.referrerPolicy = responseReferrerPolicy;
+						}
+
 						// HTTP-redirect fetch step 15
 						resolve(fetch(new Request(locationURL, requestOptions)));
 						finalize();
@@ -287,29 +296,44 @@ export default async function fetch(url, options_) {
 }
 
 function fixResponseChunkedTransferBadEnding(request, errorCallback) {
-	const LAST_CHUNK = Buffer.from('0\r\n');
-	let socket;
+	const LAST_CHUNK = Buffer.from('0\r\n\r\n');
 
-	request.on('socket', s => {
-		socket = s;
-	});
+	let isChunkedTransfer = false;
+	let properLastChunkReceived = false;
+	let previousChunk;
 
 	request.on('response', response => {
 		const {headers} = response;
-		if (headers['transfer-encoding'] === 'chunked' && !headers['content-length']) {
-			let properLastChunkReceived = false;
+		isChunkedTransfer = headers['transfer-encoding'] === 'chunked' && !headers['content-length'];
+	});
 
-			socket.on('data', buf => {
-				properLastChunkReceived = Buffer.compare(buf.slice(-3), LAST_CHUNK) === 0;
-			});
+	request.on('socket', socket => {
+		const onSocketClose = () => {
+			if (isChunkedTransfer && !properLastChunkReceived) {
+				const error = new Error('Premature close');
+				error.code = 'ERR_STREAM_PREMATURE_CLOSE';
+				errorCallback(error);
+			}
+		};
 
-			socket.prependListener('close', () => {
-				if (!properLastChunkReceived) {
-					const error = new Error('Premature close');
-					error.code = 'ERR_STREAM_PREMATURE_CLOSE';
-					errorCallback(error);
-				}
-			});
-		}
+		socket.prependListener('close', onSocketClose);
+
+		request.on('abort', () => {
+			socket.removeListener('close', onSocketClose);
+		});
+
+		socket.on('data', buf => {
+			properLastChunkReceived = Buffer.compare(buf.slice(-5), LAST_CHUNK) === 0;
+
+			// Sometimes final 0-length chunk and end of message code are in separate packets
+			if (!properLastChunkReceived && previousChunk) {
+				properLastChunkReceived = (
+					Buffer.compare(previousChunk.slice(-3), LAST_CHUNK.slice(0, 3)) === 0 &&
+					Buffer.compare(buf.slice(-2), LAST_CHUNK.slice(3)) === 0
+				);
+			}
+
+			previousChunk = buf;
+		});
 	});
 }
diff --git a/src/request.js b/src/request.js
index ab92253..6d6272c 100644
--- a/src/request.js
+++ b/src/request.js
@@ -7,11 +7,14 @@
  * All spec algorithm step numbers are based on https://fetch.spec.whatwg.org/commit-snapshots/ae716822cb3a61843226cd090eefc6589446c1d2/.
  */
 
-import {format as formatUrl} from 'url';
+import {format as formatUrl} from 'node:url';
 import Headers from './headers.js';
 import Body, {clone, extractContentType, getTotalBytes} from './body.js';
 import {isAbortSignal} from './utils/is.js';
 import {getSearch} from './utils/get-search.js';
+import {
+	validateReferrerPolicy, determineRequestsReferrer, DEFAULT_REFERRER_POLICY
+} from './utils/referrer.js';
 
 const INTERNALS = Symbol('Request internals');
 
@@ -49,6 +52,10 @@ export default class Request extends Body {
 			input = {};
 		}
 
+		if (parsedURL.username !== '' || parsedURL.password !== '') {
+			throw new TypeError(`${parsedURL} is an url with embedded credentails.`);
+		}
+
 		let method = init.method || input.method || 'GET';
 		method = method.toUpperCase();
 
@@ -73,7 +80,7 @@ export default class Request extends Body {
 		if (inputBody !== null && !headers.has('Content-Type')) {
 			const contentType = extractContentType(inputBody, this);
 			if (contentType) {
-				headers.append('Content-Type', contentType);
+				headers.set('Content-Type', contentType);
 			}
 		}
 
@@ -89,12 +96,28 @@ export default class Request extends Body {
 			throw new TypeError('Expected signal to be an instanceof AbortSignal or EventTarget');
 		}
 
+		// §5.4, Request constructor steps, step 15.1
+		// eslint-disable-next-line no-eq-null, eqeqeq
+		let referrer = init.referrer == null ? input.referrer : init.referrer;
+		if (referrer === '') {
+			// §5.4, Request constructor steps, step 15.2
+			referrer = 'no-referrer';
+		} else if (referrer) {
+			// §5.4, Request constructor steps, step 15.3.1, 15.3.2
+			const parsedReferrer = new URL(referrer);
+			// §5.4, Request constructor steps, step 15.3.3, 15.3.4
+			referrer = /^about:(\/\/)?client$/.test(parsedReferrer) ? 'client' : parsedReferrer;
+		} else {
+			referrer = undefined;
+		}
+
 		this[INTERNALS] = {
 			method,
 			redirect: init.redirect || input.redirect || 'follow',
 			headers,
 			parsedURL,
-			signal
+			signal,
+			referrer
 		};
 
 		// Node-fetch-only options
@@ -104,6 +127,10 @@ export default class Request extends Body {
 		this.agent = init.agent || input.agent;
 		this.highWaterMark = init.highWaterMark || input.highWaterMark || 16384;
 		this.insecureHTTPParser = init.insecureHTTPParser || input.insecureHTTPParser || false;
+
+		// §5.4, Request constructor steps, step 16.
+		// Default is empty string per https://fetch.spec.whatwg.org/#concept-request-referrer-policy
+		this.referrerPolicy = init.referrerPolicy || input.referrerPolicy || '';
 	}
 
 	get method() {
@@ -126,6 +153,31 @@ export default class Request extends Body {
 		return this[INTERNALS].signal;
 	}
 
+	// https://fetch.spec.whatwg.org/#dom-request-referrer
+	get referrer() {
+		if (this[INTERNALS].referrer === 'no-referrer') {
+			return '';
+		}
+
+		if (this[INTERNALS].referrer === 'client') {
+			return 'about:client';
+		}
+
+		if (this[INTERNALS].referrer) {
+			return this[INTERNALS].referrer.toString();
+		}
+
+		return undefined;
+	}
+
+	get referrerPolicy() {
+		return this[INTERNALS].referrerPolicy;
+	}
+
+	set referrerPolicy(referrerPolicy) {
+		this[INTERNALS].referrerPolicy = validateReferrerPolicy(referrerPolicy);
+	}
+
 	/**
 	 * Clone this request
 	 *
@@ -146,7 +198,9 @@ Object.defineProperties(Request.prototype, {
 	headers: {enumerable: true},
 	redirect: {enumerable: true},
 	clone: {enumerable: true},
-	signal: {enumerable: true}
+	signal: {enumerable: true},
+	referrer: {enumerable: true},
+	referrerPolicy: {enumerable: true}
 });
 
 /**
@@ -182,6 +236,29 @@ export const getNodeRequestOptions = request => {
 		headers.set('Content-Length', contentLengthValue);
 	}
 
+	// 4.1. Main fetch, step 2.6
+	// > If request's referrer policy is the empty string, then set request's referrer policy to the
+	// > default referrer policy.
+	if (request.referrerPolicy === '') {
+		request.referrerPolicy = DEFAULT_REFERRER_POLICY;
+	}
+
+	// 4.1. Main fetch, step 2.7
+	// > If request's referrer is not "no-referrer", set request's referrer to the result of invoking
+	// > determine request's referrer.
+	if (request.referrer && request.referrer !== 'no-referrer') {
+		request[INTERNALS].referrer = determineRequestsReferrer(request);
+	} else {
+		request[INTERNALS].referrer = 'no-referrer';
+	}
+
+	// 4.5. HTTP-network-or-cache fetch, step 6.9
+	// > If httpRequest's referrer is a URL, then append `Referer`/httpRequest's referrer, serialized
+	// >  and isomorphic encoded, to httpRequest's header list.
+	if (request[INTERNALS].referrer instanceof URL) {
+		headers.set('Referer', request.referrer);
+	}
+
 	// HTTP-network-or-cache fetch step 2.11
 	if (!headers.has('User-Agent')) {
 		headers.set('User-Agent', 'node-fetch');
@@ -206,22 +283,20 @@ export const getNodeRequestOptions = request => {
 
 	const search = getSearch(parsedURL);
 
-	// Manually spread the URL object instead of spread syntax
-	const requestOptions = {
+	// Pass the full URL directly to request(), but overwrite the following
+	// options:
+	const options = {
+		// Overwrite search to retain trailing ? (issue #776)
 		path: parsedURL.pathname + search,
-		pathname: parsedURL.pathname,
-		hostname: parsedURL.hostname,
-		protocol: parsedURL.protocol,
-		port: parsedURL.port,
-		hash: parsedURL.hash,
-		search: parsedURL.search,
-		query: parsedURL.query,
-		href: parsedURL.href,
+		// The following options are not expressed in the URL
 		method: request.method,
 		headers: headers[Symbol.for('nodejs.util.inspect.custom')](),
 		insecureHTTPParser: request.insecureHTTPParser,
 		agent
 	};
 
-	return requestOptions;
+	return {
+		parsedURL,
+		options
+	};
 };
diff --git a/src/response.js b/src/response.js
index af820d1..eaba9a9 100644
--- a/src/response.js
+++ b/src/response.js
@@ -95,7 +95,8 @@ export default class Response extends Body {
 			headers: this.headers,
 			ok: this.ok,
 			redirected: this.redirected,
-			size: this.size
+			size: this.size,
+			highWaterMark: this.highWaterMark
 		});
 	}
 
diff --git a/src/utils/form-data.js b/src/utils/form-data.js
index 7b66a8a..ba0c14a 100644
--- a/src/utils/form-data.js
+++ b/src/utils/form-data.js
@@ -1,4 +1,4 @@
-import {randomBytes} from 'crypto';
+import {randomBytes} from 'node:crypto';
 
 import {isBlob} from './is.js';
 
diff --git a/src/utils/is.js b/src/utils/is.js
index fa8d159..d23b9f0 100644
--- a/src/utils/is.js
+++ b/src/utils/is.js
@@ -35,6 +35,7 @@ export const isURLSearchParameters = object => {
  */
 export const isBlob = object => {
 	return (
+		object &&
 		typeof object === 'object' &&
 		typeof object.arrayBuffer === 'function' &&
 		typeof object.type === 'string' &&
diff --git a/src/utils/referrer.js b/src/utils/referrer.js
new file mode 100644
index 0000000..f9b6817
--- /dev/null
+++ b/src/utils/referrer.js
@@ -0,0 +1,340 @@
+import {isIP} from 'net';
+
+/**
+ * @external URL
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL|URL}
+ */
+
+/**
+ * @module utils/referrer
+ * @private
+ */
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#strip-url|Referrer Policy §8.4. Strip url for use as a referrer}
+ * @param {string} URL
+ * @param {boolean} [originOnly=false]
+ */
+export function stripURLForUseAsAReferrer(url, originOnly = false) {
+	// 1. If url is null, return no referrer.
+	if (url == null) { // eslint-disable-line no-eq-null, eqeqeq
+		return 'no-referrer';
+	}
+
+	url = new URL(url);
+
+	// 2. If url's scheme is a local scheme, then return no referrer.
+	if (/^(about|blob|data):$/.test(url.protocol)) {
+		return 'no-referrer';
+	}
+
+	// 3. Set url's username to the empty string.
+	url.username = '';
+
+	// 4. Set url's password to null.
+	// Note: `null` appears to be a mistake as this actually results in the password being `"null"`.
+	url.password = '';
+
+	// 5. Set url's fragment to null.
+	// Note: `null` appears to be a mistake as this actually results in the fragment being `"#null"`.
+	url.hash = '';
+
+	// 6. If the origin-only flag is true, then:
+	if (originOnly) {
+		// 6.1. Set url's path to null.
+		// Note: `null` appears to be a mistake as this actually results in the path being `"/null"`.
+		url.pathname = '';
+
+		// 6.2. Set url's query to null.
+		// Note: `null` appears to be a mistake as this actually results in the query being `"?null"`.
+		url.search = '';
+	}
+
+	// 7. Return url.
+	return url;
+}
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#enumdef-referrerpolicy|enum ReferrerPolicy}
+ */
+export const ReferrerPolicy = new Set([
+	'',
+	'no-referrer',
+	'no-referrer-when-downgrade',
+	'same-origin',
+	'origin',
+	'strict-origin',
+	'origin-when-cross-origin',
+	'strict-origin-when-cross-origin',
+	'unsafe-url'
+]);
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#default-referrer-policy|default referrer policy}
+ */
+export const DEFAULT_REFERRER_POLICY = 'strict-origin-when-cross-origin';
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#referrer-policies|Referrer Policy §3. Referrer Policies}
+ * @param {string} referrerPolicy
+ * @returns {string} referrerPolicy
+ */
+export function validateReferrerPolicy(referrerPolicy) {
+	if (!ReferrerPolicy.has(referrerPolicy)) {
+		throw new TypeError(`Invalid referrerPolicy: ${referrerPolicy}`);
+	}
+
+	return referrerPolicy;
+}
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy|Referrer Policy §3.2. Is origin potentially trustworthy?}
+ * @param {external:URL} url
+ * @returns `true`: "Potentially Trustworthy", `false`: "Not Trustworthy"
+ */
+export function isOriginPotentiallyTrustworthy(url) {
+	// 1. If origin is an opaque origin, return "Not Trustworthy".
+	// Not applicable
+
+	// 2. Assert: origin is a tuple origin.
+	// Not for implementations
+
+	// 3. If origin's scheme is either "https" or "wss", return "Potentially Trustworthy".
+	if (/^(http|ws)s:$/.test(url.protocol)) {
+		return true;
+	}
+
+	// 4. If origin's host component matches one of the CIDR notations 127.0.0.0/8 or ::1/128 [RFC4632], return "Potentially Trustworthy".
+	const hostIp = url.host.replace(/(^\[)|(]$)/g, '');
+	const hostIPVersion = isIP(hostIp);
+
+	if (hostIPVersion === 4 && /^127\./.test(hostIp)) {
+		return true;
+	}
+
+	if (hostIPVersion === 6 && /^(((0+:){7})|(::(0+:){0,6}))0*1$/.test(hostIp)) {
+		return true;
+	}
+
+	// 5. If origin's host component is "localhost" or falls within ".localhost", and the user agent conforms to the name resolution rules in [let-localhost-be-localhost], return "Potentially Trustworthy".
+	// We are returning FALSE here because we cannot ensure conformance to
+	// let-localhost-be-loalhost (https://tools.ietf.org/html/draft-west-let-localhost-be-localhost)
+	if (/^(.+\.)*localhost$/.test(url.host)) {
+		return false;
+	}
+
+	// 6. If origin's scheme component is file, return "Potentially Trustworthy".
+	if (url.protocol === 'file:') {
+		return true;
+	}
+
+	// 7. If origin's scheme component is one which the user agent considers to be authenticated, return "Potentially Trustworthy".
+	// Not supported
+
+	// 8. If origin has been configured as a trustworthy origin, return "Potentially Trustworthy".
+	// Not supported
+
+	// 9. Return "Not Trustworthy".
+	return false;
+}
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-secure-contexts/#is-url-trustworthy|Referrer Policy §3.3. Is url potentially trustworthy?}
+ * @param {external:URL} url
+ * @returns `true`: "Potentially Trustworthy", `false`: "Not Trustworthy"
+ */
+export function isUrlPotentiallyTrustworthy(url) {
+	// 1. If url is "about:blank" or "about:srcdoc", return "Potentially Trustworthy".
+	if (/^about:(blank|srcdoc)$/.test(url)) {
+		return true;
+	}
+
+	// 2. If url's scheme is "data", return "Potentially Trustworthy".
+	if (url.protocol === 'data:') {
+		return true;
+	}
+
+	// Note: The origin of blob: and filesystem: URLs is the origin of the context in which they were
+	// created. Therefore, blobs created in a trustworthy origin will themselves be potentially
+	// trustworthy.
+	if (/^(blob|filesystem):$/.test(url.protocol)) {
+		return true;
+	}
+
+	// 3. Return the result of executing §3.2 Is origin potentially trustworthy? on url's origin.
+	return isOriginPotentiallyTrustworthy(url);
+}
+
+/**
+ * Modifies the referrerURL to enforce any extra security policy considerations.
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer|Referrer Policy §8.3. Determine request's Referrer}, step 7
+ * @callback module:utils/referrer~referrerURLCallback
+ * @param {external:URL} referrerURL
+ * @returns {external:URL} modified referrerURL
+ */
+
+/**
+ * Modifies the referrerOrigin to enforce any extra security policy considerations.
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer|Referrer Policy §8.3. Determine request's Referrer}, step 7
+ * @callback module:utils/referrer~referrerOriginCallback
+ * @param {external:URL} referrerOrigin
+ * @returns {external:URL} modified referrerOrigin
+ */
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer|Referrer Policy §8.3. Determine request's Referrer}
+ * @param {Request} request
+ * @param {object} o
+ * @param {module:utils/referrer~referrerURLCallback} o.referrerURLCallback
+ * @param {module:utils/referrer~referrerOriginCallback} o.referrerOriginCallback
+ * @returns {external:URL} Request's referrer
+ */
+export function determineRequestsReferrer(request, {referrerURLCallback, referrerOriginCallback} = {}) {
+	// There are 2 notes in the specification about invalid pre-conditions.  We return null, here, for
+	// these cases:
+	// > Note: If request's referrer is "no-referrer", Fetch will not call into this algorithm.
+	// > Note: If request's referrer policy is the empty string, Fetch will not call into this
+	// > algorithm.
+	if (request.referrer === 'no-referrer' || request.referrerPolicy === '') {
+		return null;
+	}
+
+	// 1. Let policy be request's associated referrer policy.
+	const policy = request.referrerPolicy;
+
+	// 2. Let environment be request's client.
+	// not applicable to node.js
+
+	// 3. Switch on request's referrer:
+	if (request.referrer === 'about:client') {
+		return 'no-referrer';
+	}
+
+	// "a URL": Let referrerSource be request's referrer.
+	const referrerSource = request.referrer;
+
+	// 4. Let request's referrerURL be the result of stripping referrerSource for use as a referrer.
+	let referrerURL = stripURLForUseAsAReferrer(referrerSource);
+
+	// 5. Let referrerOrigin be the result of stripping referrerSource for use as a referrer, with the
+	//    origin-only flag set to true.
+	let referrerOrigin = stripURLForUseAsAReferrer(referrerSource, true);
+
+	// 6. If the result of serializing referrerURL is a string whose length is greater than 4096, set
+	//    referrerURL to referrerOrigin.
+	if (referrerURL.toString().length > 4096) {
+		referrerURL = referrerOrigin;
+	}
+
+	// 7. The user agent MAY alter referrerURL or referrerOrigin at this point to enforce arbitrary
+	//    policy considerations in the interests of minimizing data leakage. For example, the user
+	//    agent could strip the URL down to an origin, modify its host, replace it with an empty
+	//    string, etc.
+	if (referrerURLCallback) {
+		referrerURL = referrerURLCallback(referrerURL);
+	}
+
+	if (referrerOriginCallback) {
+		referrerOrigin = referrerOriginCallback(referrerOrigin);
+	}
+
+	// 8.Execute the statements corresponding to the value of policy:
+	const currentURL = new URL(request.url);
+
+	switch (policy) {
+		case 'no-referrer':
+			return 'no-referrer';
+
+		case 'origin':
+			return referrerOrigin;
+
+		case 'unsafe-url':
+			return referrerURL;
+
+		case 'strict-origin':
+			// 1. If referrerURL is a potentially trustworthy URL and request's current URL is not a
+			//    potentially trustworthy URL, then return no referrer.
+			if (isUrlPotentiallyTrustworthy(referrerURL) && !isUrlPotentiallyTrustworthy(currentURL)) {
+				return 'no-referrer';
+			}
+
+			// 2. Return referrerOrigin.
+			return referrerOrigin.toString();
+
+		case 'strict-origin-when-cross-origin':
+			// 1. If the origin of referrerURL and the origin of request's current URL are the same, then
+			//    return referrerURL.
+			if (referrerURL.origin === currentURL.origin) {
+				return referrerURL;
+			}
+
+			// 2. If referrerURL is a potentially trustworthy URL and request's current URL is not a
+			//    potentially trustworthy URL, then return no referrer.
+			if (isUrlPotentiallyTrustworthy(referrerURL) && !isUrlPotentiallyTrustworthy(currentURL)) {
+				return 'no-referrer';
+			}
+
+			// 3. Return referrerOrigin.
+			return referrerOrigin;
+
+		case 'same-origin':
+			// 1. If the origin of referrerURL and the origin of request's current URL are the same, then
+			//    return referrerURL.
+			if (referrerURL.origin === currentURL.origin) {
+				return referrerURL;
+			}
+
+			// 2. Return no referrer.
+			return 'no-referrer';
+
+		case 'origin-when-cross-origin':
+			// 1. If the origin of referrerURL and the origin of request's current URL are the same, then
+			//    return referrerURL.
+			if (referrerURL.origin === currentURL.origin) {
+				return referrerURL;
+			}
+
+			// Return referrerOrigin.
+			return referrerOrigin;
+
+		case 'no-referrer-when-downgrade':
+			// 1. If referrerURL is a potentially trustworthy URL and request's current URL is not a
+			//    potentially trustworthy URL, then return no referrer.
+			if (isUrlPotentiallyTrustworthy(referrerURL) && !isUrlPotentiallyTrustworthy(currentURL)) {
+				return 'no-referrer';
+			}
+
+			// 2. Return referrerURL.
+			return referrerURL;
+
+		default:
+			throw new TypeError(`Invalid referrerPolicy: ${policy}`);
+	}
+}
+
+/**
+ * @see {@link https://w3c.github.io/webappsec-referrer-policy/#parse-referrer-policy-from-header|Referrer Policy §8.1. Parse a referrer policy from a Referrer-Policy header}
+ * @param {Headers} headers Response headers
+ * @returns {string} policy
+ */
+export function parseReferrerPolicyFromHeader(headers) {
+	// 1. Let policy-tokens be the result of extracting header list values given `Referrer-Policy`
+	//    and response’s header list.
+	const policyTokens = (headers.get('referrer-policy') || '').split(/[,\s]+/);
+
+	// 2. Let policy be the empty string.
+	let policy = '';
+
+	// 3. For each token in policy-tokens, if token is a referrer policy and token is not the empty
+	//    string, then set policy to token.
+	// Note: This algorithm loops over multiple policy values to allow deployment of new policy
+	// values with fallbacks for older user agents, as described in § 11.1 Unknown Policy Values.
+	for (const token of policyTokens) {
+		if (token && ReferrerPolicy.has(token)) {
+			policy = token;
+		}
+	}
+
+	// 4. Return policy.
+	return policy;
+}


Test patch:
diff --git a/@types/index.test-d.ts b/@types/index.test-d.ts
index 4b280f1..4b24dcb 100644
--- a/@types/index.test-d.ts
+++ b/@types/index.test-d.ts
@@ -88,6 +88,9 @@ async function run() {
 		new Map([['a', null], ['3', null]]).keys()
 	]);
 	/* eslint-enable no-new */
+
+	expectType<Response>(Response.redirect('https://google.com'));
+	expectType<Response>(Response.redirect('https://google.com', 301));
 }
 
 run().finally(() => {
diff --git a/test/headers.js b/test/headers.js
index 069a6a1..f57a0b0 100644
--- a/test/headers.js
+++ b/test/headers.js
@@ -1,4 +1,4 @@
-import {format} from 'util';
+import {format} from 'node:util';
 import chai from 'chai';
 import chaiIterator from 'chai-iterator';
 import {Headers} from '../src/index.js';
diff --git a/test/main.js b/test/main.js
index c50860e..c8ae86e 100644
--- a/test/main.js
+++ b/test/main.js
@@ -1,12 +1,12 @@
 // Test tools
-import zlib from 'zlib';
-import crypto from 'crypto';
-import http from 'http';
-import fs from 'fs';
-import stream from 'stream';
-import path from 'path';
-import {lookup} from 'dns';
-import vm from 'vm';
+import zlib from 'node:zlib';
+import crypto from 'node:crypto';
+import http from 'node:http';
+import fs from 'node:fs';
+import stream from 'node:stream';
+import path from 'node:path';
+import {lookup} from 'node:dns';
+import vm from 'node:vm';
 import chai from 'chai';
 import chaiPromised from 'chai-as-promised';
 import chaiIterator from 'chai-iterator';
@@ -684,6 +684,36 @@ describe('node-fetch', () => {
 		});
 	});
 
+	it('should follow redirect after empty chunked transfer-encoding', () => {
+		const url = `${base}redirect/chunked`;
+		return fetch(url).then(res => {
+			expect(res.status).to.equal(200);
+			expect(res.ok).to.be.true;
+		});
+	});
+
+	it('should handle chunked response with more than 1 chunk in the final packet', () => {
+		const url = `${base}chunked/multiple-ending`;
+		return fetch(url).then(res => {
+			expect(res.ok).to.be.true;
+
+			return res.text().then(result => {
+				expect(result).to.equal('foobar');
+			});
+		});
+	});
+
+	it('should handle chunked response with final chunk and EOM in separate packets', () => {
+		const url = `${base}chunked/split-ending`;
+		return fetch(url).then(res => {
+			expect(res.ok).to.be.true;
+
+			return res.text().then(result => {
+				expect(result).to.equal('foobar');
+			});
+		});
+	});
+
 	it('should handle DNS-error response', () => {
 		const url = 'http://domain.invalid';
 		return expect(fetch(url)).to.eventually.be.rejected
@@ -2185,7 +2215,6 @@ describe('node-fetch', () => {
 		function lookupSpy(hostname, options, callback) {
 			called++;
 
-			// eslint-disable-next-line node/prefer-promises/dns
 			return lookup(hostname, options, callback);
 		}
 
@@ -2202,7 +2231,6 @@ describe('node-fetch', () => {
 		function lookupSpy(hostname, options, callback) {
 			families.push(options.family);
 
-			// eslint-disable-next-line node/prefer-promises/dns
 			return lookup(hostname, {}, callback);
 		}
 
@@ -2304,3 +2332,33 @@ describe('node-fetch', () => {
 		expect(res.url).to.equal(`${base}m%C3%B6bius`);
 	});
 });
+
+describe('node-fetch using IPv6', () => {
+	const local = new TestServer('[::1]');
+	let base;
+
+	before(async () => {
+		await local.start();
+		base = `http://${local.hostname}:${local.port}/`;
+	});
+
+	after(async () => {
+		return local.stop();
+	});
+
+	it('should resolve into response', () => {
+		const url = `${base}hello`;
+		expect(url).to.contain('[::1]');
+		return fetch(url).then(res => {
+			expect(res).to.be.an.instanceof(Response);
+			expect(res.headers).to.be.an.instanceof(Headers);
+			expect(res.body).to.be.an.instanceof(stream.Transform);
+			expect(res.bodyUsed).to.be.false;
+
+			expect(res.url).to.equal(url);
+			expect(res.ok).to.be.true;
+			expect(res.status).to.equal(200);
+			expect(res.statusText).to.equal('OK');
+		});
+	});
+});
diff --git a/test/referrer.js b/test/referrer.js
new file mode 100644
index 0000000..35e6b93
--- /dev/null
+++ b/test/referrer.js
@@ -0,0 +1,552 @@
+import chai from 'chai';
+
+import fetch, {Request, Headers} from '../src/index.js';
+import {
+	DEFAULT_REFERRER_POLICY, ReferrerPolicy, stripURLForUseAsAReferrer, validateReferrerPolicy,
+	isOriginPotentiallyTrustworthy, isUrlPotentiallyTrustworthy, determineRequestsReferrer,
+	parseReferrerPolicyFromHeader
+} from '../src/utils/referrer.js';
+import TestServer from './utils/server.js';
+
+const {expect} = chai;
+
+describe('fetch() with referrer and referrerPolicy', () => {
+	const local = new TestServer();
+	let base;
+
+	before(async () => {
+		await local.start();
+		base = `http://${local.hostname}:${local.port}/`;
+	});
+
+	after(async () => {
+		return local.stop();
+	});
+
+	it('should send request without a referrer by default', () => {
+		return fetch(`${base}inspect`).then(res => res.json()).then(res => {
+			expect(res.headers.referer).to.be.undefined;
+		});
+	});
+
+	it('should send request with a referrer', () => {
+		return fetch(`${base}inspect`, {
+			referrer: base,
+			referrerPolicy: 'unsafe-url'
+		}).then(res => res.json()).then(res => {
+			expect(res.headers.referer).to.equal(base);
+		});
+	});
+
+	it('should send request with referrerPolicy strict-origin-when-cross-origin by default', () => {
+		return Promise.all([
+			fetch(`${base}inspect`, {
+				referrer: base
+			}).then(res => res.json()).then(res => {
+				expect(res.headers.referer).to.equal(base);
+			}),
+			fetch(`${base}inspect`, {
+				referrer: 'https://example.com'
+			}).then(res => res.json()).then(res => {
+				expect(res.headers.referer).to.be.undefined;
+			})
+		]);
+	});
+
+	it('should send request with a referrer and respect redirected referrer-policy', () => {
+		return Promise.all([
+			fetch(`${base}redirect/referrer-policy`, {
+				referrer: base
+			}).then(res => res.json()).then(res => {
+				expect(res.headers.referer).to.equal(base);
+			}),
+			fetch(`${base}redirect/referrer-policy`, {
+				referrer: 'https://example.com'
+			}).then(res => res.json()).then(res => {
+				expect(res.headers.referer).to.be.undefined;
+			}),
+			fetch(`${base}redirect/referrer-policy`, {
+				referrer: 'https://example.com',
+				referrerPolicy: 'unsafe-url'
+			}).then(res => res.json()).then(res => {
+				expect(res.headers.referer).to.equal('https://example.com/');
+			}),
+			fetch(`${base}redirect/referrer-policy/same-origin`, {
+				referrer: 'https://example.com',
+				referrerPolicy: 'unsafe-url'
+			}).then(res => res.json()).then(res => {
+				expect(res.headers.referer).to.undefined;
+			})
+		]);
+	});
+});
+
+describe('Request constructor', () => {
+	describe('referrer', () => {
+		it('should leave referrer undefined by default', () => {
+			const req = new Request('http://example.com');
+			expect(req.referrer).to.be.undefined;
+		});
+
+		it('should accept empty string referrer as no-referrer', () => {
+			const referrer = '';
+			const req = new Request('http://example.com', {referrer});
+			expect(req.referrer).to.equal(referrer);
+		});
+
+		it('should accept about:client referrer as client', () => {
+			const referrer = 'about:client';
+			const req = new Request('http://example.com', {referrer});
+			expect(req.referrer).to.equal(referrer);
+		});
+
+		it('should accept about://client referrer as client', () => {
+			const req = new Request('http://example.com', {referrer: 'about://client'});
+			expect(req.referrer).to.equal('about:client');
+		});
+
+		it('should accept a string URL referrer', () => {
+			const referrer = 'http://example.com/';
+			const req = new Request('http://example.com', {referrer});
+			expect(req.referrer).to.equal(referrer);
+		});
+
+		it('should accept a URL referrer', () => {
+			const referrer = new URL('http://example.com');
+			const req = new Request('http://example.com', {referrer});
+			expect(req.referrer).to.equal(referrer.toString());
+		});
+
+		it('should accept a referrer from input', () => {
+			const referrer = 'http://example.com/';
+			const req = new Request(new Request('http://example.com', {referrer}));
+			expect(req.referrer).to.equal(referrer.toString());
+		});
+
+		it('should throw a TypeError for an invalid URL', () => {
+			expect(() => {
+				const req = new Request('http://example.com', {referrer: 'foobar'});
+				expect.fail(req);
+			}).to.throw(TypeError, 'Invalid URL: foobar');
+		});
+	});
+
+	describe('referrerPolicy', () => {
+		it('should default refererPolicy to empty string', () => {
+			const req = new Request('http://example.com');
+			expect(req.referrerPolicy).to.equal('');
+		});
+
+		it('should accept refererPolicy', () => {
+			const referrerPolicy = 'unsafe-url';
+			const req = new Request('http://example.com', {referrerPolicy});
+			expect(req.referrerPolicy).to.equal(referrerPolicy);
+		});
+
+		it('should accept referrerPolicy from input', () => {
+			const referrerPolicy = 'unsafe-url';
+			const req = new Request(new Request('http://example.com', {referrerPolicy}));
+			expect(req.referrerPolicy).to.equal(referrerPolicy);
+		});
+
+		it('should throw a TypeError for an invalid referrerPolicy', () => {
+			expect(() => {
+				const req = new Request('http://example.com', {referrerPolicy: 'foobar'});
+				expect.fail(req);
+			}).to.throw(TypeError, 'Invalid referrerPolicy: foobar');
+		});
+	});
+});
+
+describe('utils/referrer', () => {
+	it('default policy should be strict-origin-when-cross-origin', () => {
+		expect(DEFAULT_REFERRER_POLICY).to.equal('strict-origin-when-cross-origin');
+	});
+
+	describe('stripURLForUseAsAReferrer', () => {
+		it('should return no-referrer for null/undefined URL', () => {
+			expect(stripURLForUseAsAReferrer(undefined)).to.equal('no-referrer');
+			expect(stripURLForUseAsAReferrer(null)).to.equal('no-referrer');
+		});
+
+		it('should return no-referrer for about:, blob:, and data: URLs', () => {
+			expect(stripURLForUseAsAReferrer('about:client')).to.equal('no-referrer');
+			expect(stripURLForUseAsAReferrer('blob:theblog')).to.equal('no-referrer');
+			expect(stripURLForUseAsAReferrer('data:,thedata')).to.equal('no-referrer');
+		});
+
+		it('should strip the username, password, and hash', () => {
+			const urlStr = 'http://foo:bar@example.com/foo?q=search#theanchor';
+			expect(stripURLForUseAsAReferrer(urlStr).toString())
+				.to.equal('http://example.com/foo?q=search');
+		});
+
+		it('should strip the pathname and query when origin-only', () => {
+			const urlStr = 'http://foo:bar@example.com/foo?q=search#theanchor';
+			expect(stripURLForUseAsAReferrer(urlStr, true).toString())
+				.to.equal('http://example.com/');
+		});
+	});
+
+	describe('validateReferrerPolicy', () => {
+		it('should return the referrer policy', () => {
+			for (const referrerPolicy of ReferrerPolicy) {
+				expect(validateReferrerPolicy(referrerPolicy)).to.equal(referrerPolicy);
+			}
+		});
+
+		it('should throw a TypeError for invalid referrer policies', () => {
+			expect(validateReferrerPolicy.bind(null, undefined))
+				.to.throw(TypeError, 'Invalid referrerPolicy: undefined');
+			expect(validateReferrerPolicy.bind(null, null))
+				.to.throw(TypeError, 'Invalid referrerPolicy: null');
+			expect(validateReferrerPolicy.bind(null, false))
+				.to.throw(TypeError, 'Invalid referrerPolicy: false');
+			expect(validateReferrerPolicy.bind(null, 0))
+				.to.throw(TypeError, 'Invalid referrerPolicy: 0');
+			expect(validateReferrerPolicy.bind(null, 'always'))
+				.to.throw(TypeError, 'Invalid referrerPolicy: always');
+		});
+	});
+
+	const testIsOriginPotentiallyTrustworthyStatements = func => {
+		it('should be potentially trustworthy for HTTPS and WSS URLs', () => {
+			expect(func(new URL('https://example.com'))).to.be.true;
+			expect(func(new URL('wss://example.com'))).to.be.true;
+		});
+
+		it('should be potentially trustworthy for loopback IP address URLs', () => {
+			expect(func(new URL('http://127.0.0.1'))).to.be.true;
+			expect(func(new URL('http://127.1.2.3'))).to.be.true;
+			expect(func(new URL('ws://[::1]'))).to.be.true;
+		});
+
+		it('should not be potentially trustworthy for "localhost" URLs', () => {
+			expect(func(new URL('http://localhost'))).to.be.false;
+		});
+
+		it('should be potentially trustworthy for file: URLs', () => {
+			expect(func(new URL('file://foo/bar'))).to.be.true;
+		});
+
+		it('should not be potentially trustworthy for all other origins', () => {
+			expect(func(new URL('http://example.com'))).to.be.false;
+			expect(func(new URL('ws://example.com'))).to.be.false;
+		});
+	};
+
+	describe('isOriginPotentiallyTrustworthy', () => {
+		testIsOriginPotentiallyTrustworthyStatements(isOriginPotentiallyTrustworthy);
+	});
+
+	describe('isUrlPotentiallyTrustworthy', () => {
+		it('should be potentially trustworthy for about:blank and about:srcdoc', () => {
+			expect(isUrlPotentiallyTrustworthy(new URL('about:blank'))).to.be.true;
+			expect(isUrlPotentiallyTrustworthy(new URL('about:srcdoc'))).to.be.true;
+		});
+
+		it('should be potentially trustworthy for data: URLs', () => {
+			expect(isUrlPotentiallyTrustworthy(new URL('data:,thedata'))).to.be.true;
+		});
+
+		it('should be potentially trustworthy for blob: and filesystem: URLs', () => {
+			expect(isUrlPotentiallyTrustworthy(new URL('blob:theblob'))).to.be.true;
+			expect(isUrlPotentiallyTrustworthy(new URL('filesystem:thefilesystem'))).to.be.true;
+		});
+
+		testIsOriginPotentiallyTrustworthyStatements(isUrlPotentiallyTrustworthy);
+	});
+
+	describe('determineRequestsReferrer', () => {
+		it('should return null for no-referrer or empty referrerPolicy', () => {
+			expect(determineRequestsReferrer({referrer: 'no-referrer'})).to.be.null;
+			expect(determineRequestsReferrer({referrerPolicy: ''})).to.be.null;
+		});
+
+		it('should return no-referrer for about:client', () => {
+			expect(determineRequestsReferrer({
+				referrer: 'about:client',
+				referrerPolicy: DEFAULT_REFERRER_POLICY
+			})).to.equal('no-referrer');
+		});
+
+		it('should return just the origin for URLs over 4096 characters', () => {
+			expect(determineRequestsReferrer({
+				url: 'http://foo:bar@example.com/foo?q=search#theanchor',
+				referrer: `http://example.com/${'0'.repeat(4096)}`,
+				referrerPolicy: DEFAULT_REFERRER_POLICY
+			}).toString()).to.equal('http://example.com/');
+		});
+
+		it('should alter the referrer URL by callback', () => {
+			expect(determineRequestsReferrer({
+				url: 'http://foo:bar@example.com/foo?q=search#theanchor',
+				referrer: 'http://foo:bar@example.com/foo?q=search#theanchor',
+				referrerPolicy: 'unsafe-url'
+			}, {
+				referrerURLCallback: referrerURL => {
+					return new URL(referrerURL.toString().replace(/^http:/, 'myprotocol:'));
+				}
+			}).toString()).to.equal('myprotocol://example.com/foo?q=search');
+		});
+
+		it('should alter the referrer origin by callback', () => {
+			expect(determineRequestsReferrer({
+				url: 'http://foo:bar@example.com/foo?q=search#theanchor',
+				referrer: 'http://foo:bar@example.com/foo?q=search#theanchor',
+				referrerPolicy: 'origin'
+			}, {
+				referrerOriginCallback: referrerOrigin => {
+					return new URL(referrerOrigin.toString().replace(/^http:/, 'myprotocol:'));
+				}
+			}).toString()).to.equal('myprotocol://example.com/');
+		});
+
+		it('should throw a TypeError for an invalid policy', () => {
+			expect(() => {
+				determineRequestsReferrer({
+					url: 'http://foo:bar@example.com/foo?q=search#theanchor',
+					referrer: 'http://foo:bar@example.com/foo?q=search#theanchor',
+					referrerPolicy: 'always'
+				});
+			}).to.throw(TypeError, 'Invalid referrerPolicy: always');
+		});
+
+		const referrerPolicyTestLabel = ({currentURLTrust, referrerURLTrust, sameOrigin}) => {
+			if (currentURLTrust === null && referrerURLTrust === null && sameOrigin === null) {
+				return 'Always';
+			}
+
+			const result = [];
+
+			if (currentURLTrust !== null) {
+				result.push(`Current URL is ${currentURLTrust ? '' : 'not '}potentially trustworthy`);
+			}
+
+			if (referrerURLTrust !== null) {
+				result.push(`Referrer URL is ${referrerURLTrust ? '' : 'not '}potentially trustworthy`);
+			}
+
+			if (sameOrigin !== null) {
+				result.push(`Current URL & Referrer URL do ${sameOrigin ? '' : 'not '}have same origin`);
+			}
+
+			return result.join(', ');
+		};
+
+		const referrerPolicyTests = (referrerPolicy, matrix) => {
+			describe(`Referrer policy: ${referrerPolicy}`, () => {
+				for (const {currentURLTrust, referrerURLTrust, sameOrigin, result} of matrix) {
+					describe(referrerPolicyTestLabel({currentURLTrust, referrerURLTrust, sameOrigin}), () => {
+						const requests = [];
+
+						if (sameOrigin === true || sameOrigin === null) {
+							requests.push({
+								referrerPolicy,
+								url: 'http://foo:bar@example.com/foo?q=search#theanchor',
+								referrer: 'http://foo:bar@example.com/foo?q=search#theanchor'
+							});
+						}
+
+						if (sameOrigin === false || sameOrigin === null) {
+							requests.push({
+								referrerPolicy,
+								url: 'http://foo:bar@example2.com/foo?q=search#theanchor',
+								referrer: 'http://foo:bar@example.com/foo?q=search#theanchor'
+							});
+						}
+
+						let requestsLength = requests.length;
+						switch (currentURLTrust) {
+							case null:
+								for (let i = 0; i < requestsLength; i++) {
+									const req = requests[i];
+									requests.push({...req, url: req.url.replace(/^http:/, 'https:')});
+								}
+
+								break;
+
+							case true:
+								for (let i = 0; i < requestsLength; i++) {
+									const req = requests[i];
+									req.url = req.url.replace(/^http:/, 'https:');
+								}
+
+								break;
+
+							case false:
+								// nothing to do, default is not potentially trustworthy
+								break;
+
+							default:
+								throw new TypeError(`Invalid currentURLTrust condition: ${currentURLTrust}`);
+						}
+
+						requestsLength = requests.length;
+						switch (referrerURLTrust) {
+							case null:
+								for (let i = 0; i < requestsLength; i++) {
+									const req = requests[i];
+
+									if (sameOrigin) {
+										if (req.url.startsWith('https:')) {
+											requests.splice(i, 1);
+										} else {
+											continue;
+										}
+									}
+
+									requests.push({...req, referrer: req.referrer.replace(/^http:/, 'https:')});
+								}
+
+								break;
+
+							case true:
+								for (let i = 0; i < requestsLength; i++) {
+									const req = requests[i];
+									req.referrer = req.referrer.replace(/^http:/, 'https:');
+								}
+
+								break;
+
+							case false:
+								// nothing to do, default is not potentially trustworthy
+								break;
+
+							default:
+								throw new TypeError(`Invalid referrerURLTrust condition: ${referrerURLTrust}`);
+						}
+
+						it('should have tests', () => {
+							expect(requests).to.not.be.empty;
+						});
+
+						for (const req of requests) {
+							it(`should return ${result} for url: ${req.url}, referrer: ${req.referrer}`, () => {
+								if (result === 'no-referrer') {
+									return expect(determineRequestsReferrer(req).toString())
+										.to.equal('no-referrer');
+								}
+
+								if (result === 'referrer-origin') {
+									const referrerOrigih = stripURLForUseAsAReferrer(req.referrer, true);
+									return expect(determineRequestsReferrer(req).toString())
+										.to.equal(referrerOrigih.toString());
+								}
+
+								if (result === 'referrer-url') {
+									const referrerURL = stripURLForUseAsAReferrer(req.referrer);
+									return expect(determineRequestsReferrer(req).toString())
+										.to.equal(referrerURL.toString());
+								}
+
+								throw new TypeError(`Invalid result: ${result}`);
+							});
+						}
+					});
+				}
+			});
+		};
+
+		// 3.1 no-referrer
+		referrerPolicyTests('no-referrer', [
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: null, result: 'no-referrer'}
+		]);
+
+		// 3.2 no-referrer-when-downgrade
+		referrerPolicyTests('no-referrer-when-downgrade', [
+			{currentURLTrust: false, referrerURLTrust: true, sameOrigin: null, result: 'no-referrer'},
+			{currentURLTrust: null, referrerURLTrust: false, sameOrigin: null, result: 'referrer-url'},
+			{currentURLTrust: true, referrerURLTrust: true, sameOrigin: null, result: 'referrer-url'}
+		]);
+
+		// 3.3 same-origin
+		referrerPolicyTests('same-origin', [
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: false, result: 'no-referrer'},
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: true, result: 'referrer-url'}
+		]);
+
+		// 3.4 origin
+		referrerPolicyTests('origin', [
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: null, result: 'referrer-origin'}
+		]);
+
+		// 3.5 strict-origin
+		referrerPolicyTests('strict-origin', [
+			{currentURLTrust: false, referrerURLTrust: true, sameOrigin: null, result: 'no-referrer'},
+			{currentURLTrust: null, referrerURLTrust: false, sameOrigin: null, result: 'referrer-origin'},
+			{currentURLTrust: true, referrerURLTrust: true, sameOrigin: null, result: 'referrer-origin'}
+		]);
+
+		// 3.6 origin-when-cross-origin
+		referrerPolicyTests('origin-when-cross-origin', [
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: false, result: 'referrer-origin'},
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: true, result: 'referrer-url'}
+		]);
+
+		// 3.7 strict-origin-when-cross-origin
+		referrerPolicyTests('strict-origin-when-cross-origin', [
+			{currentURLTrust: false, referrerURLTrust: true, sameOrigin: false, result: 'no-referrer'},
+			{currentURLTrust: null, referrerURLTrust: false, sameOrigin: false,
+				result: 'referrer-origin'},
+			{currentURLTrust: true, referrerURLTrust: true, sameOrigin: false, result: 'referrer-origin'},
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: true, result: 'referrer-url'}
+		]);
+
+		// 3.8 unsafe-url
+		referrerPolicyTests('unsafe-url', [
+			{currentURLTrust: null, referrerURLTrust: null, sameOrigin: null, result: 'referrer-url'}
+		]);
+	});
+
+	describe('parseReferrerPolicyFromHeader', () => {
+		it('should return an empty string when no referrer policy is found', () => {
+			expect(parseReferrerPolicyFromHeader(new Headers())).to.equal('');
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([['Referrer-Policy', '']])
+			)).to.equal('');
+		});
+
+		it('should return the last valid referrer policy', () => {
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([['Referrer-Policy', 'no-referrer']])
+			)).to.equal('no-referrer');
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([['Referrer-Policy', 'no-referrer unsafe-url']])
+			)).to.equal('unsafe-url');
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([['Referrer-Policy', 'foo no-referrer bar']])
+			)).to.equal('no-referrer');
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([['Referrer-Policy', 'foo no-referrer unsafe-url bar']])
+			)).to.equal('unsafe-url');
+		});
+
+		it('should use all Referrer-Policy headers', () => {
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([
+					['Referrer-Policy', 'no-referrer'],
+					['Referrer-Policy', '']
+				])
+			)).to.equal('no-referrer');
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([
+					['Referrer-Policy', 'no-referrer'],
+					['Referrer-Policy', 'unsafe-url']
+				])
+			)).to.equal('unsafe-url');
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([
+					['Referrer-Policy', 'no-referrer foo'],
+					['Referrer-Policy', 'bar unsafe-url wow']
+				])
+			)).to.equal('unsafe-url');
+			expect(parseReferrerPolicyFromHeader(
+				new Headers([
+					['Referrer-Policy', 'no-referrer unsafe-url'],
+					['Referrer-Policy', 'foo bar']
+				])
+			)).to.equal('unsafe-url');
+		});
+	});
+});
diff --git a/test/request.js b/test/request.js
index 5f1fda0..de4fed1 100644
--- a/test/request.js
+++ b/test/request.js
@@ -1,5 +1,5 @@
-import stream from 'stream';
-import http from 'http';
+import stream from 'node:stream';
+import http from 'node:http';
 
 import AbortController from 'abort-controller';
 import chai from 'chai';
@@ -125,6 +125,13 @@ describe('Request', () => {
 			.to.throw(TypeError);
 	});
 
+	it('should throw error when including credentials', () => {
+		expect(() => new Request('https://john:pass@github.com/'))
+			.to.throw(TypeError);
+		expect(() => new Request(new URL('https://john:pass@github.com/')))
+			.to.throw(TypeError);
+	});
+
 	it('should default to null as body', () => {
 		const request = new Request(base);
 		expect(request.body).to.equal(null);
diff --git a/test/response.js b/test/response.js
index 9b89fef..0a3b62a 100644
--- a/test/response.js
+++ b/test/response.js
@@ -1,5 +1,4 @@
-
-import * as stream from 'stream';
+import * as stream from 'node:stream';
 import chai from 'chai';
 import Blob from 'fetch-blob';
 import {Response} from '../src/index.js';
@@ -122,7 +121,8 @@ describe('Response', () => {
 			},
 			url: base,
 			status: 346,
-			statusText: 'production'
+			statusText: 'production',
+			highWaterMark: 789
 		});
 		const cl = res.clone();
 		expect(cl.headers.get('a')).to.equal('1');
@@ -130,6 +130,7 @@ describe('Response', () => {
 		expect(cl.url).to.equal(base);
 		expect(cl.status).to.equal(346);
 		expect(cl.statusText).to.equal('production');
+		expect(cl.highWaterMark).to.equal(789);
 		expect(cl.ok).to.be.false;
 		// Clone body shouldn't be the same body
 		expect(cl.body).to.not.equal(body);
@@ -208,6 +209,38 @@ describe('Response', () => {
 		expect(res.url).to.equal('');
 	});
 
+	it('should cast string to stream using res.body', () => {
+		const res = new Response('hi');
+		expect(res.body).to.be.an.instanceof(stream.Readable);
+	});
+
+	it('should cast typed array to stream using res.body', () => {
+		const res = new Response(Uint8Array.from([97]));
+		expect(res.body).to.be.an.instanceof(stream.Readable);
+	});
+
+	it('should cast blob to stream using res.body', () => {
+		const res = new Response(new Blob(['a']));
+		expect(res.body).to.be.an.instanceof(stream.Readable);
+	});
+
+	it('should not cast null to stream using res.body', () => {
+		const res = new Response(null);
+		expect(res.body).to.be.null;
+	});
+
+	it('should cast typed array to text using res.text()', async () => {
+		const res = new Response(Uint8Array.from([97]));
+		expect(await res.text()).to.equal('a');
+	});
+
+	it('should cast stream to text using res.text() in a roundabout way', async () => {
+		const {body} = new Response('a');
+		expect(body).to.be.an.instanceof(stream.Readable);
+		const res = new Response(body);
+		expect(await res.text()).to.equal('a');
+	});
+
 	it('should support error() static method', () => {
 		const res = Response.error();
 		expect(res).to.be.an.instanceof(Response);
diff --git a/test/utils/server.js b/test/utils/server.js
index b9ba351..2a1e8e9 100644
--- a/test/utils/server.js
+++ b/test/utils/server.js
@@ -4,7 +4,7 @@ import {once} from 'events';
 import Busboy from 'busboy';
 
 export default class TestServer {
-	constructor() {
+	constructor(hostname) {
 		this.server = http.createServer(this.router);
 		// Node 8 default keepalive timeout is 5000ms
 		// make it shorter here as we want to close server quickly at the end of tests
@@ -15,10 +15,18 @@ export default class TestServer {
 		this.server.on('connection', socket => {
 			socket.setTimeout(1500);
 		});
+		this.hostname = hostname || 'localhost';
 	}
 
 	async start() {
-		this.server.listen(0, 'localhost');
+		let host = this.hostname;
+		if (host.startsWith('[')) {
+			// If we're trying to listen on an IPv6 literal hostname, strip the
+			// square brackets before binding to the IPv6 address
+			host = host.slice(1, -1);
+		}
+
+		this.server.listen(0, host);
 		return once(this.server, 'listening');
 	}
 
@@ -31,10 +39,6 @@ export default class TestServer {
 		return this.server.address().port;
 	}
 
-	get hostname() {
-		return 'localhost';
-	}
-
 	mockResponse(responseHandler) {
 		this.server.nextResponseHandler = responseHandler;
 		return `http://${this.hostname}:${this.port}/mocked`;
@@ -297,6 +301,28 @@ export default class TestServer {
 			res.socket.end('\r\n');
 		}
 
+		if (p === '/redirect/referrer-policy') {
+			res.statusCode = 301;
+			res.setHeader('Location', '/inspect');
+			res.setHeader('Referrer-Policy', 'foo unsafe-url bar');
+			res.end();
+		}
+
+		if (p === '/redirect/referrer-policy/same-origin') {
+			res.statusCode = 301;
+			res.setHeader('Location', '/inspect');
+			res.setHeader('Referrer-Policy', 'foo unsafe-url same-origin bar');
+			res.end();
+		}
+
+		if (p === '/redirect/chunked') {
+			res.writeHead(301, {
+				Location: '/inspect',
+				'Transfer-Encoding': 'chunked'
+			});
+			setTimeout(() => res.end(), 10);
+		}
+
 		if (p === '/error/400') {
 			res.statusCode = 400;
 			res.setHeader('Content-Type', 'text/plain');
@@ -344,6 +370,24 @@ export default class TestServer {
 			}, 400);
 		}
 
+		if (p === '/chunked/split-ending') {
+			res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n');
+			res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n');
+
+			setTimeout(() => {
+				res.socket.write('0\r\n');
+			}, 10);
+
+			setTimeout(() => {
+				res.socket.end('\r\n');
+			}, 20);
+		}
+
+		if (p === '/chunked/multiple-ending') {
+			res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n');
+			res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n');
+		}
+
 		if (p === '/error/json') {
 			res.statusCode = 200;
 			res.setHeader('Content-Type', 'application/json');