Repository: wheresrhys/fetch-mock (js)

Issue:
Add support for all the types of body in the fetch spec
[arrayBuffer()](https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer)
[blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob)
[bytes()](https://developer.mozilla.org/en-US/docs/Web/API/Response/bytes)
[clone()](https://developer.mozilla.org/en-US/docs/Web/API/Response/clone)
[formData()](https://developer.mozilla.org/en-US/docs/Web/API/Response/formData)
[json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json)
[text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text)

Pull request description:
fix: handle all types of BodyInit correctly
TODO
- [x] is sendAsJson even necessary now?
- [x] setting content length
- [x] tests for each type
- [x] friendlier error 
- [x] test for error handling
- [x] docs - new support and also removing sendAsJson
- [x] blog post

fixes #713 

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 92575c2..91cc887 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -8,7 +8,7 @@ references:
 
   node18: &node18
     docker:
-      - image: cimg/node:18.0
+      - image: cimg/node:18.11
   nodelts: &nodelts
     docker:
       - image: cimg/node:lts
diff --git a/docs/docs/@fetch-mock/core/configuration.md b/docs/docs/@fetch-mock/core/configuration.md
index c1a6b56..fee495a 100644
--- a/docs/docs/@fetch-mock/core/configuration.md
+++ b/docs/docs/@fetch-mock/core/configuration.md
@@ -15,17 +15,11 @@ fetchMock.config.sendAsJson = false;
 
 Options marked with a `†` can also be overridden for individual calls to `.mock(matcher, response, options)` by setting as properties on the `options` parameter
 
-### sendAsJson<sup>†</sup>
-
-`{Boolean}` default: `true`
-
-Always convert objects passed to `.mock()` to JSON strings before building reponses. Can be useful to set to `false` globally if e.g. dealing with a lot of `ArrayBuffer`s. When `true` the `Content-Type: application/json` header will also be set on each response.
-
 ### includeContentLength<sup>†</sup>
 
 `{Boolean}` default: `true`
 
-Sets a `Content-Length` header on each response.
+Sets a `Content-Length` header on each response, with the exception of responses whose body is a `FormData` or `ReadableStream` instance as these are hard/impossible to calculate up front.
 
 ### matchPartialBody
 
diff --git a/docs/docs/@fetch-mock/core/route/response.md b/docs/docs/@fetch-mock/core/route/response.md
index 81bd4c1..44a0556 100644
--- a/docs/docs/@fetch-mock/core/route/response.md
+++ b/docs/docs/@fetch-mock/core/route/response.md
@@ -32,9 +32,13 @@ If the object _only_ contains properties from among those listed below it is use
 
 ### body
 
-`{String|Object}`
+`{String|Object|BodyInit}`
 
-Set the `Response` body, e.g. `"Server responded ok"`, `{ token: 'abcdef' }`. See the `Object` section of the docs below for behaviour when passed an `Object`.
+Set the `Response` body. This could be
+
+- a string e.g. `"Server responded ok"`, `{ token: 'abcdef' }`.
+- an object literal (see the `Object` section of the docs below).
+- Anything else that satisfies the specification for the [body parameter of new Response()](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#body). This currently allows instances of Blob, ArrayBuffer, TypedArray, DataView, FormData, ReadableStream, URLSearchParams, and String.
 
 ### status
 
@@ -62,9 +66,15 @@ Forces `fetch` to return a `Promise` rejected with the value of `throws` e.g. `n
 
 ## Object
 
-`{Object|ArrayBuffer|...`
+`{Object}`
+
+Any object literal that does not match the schema for a response config will be converted to a `JSON` string and set as the response `body`.
+
+The `Content-Type: application/json` header will also be set on each response. To send JSON responses that do not set this header (e.g. to mock a poorly configured server) manually convert the object to a string first e.g.
 
-If the `sendAsJson` option is set to `true`, any object that does not match the schema for a response config will be converted to a `JSON` string and set as the response `body`. Otherwise, the object will be set as the response `body` (useful for `ArrayBuffer`s etc.)
+```js
+fetchMock.route('http://a.com', JSON.stringify({ prop: 'value' }));
+```
 
 ## Promise
 
diff --git a/package-lock.json b/package-lock.json
index bb8d6f2..6d6c41c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12077,6 +12077,15 @@
 				"node": "^12.20 || >= 14.13"
 			}
 		},
+		"node_modules/fetch-blob/node_modules/web-streams-polyfill": {
+			"version": "3.3.3",
+			"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+			"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+			"dev": true,
+			"engines": {
+				"node": ">= 8"
+			}
+		},
 		"node_modules/fetch-mock": {
 			"resolved": "packages/fetch-mock",
 			"link": true
@@ -25499,15 +25508,6 @@
 				"url": "https://github.com/sponsors/wooorm"
 			}
 		},
-		"node_modules/web-streams-polyfill": {
-			"version": "3.3.3",
-			"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
-			"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
-			"dev": true,
-			"engines": {
-				"node": ">= 8"
-			}
-		},
 		"node_modules/webdriver": {
 			"version": "8.40.2",
 			"resolved": "https://registry.npmjs.org/webdriver/-/webdriver-8.40.2.tgz",
@@ -26450,7 +26450,7 @@
 		"packages/vitest": {
 			"name": "@fetch-mock/vitest",
 			"version": "0.1.2",
-			"license": "ISC",
+			"license": "MIT",
 			"dependencies": {
 				"@fetch-mock/core": "^0.6.3"
 			},
diff --git a/packages/core/package.json b/packages/core/package.json
index 8c3fdda..e0a1eae 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -18,7 +18,7 @@
 	"types": "./dist/esm/index.d.ts",
 	"type": "module",
 	"engines": {
-		"node": ">=18.0.0"
+		"node": ">=18.11.0"
 	},
 	"dependencies": {
 		"@types/glob-to-regexp": "^0.4.4",
diff --git a/packages/core/src/FetchMock.ts b/packages/core/src/FetchMock.ts
index 46db9d0..b5f8f47 100644
--- a/packages/core/src/FetchMock.ts
+++ b/packages/core/src/FetchMock.ts
@@ -5,7 +5,6 @@ import CallHistory from './CallHistory.js';
 import * as requestUtils from './RequestUtils.js';
 
 export type FetchMockGlobalConfig = {
-	sendAsJson?: boolean;
 	includeContentLength?: boolean;
 	matchPartialBody?: boolean;
 	allowRelativeUrls?: boolean;
@@ -20,7 +19,6 @@ export type FetchMockConfig = FetchMockGlobalConfig & FetchImplementations;
 
 export const defaultFetchMockConfig: FetchMockConfig = {
 	includeContentLength: true,
-	sendAsJson: true,
 	matchPartialBody: false,
 	Request: globalThis.Request,
 	Response: globalThis.Response,
diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts
index 2d341f0..85b65e9 100644
--- a/packages/core/src/Route.ts
+++ b/packages/core/src/Route.ts
@@ -42,7 +42,7 @@ export type RouteConfig = UserRouteConfig &
 	FetchImplementations &
 	InternalRouteConfig;
 export type RouteResponseConfig = {
-	body?: string | object;
+	body?: BodyInit | object;
 	status?: number;
 	headers?: {
 		[key: string]: string;
@@ -72,6 +72,22 @@ export type RouteResponse =
 	| RouteResponseFunction;
 export type RouteName = string;
 
+function isBodyInit(body: BodyInit | object): body is BodyInit {
+	return (
+		body instanceof Blob ||
+		body instanceof ArrayBuffer ||
+		// checks for TypedArray
+		ArrayBuffer.isView(body) ||
+		body instanceof DataView ||
+		body instanceof FormData ||
+		body instanceof ReadableStream ||
+		body instanceof URLSearchParams ||
+		body instanceof String ||
+		typeof body === 'string' ||
+		body === null
+	);
+}
+
 function sanitizeStatus(status?: number): number {
 	if (!status) {
 		return 200;
@@ -194,31 +210,48 @@ class Route {
 	constructResponseBody(
 		responseInput: RouteResponseConfig,
 		responseOptions: ResponseInitUsingHeaders,
-	): string | null {
-		// start to construct the body
+	): BodyInit {
 		let body = responseInput.body;
-		// convert to json if we need to
-		if (typeof body === 'object') {
-			if (this.config.sendAsJson && responseInput.body != null) {
+		const bodyIsBodyInit = isBodyInit(body);
+
+		if (!bodyIsBodyInit) {
+			if (typeof body === 'undefined') {
+				body = null;
+			} else if (typeof body === 'object') {
+				// convert to json if we need to
 				body = JSON.stringify(body);
 				if (!responseOptions.headers.has('Content-Type')) {
 					responseOptions.headers.set('Content-Type', 'application/json');
 				}
+			} else {
+				throw new TypeError('Invalid body provided to construct response');
 			}
 		}
 
-		if (typeof body === 'string') {
-			// add a Content-Length header if we need to
-			if (
-				this.config.includeContentLength &&
-				!responseOptions.headers.has('Content-Length')
+		// add a Content-Length header if we need to
+		if (
+			this.config.includeContentLength &&
+			!responseOptions.headers.has('Content-Length') &&
+			!(body instanceof ReadableStream) &&
+			!(body instanceof FormData)
+		) {
+			let length = 0;
+			if (body instanceof Blob) {
+				length = body.size;
+			} else if (
+				body instanceof ArrayBuffer ||
+				ArrayBuffer.isView(body) ||
+				body instanceof DataView
 			) {
-				responseOptions.headers.set('Content-Length', body.length.toString());
+				length = body.byteLength;
+			} else if (body instanceof URLSearchParams) {
+				length = body.toString().length;
+			} else if (typeof body === 'string' || body instanceof String) {
+				length = body.length;
 			}
-			return body;
+			responseOptions.headers.set('Content-Length', length.toString());
 		}
-		// @ts-expect-error TODO need to implement handling of non-string bodies properlyy
-		return body || null;
+		return body as BodyInit;
 	}
 
 	static defineMatcher(matcher: MatcherDefinition) {
diff --git a/packages/core/src/Router.ts b/packages/core/src/Router.ts
index 5448915..be3af6c 100644
--- a/packages/core/src/Router.ts
+++ b/packages/core/src/Router.ts
@@ -273,8 +273,8 @@ export default class Router {
 				if (typeof response[name] === 'function') {
 					//@ts-expect-error TODO probably make use of generics here
 					return new Proxy(response[name], {
-						apply: (matcherFunction, thisArg, args) => {
-							const result = matcherFunction.apply(response, args);
+						apply: (func, thisArg, args) => {
+							const result = func.apply(response, args);
 							if (result.then) {
 								pendingPromises.push(
 									//@ts-expect-error TODO probably make use of generics here


Test patch:
diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js
index 95d5deb..15fae2d 100644
--- a/packages/core/src/__tests__/FetchMock/response-construction.test.js
+++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js
@@ -1,8 +1,7 @@
 import { beforeEach, describe, expect, it } from 'vitest';
-
 import fetchMock from '../../FetchMock';
 
-describe('response generation', () => {
+describe('response construction', () => {
 	let fm;
 	beforeEach(() => {
 		fm = fetchMock.createInstance();
@@ -48,128 +47,159 @@ describe('response generation', () => {
 		});
 	});
 
-	describe('json', () => {
-		it('respond with a json', async () => {
-			fm.route('*', { an: 'object' });
-			const res = await fm.fetchHandler('http://a.com/');
-			expect(res.status).toEqual(200);
-			expect(res.statusText).toEqual('OK');
-			expect(res.headers.get('content-type')).toEqual('application/json');
-			expect(await res.json()).toEqual({ an: 'object' });
+	it('respond with a complex response, including headers', async () => {
+		fm.route('*', {
+			status: 202,
+			body: { an: 'object' },
+			headers: {
+				header: 'val',
+			},
 		});
-
-		it('convert body properties to json', async () => {
-			fm.route('*', {
-				body: { an: 'object' },
-			});
-			const res = await fm.fetchHandler('http://a.com/');
-			expect(res.headers.get('content-type')).toEqual('application/json');
-			expect(await res.json()).toEqual({ an: 'object' });
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(202);
+		expect(res.headers.get('header')).toEqual('val');
+		expect(await res.json()).toEqual({ an: 'object' });
+	});
+	describe('encoded and streamed data', () => {
+		function ab2str(buf) {
+			return String.fromCharCode.apply(null, new Uint16Array(buf));
+		}
+
+		function str2ab(str) {
+			var buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char
+			var bufView = new Uint16Array(buf);
+			for (var i = 0, strLen = str.length; i < strLen; i++) {
+				bufView[i] = str.charCodeAt(i);
+			}
+			return buf;
+		}
+
+		it('respond with Blob', async () => {
+			const blobParts = ['test value'];
+			const body = new Blob(blobParts);
+			fm.route('*', body);
+			const res = await fm.fetchHandler('http://a.com');
+			expect(res.status).to.equal(200);
+			const receivedData = await res.blob();
+			expect(receivedData).to.eql(body);
+			expect(res.headers.get('content-length')).toEqual('10');
 		});
-
-		it('not overide existing content-type-header', async () => {
-			fm.route('*', {
-				body: { an: 'object' },
-				headers: {
-					'content-type': 'text/html',
-				},
-			});
-			const res = await fm.fetchHandler('http://a.com/');
-			expect(res.headers.get('content-type')).toEqual('text/html');
-			expect(await res.json()).toEqual({ an: 'object' });
+		it('respond with ArrayBuffer', async () => {
+			const body = str2ab('test value');
+			fm.route('*', body);
+			const res = await fm.fetchHandler('http://a.com');
+			expect(res.status).to.equal(200);
+			const receivedData = await res.arrayBuffer();
+			expect(ab2str(receivedData)).to.eql('test value');
+			expect(res.headers.get('content-length')).toEqual('20');
 		});
-
-		it('not convert if `body` property exists', async () => {
-			fm.route('*', { body: 'exists' });
-			const res = await fm.fetchHandler('http://a.com/');
-			expect(res.headers.get('content-type')).not.toEqual('application/json');
+		it('respond with TypedArray', async () => {
+			const buffer = str2ab('test value');
+			const body = new Uint8Array(buffer);
+			fm.route('*', body);
+			const res = await fm.fetchHandler('http://a.com');
+			expect(res.status).to.equal(200);
+			const receivedData = await res.arrayBuffer();
+			expect(new Uint8Array(receivedData)).to.eql(body);
+			expect(res.headers.get('content-length')).toEqual('20');
 		});
-
-		it('not convert if `headers` property exists', async () => {
-			fm.route('*', { headers: {} });
-			const res = await fm.fetchHandler('http://a.com/');
-			expect(res.headers.get('content-type')).toBeNull();
+		it('respond with DataView', async () => {
+			const buffer = str2ab('test value');
+			const body = new DataView(buffer, 0);
+			fm.route('*', body);
+			const res = await fm.fetchHandler('http://a.com');
+			expect(res.status).to.equal(200);
+			const receivedData = await res.arrayBuffer();
+			expect(new DataView(receivedData, 0)).to.eql(body);
+			expect(res.headers.get('content-length')).toEqual('20');
 		});
 
-		it('not convert if `status` property exists', async () => {
-			fm.route('*', { status: 300 });
-			const res = await fm.fetchHandler('http://a.com/');
-			expect(res.headers.get('content-type')).toBeNull();
+		it('respond with ReadableStream', async () => {
+			const body = new Blob(['test value']).stream();
+			fm.route('*', body);
+			const res = await fm.fetchHandler('http://a.com');
+			expect(res.status).to.equal(200);
+			const receivedData = await res.text();
+			expect(receivedData).to.eql('test value');
+			expect(res.headers.get('content-length')).toBe(null);
 		});
-
-		it('convert if non-whitelisted property exists', async () => {
-			fm.route('*', { status: 300, weird: true });
-			const res = await fm.fetchHandler('http://a.com/');
-			expect(res.headers.get('content-type')).toEqual('application/json');
+	});
+	describe('structured data', () => {
+		it('respond with FormData', async () => {
+			const body = new FormData();
+			body.append('field', 'value');
+			fm.route('*', body);
+			const res = await fm.fetchHandler('http://a.com');
+			expect(res.status).to.equal(200);
+			const receivedData = await res.formData();
+			expect(receivedData).to.eql(body);
+			expect(res.headers.get('content-length')).toBe(null);
 		});
-
-		describe('sendAsJson option', () => {
-			it('convert object responses to json by default', async () => {
+		it('respond with URLSearchParams', async () => {
+			const body = new URLSearchParams();
+			body.append('field', 'value');
+			fm.route('*', body);
+			const res = await fm.fetchHandler('http://a.com');
+			expect(res.status).to.equal(200);
+			const receivedData = await res.formData();
+			expect(receivedData.get('field')).to.equal('value');
+			expect(res.headers.get('content-length')).toEqual('11');
+		});
+		describe('json', () => {
+			it('respond with a json', async () => {
 				fm.route('*', { an: 'object' });
-				const res = await fm.fetchHandler('http://it.at.there');
+				const res = await fm.fetchHandler('http://a.com/');
+				expect(res.status).toEqual(200);
+				expect(res.statusText).toEqual('OK');
 				expect(res.headers.get('content-type')).toEqual('application/json');
+				expect(await res.json()).toEqual({ an: 'object' });
 			});
 
-			it("don't convert when configured false", async () => {
-				fm.config.sendAsJson = false;
-				fm.route('*', { an: 'object' });
-				const res = await fm.fetchHandler('http://it.at.there');
-				// can't check for existence as the spec says, in the browser, that
-				// a default value should be set
-				expect(res.headers.get('content-type')).not.toEqual('application/json');
+			it('convert body properties to json', async () => {
+				fm.route('*', {
+					body: { an: 'object' },
+				});
+				const res = await fm.fetchHandler('http://a.com/');
+				expect(res.headers.get('content-type')).toEqual('application/json');
+				expect(await res.json()).toEqual({ an: 'object' });
 			});
 
-			it('local setting can override to true', async () => {
-				fm.config.sendAsJson = false;
-				fm.route('*', { an: 'object' }, { sendAsJson: true });
-				const res = await fm.fetchHandler('http://it.at.there');
-				expect(res.headers.get('content-type')).toEqual('application/json');
+			it('not overide existing content-type-header', async () => {
+				fm.route('*', {
+					body: { an: 'object' },
+					headers: {
+						'content-type': 'text/html',
+					},
+				});
+				const res = await fm.fetchHandler('http://a.com/');
+				expect(res.headers.get('content-type')).toEqual('text/html');
+				expect(await res.json()).toEqual({ an: 'object' });
 			});
 
-			it('local setting can override to false', async () => {
-				fm.config.sendAsJson = true;
-				fm.route('*', { an: 'object' }, { sendAsJson: false });
-				const res = await fm.fetchHandler('http://it.at.there');
-				// can't check for existence as the spec says, in the browser, that
-				// a default value should be set
+			it('not convert if `body` property exists', async () => {
+				fm.route('*', { body: 'exists' });
+				const res = await fm.fetchHandler('http://a.com/');
 				expect(res.headers.get('content-type')).not.toEqual('application/json');
 			});
-		});
-	});
 
-	it('respond with a complex response, including headers', async () => {
-		fm.route('*', {
-			status: 202,
-			body: { an: 'object' },
-			headers: {
-				header: 'val',
-			},
-		});
-		const res = await fm.fetchHandler('http://a.com/');
-		expect(res.status).toEqual(202);
-		expect(res.headers.get('header')).toEqual('val');
-		expect(await res.json()).toEqual({ an: 'object' });
-	});
+			it('not convert if `headers` property exists', async () => {
+				fm.route('*', { headers: {} });
+				const res = await fm.fetchHandler('http://a.com/');
+				expect(res.headers.get('content-type')).toBeNull();
+			});
 
-	if (typeof Buffer !== 'undefined') {
-		it('can respond with a buffer', () => {
-			fm.route(/a/, new Buffer('buffer'), { sendAsJson: false });
-			return fm
-				.fetchHandler('http://a.com')
-				.then((res) => res.text())
-				.then((txt) => {
-					expect(txt).to.equal('buffer');
-				});
-		});
-	}
+			it('not convert if `status` property exists', async () => {
+				fm.route('*', { status: 300 });
+				const res = await fm.fetchHandler('http://a.com/');
+				expect(res.headers.get('content-type')).toBeNull();
+			});
 
-	it('respond with blob', async () => {
-		const blob = new Blob();
-		fm.route('*', blob, { sendAsJson: false });
-		const res = await fm.fetchHandler('http://a.com');
-		expect(res.status).to.equal(200);
-		const blobData = await res.blob();
-		expect(blobData).to.eql(blob);
+			it('convert if non-whitelisted property exists', async () => {
+				fm.route('*', { status: 300, weird: true });
+				const res = await fm.fetchHandler('http://a.com/');
+				expect(res.headers.get('content-type')).toEqual('application/json');
+			});
+		});
 	});
 
 	it('should set the url property on responses', async () => {
diff --git a/packages/vitest/package.json b/packages/vitest/package.json
index 1a8af1f..b15eaee 100644
--- a/packages/vitest/package.json
+++ b/packages/vitest/package.json
@@ -13,7 +13,7 @@
 	"types": "./types/index.d.ts",
 	"type": "module",
 	"engines": {
-		"node": ">=18.0.0"
+		"node": ">=18.11.0"
 	},
 	"dependencies": {
 		"@fetch-mock/core": "^0.6.3"