From 193ae0dd53e635e5365d393d5abd7e5476a877aa Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Mon, 29 Sep 2025 21:59:51 +0300 Subject: [PATCH 1/4] fix: avoid unnecessary `JSON.stringify` --- create-fetchival.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/create-fetchival.js b/create-fetchival.js index 6b3041f..53cb52f 100644 --- a/create-fetchival.js +++ b/create-fetchival.js @@ -13,16 +13,20 @@ function createFetchival({ fetch }) { // Unlike fetchival, don't silently ignore and override if (opts.body) throw new Error('unexpected pre-set body option') + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...opts.headers, + } + // If a consumer has set the non-json content type, we don't stringify the body. + const shouldStringify = headers['Content-Type'] === 'application/json' + // Unlike fetchival, don't pollute the opts object we were given const res = await fetchival.fetch(url, { ...opts, method, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...opts.headers, - }, - ...(data ? { body: JSON.stringify(data) } : {}), + headers, + ...(data ? { body: shouldStringify ? JSON.stringify(data) : data } : {}), }) if (res.status >= 200 && res.status < 300) { From 97c549da6d4ce45498be05f8ed7f4b1d0fc82c62 Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Mon, 29 Sep 2025 22:09:47 +0300 Subject: [PATCH 2/4] test: add unit test --- test/create-fetchival.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/create-fetchival.js b/test/create-fetchival.js index c83d690..0684490 100644 --- a/test/create-fetchival.js +++ b/test/create-fetchival.js @@ -31,4 +31,28 @@ tape('createFetchival', (t) => { t.equals(err.message, 'Couldnt establish connection') } }) + + t.test('does not stringify body when Content-Type is not application/json', async (t) => { + let capturedOpts + const formData = 'name=John&age=30' + const fetch = async (url, opts) => { + capturedOpts = opts + return { status: 200, json: async () => ({ success: true }) } + } + + const fetchival = createFetchival({ fetch }) + + await fetchival('https://example.com')('api', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }).post(formData) + + t.equals( + capturedOpts.body, + formData, + 'body should not be stringified for non-JSON content type' + ) + t.equals(capturedOpts.headers['Content-Type'], 'application/x-www-form-urlencoded') + }) }) From ecc18df200c6b744d7693fc7d771c0385e885f77 Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Mon, 29 Sep 2025 22:32:42 +0300 Subject: [PATCH 3/4] test: one more unit test for defaul behavior --- test/create-fetchival.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/create-fetchival.js b/test/create-fetchival.js index 0684490..bfadb17 100644 --- a/test/create-fetchival.js +++ b/test/create-fetchival.js @@ -55,4 +55,24 @@ tape('createFetchival', (t) => { ) t.equals(capturedOpts.headers['Content-Type'], 'application/x-www-form-urlencoded') }) + + t.test('applies JSON.stringify to body by default', async (t) => { + let capturedOpts + const formData = 'name=John&age=30' + const fetch = async (url, opts) => { + capturedOpts = opts + return { status: 200, json: async () => ({ success: true }) } + } + + const fetchival = createFetchival({ fetch }) + + await fetchival('https://example.com')('api').post(formData) + + t.equals( + capturedOpts.body, + JSON.stringify(formData), + 'body should not be stringified for non-JSON content type' + ) + t.equals(capturedOpts.headers['Content-Type'], 'application/json') + }) }) From c78222f304fdddb1b7d954952d274a0ea8e4624a Mon Sep 17 00:00:00 2001 From: Kirill Bulgakov Date: Tue, 30 Sep 2025 13:28:47 +0300 Subject: [PATCH 4/4] refactor: reduce potential impact by only checking specific content-type --- create-fetchival.js | 6 ++++-- test/create-fetchival.js | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/create-fetchival.js b/create-fetchival.js index 53cb52f..98a5fad 100644 --- a/create-fetchival.js +++ b/create-fetchival.js @@ -8,6 +8,8 @@ function query(params) { .join('&')}` } +const prevenJsonStringifyContentTypes = new Set(['application/x-sentry-envelope']) + function createFetchival({ fetch }) { async function _fetch(method, url, opts, data) { // Unlike fetchival, don't silently ignore and override @@ -18,8 +20,8 @@ function createFetchival({ fetch }) { 'Content-Type': 'application/json', ...opts.headers, } - // If a consumer has set the non-json content type, we don't stringify the body. - const shouldStringify = headers['Content-Type'] === 'application/json' + // Allows some consumers to avoid JSON.stringify for their vendor-specific content types. + const shouldStringify = !prevenJsonStringifyContentTypes.has(headers['Content-Type']) // Unlike fetchival, don't pollute the opts object we were given const res = await fetchival.fetch(url, { diff --git a/test/create-fetchival.js b/test/create-fetchival.js index bfadb17..aa4d87e 100644 --- a/test/create-fetchival.js +++ b/test/create-fetchival.js @@ -32,7 +32,7 @@ tape('createFetchival', (t) => { } }) - t.test('does not stringify body when Content-Type is not application/json', async (t) => { + t.test('does not stringify body for specific Conent-Type headers', async (t) => { let capturedOpts const formData = 'name=John&age=30' const fetch = async (url, opts) => { @@ -44,7 +44,7 @@ tape('createFetchival', (t) => { await fetchival('https://example.com')('api', { headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Type': 'application/x-sentry-envelope', }, }).post(formData) @@ -53,7 +53,7 @@ tape('createFetchival', (t) => { formData, 'body should not be stringified for non-JSON content type' ) - t.equals(capturedOpts.headers['Content-Type'], 'application/x-www-form-urlencoded') + t.equals(capturedOpts.headers['Content-Type'], 'application/x-sentry-envelope') }) t.test('applies JSON.stringify to body by default', async (t) => {