From c8da4f521748534a68299221a8c70f59d8279a74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 07:54:09 +0000 Subject: [PATCH 1/5] feat(fetch, node)!: drop the content-type of an empty body `toFetchBody` and `toNodeHttpBody` now drop a given `content-type` when the body is `undefined`, like the peer adapter, instead of keeping it and adding `standard-server: none`. An empty body needs no hint, so it reads back as `undefined` from the content headers alone, even where the receiver can't see `standard-server` (e.g. a cross-origin browser client). Keeping it bought nothing for HEAD either: the hint and the cleared content-length already made the headers differ from GET. To send headers without content, send an empty Blob with `standard-server: none` (and `content-disposition: []`); for HEAD, send the same body as GET. Express 4/5, Fastify, h3, Hono, srvx, node:http/https/http2 and Bun.serve all drop a HEAD response's bytes and keep its headers, including content-length. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7QvHcuoeP7ZfCahKtA --- packages/core/README.md | 4 +++- packages/fetch/src/body.test.ts | 35 ++++++++++++++++++++++++++++++++- packages/fetch/src/body.ts | 3 ++- packages/node/src/body.test.ts | 16 ++++++++++++++- packages/node/src/body.ts | 3 ++- tests/data-transfer.test.ts | 2 +- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index f194557..4800eb6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -228,7 +228,9 @@ The `standard-server` header closes this gap. It carries the sender's `StandardB Adapters set the header automatically for the ambiguous body types: a `Blob` or `File` body is sent with `standard-server: file`, and a `ReadableStream` body with `standard-server: octet-stream`, alongside the usual content headers. A header you set yourself always wins, and assigning an empty array removes it entirely. -Other body types get their usual `content-type` (`application/json`, `application/x-www-form-urlencoded`, `text/event-stream`, or none when empty) unless you set one. If yours would be read as another body type, such as JSON sent as `application/problem+json`, adapters add the matching `standard-server` header; otherwise they clear it. `FormData` always gets its generated multipart `content-type`, which carries the boundary. +Other body types get their usual `content-type` (`application/json`, `application/x-www-form-urlencoded`, or `text/event-stream`) unless you set one. If yours would be read as another body type, such as JSON sent as `application/problem+json`, adapters add the matching `standard-server` header; otherwise they clear it. `FormData` always gets its generated multipart `content-type`, which carries the boundary. + +An empty (`undefined`) body is sent without `content-type`. To keep one, send an empty `Blob` with `standard-server: none`; set `content-disposition` to an empty array to drop the generated one. For a `HEAD` response, you can also send the same body as `GET`: HTTP servers drop the bytes and keep the headers, so a `Blob` still reports its `content-length`. The header is optional: when it is absent or invalid, the receiver falls back to content-header inference, so plain HTTP clients work as-is. Just set the header yourself whenever the content type alone could be misread: diff --git a/packages/fetch/src/body.test.ts b/packages/fetch/src/body.test.ts index 33690ec..fa6d8f0 100644 --- a/packages/fetch/src/body.test.ts +++ b/packages/fetch/src/body.test.ts @@ -324,6 +324,40 @@ describe('toFetchBody', () => { }) }) + it('undefined drops a given content-type and hint', () => { + const [body, headers] = toFetchBody(undefined, { + ...baseHeaders, + 'content-type': 'text/html', + 'standard-server': 'file', + }) + + expect(body).toBe(undefined) + expect(headers).toEqual({ + 'content-type': undefined, + 'standard-server': undefined, + 'x-custom-header': 'custom-value', + }) + }) + + it('empty blob with standard-server none keeps a content-type without a body', async () => { + const [body, headers] = toFetchBody(new Blob([], { type: 'text/html' }), { + 'standard-server': 'none', + 'content-disposition': [], + }) + + expect(headers).toEqual({ + 'content-type': 'text/html', + 'content-length': '0', + 'content-disposition': [], + 'standard-server': 'none', + }) + + const response = new Response(body, { headers: toFetchHeaders(headers) }) + expect(response.headers.get('content-type')).toBe('text/html') + expect(response.headers.has('content-disposition')).toBe(false) + expect(await toStandardBody(response)).toBe(undefined) + }) + it('json', () => { const [body, headers] = toFetchBody({ foo: 'bar' }, baseHeaders, {}) @@ -554,7 +588,6 @@ describe('toFetchBody', () => { ['url-search-params as text', new URLSearchParams('a=b'), 'text/plain', 'url-search-params'], ['event stream as event-stream', (async function* () {})(), 'text/event-stream', undefined], ['event stream as text', (async function* () {})(), 'text/plain', 'event-stream'], - ['undefined as html', undefined, 'text/html', 'none'], ])('keeps a given content-type and replaces a user hint: %s', (_, body, contentType, hint) => { const headers = { 'content-type': contentType, 'standard-server': 'file' } diff --git a/packages/fetch/src/body.ts b/packages/fetch/src/body.ts index c02868e..1f14055 100644 --- a/packages/fetch/src/body.ts +++ b/packages/fetch/src/body.ts @@ -145,8 +145,9 @@ export function toFetchBody( headers['standard-server'] = undefined headers['content-length'] = undefined + // An empty body has no media type. Send an empty Blob with `standard-server: none` to keep one. if (body === undefined) { - headers['standard-server'] = getRequiredStandardBodyHint('none', headers) + headers['content-type'] = undefined return [undefined, headers] } diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index 694cbef..28ec835 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -599,6 +599,21 @@ describe('toNodeHttpBody', () => { }) }) + it('undefined drops a given content-type and hint', () => { + const [body, headers] = toNodeHttpBody(undefined, { + ...baseHeaders, + 'content-type': 'text/html', + 'standard-server': 'file', + }) + + expect(body).toBe(undefined) + expect(headers).toEqual({ + 'content-type': undefined, + 'standard-server': undefined, + 'x-custom-header': 'custom-value', + }) + }) + it('json', async () => { const [body, headers] = toNodeHttpBody({ foo: 'bar' }, baseHeaders, {}) @@ -948,7 +963,6 @@ describe('toNodeHttpBody', () => { ['url-search-params as text', new URLSearchParams('a=b'), 'text/plain', 'url-search-params'], ['event stream as event-stream', (async function* () {})(), 'text/event-stream', undefined], ['event stream as text', (async function* () {})(), 'text/plain', 'event-stream'], - ['undefined as html', undefined, 'text/html', 'none'], ])('keeps a given content-type and replaces a user hint: %s', (_, body, contentType, hint) => { const headers = { 'content-type': contentType, 'standard-server': 'file' } diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index 638616c..03eb047 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -146,8 +146,9 @@ export function toNodeHttpBody( headers['standard-server'] = undefined headers['content-length'] = undefined + // An empty body has no media type. Send an empty Blob with `standard-server: none` to keep one. if (body === undefined) { - headers['standard-server'] = getRequiredStandardBodyHint('none', headers) + headers['content-type'] = undefined return [undefined, headers] } diff --git a/tests/data-transfer.test.ts b/tests/data-transfer.test.ts index 62e9742..e11fe92 100644 --- a/tests/data-transfer.test.ts +++ b/tests/data-transfer.test.ts @@ -199,7 +199,7 @@ describe.each([ }, }, { - name: 'undefined as text/html', + name: 'undefined drops text/html', createBody: () => undefined, contentType: 'text/html', }, From 48d86f835e44d3c97683304137f4e31a7b4cfe78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:06:04 +0000 Subject: [PATCH 2/5] refactor(fetch, node): trim tests and docs for the empty-body content-type - Fold the "drops a given content-type" check into the existing `undefined` encoder tests instead of a near-copy next to them. - Remove the fetch-only empty-Blob round-trip test, which re-tested the unchanged Blob branch, and the `undefined as text/html` e2e row, which only asserted what the plain `undefined` row already does. - Say in the encoder comment why an empty body drops the content-type, unlike the other bodies, instead of repeating the README. - Shorten the README note and drop the HEAD advice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7QvHcuoeP7ZfCahKtA --- packages/core/README.md | 2 +- packages/fetch/src/body.test.ts | 41 +++++---------------------------- packages/fetch/src/body.ts | 2 +- packages/node/src/body.test.ts | 22 +++++------------- packages/node/src/body.ts | 2 +- tests/data-transfer.test.ts | 5 ---- 6 files changed, 15 insertions(+), 59 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 4800eb6..1042013 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -230,7 +230,7 @@ Adapters set the header automatically for the ambiguous body types: a `Blob` or Other body types get their usual `content-type` (`application/json`, `application/x-www-form-urlencoded`, or `text/event-stream`) unless you set one. If yours would be read as another body type, such as JSON sent as `application/problem+json`, adapters add the matching `standard-server` header; otherwise they clear it. `FormData` always gets its generated multipart `content-type`, which carries the boundary. -An empty (`undefined`) body is sent without `content-type`. To keep one, send an empty `Blob` with `standard-server: none`; set `content-disposition` to an empty array to drop the generated one. For a `HEAD` response, you can also send the same body as `GET`: HTTP servers drop the bytes and keep the headers, so a `Blob` still reports its `content-length`. +An empty body is always sent without `content-type`. To send one with no body, use an empty `Blob` with `standard-server: none` and `content-disposition: []`. The header is optional: when it is absent or invalid, the receiver falls back to content-header inference, so plain HTTP clients work as-is. Just set the header yourself whenever the content type alone could be misread: diff --git a/packages/fetch/src/body.test.ts b/packages/fetch/src/body.test.ts index fa6d8f0..ad3e807 100644 --- a/packages/fetch/src/body.test.ts +++ b/packages/fetch/src/body.test.ts @@ -316,48 +316,19 @@ describe('toFetchBody', () => { } it('undefined', () => { - const [body, headers] = toFetchBody(undefined, baseHeaders, {}) - - expect(body).toBe(undefined) - expect(headers).toEqual({ - 'x-custom-header': 'custom-value', - }) - }) - - it('undefined drops a given content-type and hint', () => { - const [body, headers] = toFetchBody(undefined, { - ...baseHeaders, - 'content-type': 'text/html', - 'standard-server': 'file', - }) + // a given content-type and hint are dropped + const [body, headers] = toFetchBody( + undefined, + { ...baseHeaders, 'content-type': 'text/html', 'standard-server': 'file' }, + {}, + ) expect(body).toBe(undefined) expect(headers).toEqual({ - 'content-type': undefined, - 'standard-server': undefined, 'x-custom-header': 'custom-value', }) }) - it('empty blob with standard-server none keeps a content-type without a body', async () => { - const [body, headers] = toFetchBody(new Blob([], { type: 'text/html' }), { - 'standard-server': 'none', - 'content-disposition': [], - }) - - expect(headers).toEqual({ - 'content-type': 'text/html', - 'content-length': '0', - 'content-disposition': [], - 'standard-server': 'none', - }) - - const response = new Response(body, { headers: toFetchHeaders(headers) }) - expect(response.headers.get('content-type')).toBe('text/html') - expect(response.headers.has('content-disposition')).toBe(false) - expect(await toStandardBody(response)).toBe(undefined) - }) - it('json', () => { const [body, headers] = toFetchBody({ foo: 'bar' }, baseHeaders, {}) diff --git a/packages/fetch/src/body.ts b/packages/fetch/src/body.ts index 1f14055..075b800 100644 --- a/packages/fetch/src/body.ts +++ b/packages/fetch/src/body.ts @@ -145,7 +145,7 @@ export function toFetchBody( headers['standard-server'] = undefined headers['content-length'] = undefined - // An empty body has no media type. Send an empty Blob with `standard-server: none` to keep one. + // An empty body has no media type, so unlike the bodies below it drops a given content-type if (body === undefined) { headers['content-type'] = undefined return [undefined, headers] diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index 28ec835..ae54d67 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -591,25 +591,15 @@ describe('toNodeHttpBody', () => { } it('undefined', async () => { - const [body, headers] = toNodeHttpBody(undefined, baseHeaders, {}) - - expect(body).toBe(undefined) - expect(headers).toEqual({ - 'x-custom-header': 'custom-value', - }) - }) - - it('undefined drops a given content-type and hint', () => { - const [body, headers] = toNodeHttpBody(undefined, { - ...baseHeaders, - 'content-type': 'text/html', - 'standard-server': 'file', - }) + // a given content-type and hint are dropped + const [body, headers] = toNodeHttpBody( + undefined, + { ...baseHeaders, 'content-type': 'text/html', 'standard-server': 'file' }, + {}, + ) expect(body).toBe(undefined) expect(headers).toEqual({ - 'content-type': undefined, - 'standard-server': undefined, 'x-custom-header': 'custom-value', }) }) diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index 03eb047..26b8957 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -146,7 +146,7 @@ export function toNodeHttpBody( headers['standard-server'] = undefined headers['content-length'] = undefined - // An empty body has no media type. Send an empty Blob with `standard-server: none` to keep one. + // An empty body has no media type, so unlike the bodies below it drops a given content-type if (body === undefined) { headers['content-type'] = undefined return [undefined, headers] diff --git a/tests/data-transfer.test.ts b/tests/data-transfer.test.ts index e11fe92..db4e0dc 100644 --- a/tests/data-transfer.test.ts +++ b/tests/data-transfer.test.ts @@ -198,11 +198,6 @@ describe.each([ await expect(reader.read()).resolves.toEqual({ done: true }) }, }, - { - name: 'undefined drops text/html', - createBody: () => undefined, - contentType: 'text/html', - }, { name: 'json-object as application/problem+json', createBody: () => ({ a: 1, b: [2, 3, { c: 4 }] }), From 7b87c024c6ff371221642b46b9bce81251b7c3ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:13:04 +0000 Subject: [PATCH 3/5] docs(core): send an empty response with headers as an already-closed stream Recommend an already-closed `ReadableStream` with `standard-server: none` instead of an empty Blob: it needs no `content-disposition: []`, and it works as a HEAD response on every HTTP adapter (Express, Fastify, h3, Hono, srvx, node:http/https/http2, Bun), reading back as `undefined`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7QvHcuoeP7ZfCahKtA --- packages/core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/README.md b/packages/core/README.md index 1042013..da0974c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -230,7 +230,7 @@ Adapters set the header automatically for the ambiguous body types: a `Blob` or Other body types get their usual `content-type` (`application/json`, `application/x-www-form-urlencoded`, or `text/event-stream`) unless you set one. If yours would be read as another body type, such as JSON sent as `application/problem+json`, adapters add the matching `standard-server` header; otherwise they clear it. `FormData` always gets its generated multipart `content-type`, which carries the boundary. -An empty body is always sent without `content-type`. To send one with no body, use an empty `Blob` with `standard-server: none` and `content-disposition: []`. +An empty body is always sent without `content-type`. To send an empty response with your own headers, such as for `HEAD`, use an already-closed `ReadableStream` with `standard-server: none`, so it still reads back as `undefined`. The header is optional: when it is absent or invalid, the receiver falls back to content-header inference, so plain HTTP clients work as-is. Just set the header yourself whenever the content type alone could be misread: From be7fb815667c0e7a227f6de21da6698b710e4837 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:32:24 +0000 Subject: [PATCH 4/5] docs(core): keep the empty-response tip general Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7QvHcuoeP7ZfCahKtA --- packages/core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/README.md b/packages/core/README.md index da0974c..37544aa 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -230,7 +230,7 @@ Adapters set the header automatically for the ambiguous body types: a `Blob` or Other body types get their usual `content-type` (`application/json`, `application/x-www-form-urlencoded`, or `text/event-stream`) unless you set one. If yours would be read as another body type, such as JSON sent as `application/problem+json`, adapters add the matching `standard-server` header; otherwise they clear it. `FormData` always gets its generated multipart `content-type`, which carries the boundary. -An empty body is always sent without `content-type`. To send an empty response with your own headers, such as for `HEAD`, use an already-closed `ReadableStream` with `standard-server: none`, so it still reads back as `undefined`. +An empty body is always sent without `content-type`. To send an empty response with your own headers, such as for `HEAD`, use an already-closed `ReadableStream`. The header is optional: when it is absent or invalid, the receiver falls back to content-header inference, so plain HTTP clients work as-is. Just set the header yourself whenever the content type alone could be misread: From 17c7e2294c687d97d7b6475787888c3bd67f55a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:38:05 +0000 Subject: [PATCH 5/5] chore(fetch, node): remove the empty-body comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7QvHcuoeP7ZfCahKtA --- packages/fetch/src/body.ts | 1 - packages/node/src/body.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/fetch/src/body.ts b/packages/fetch/src/body.ts index 075b800..bf7fd6a 100644 --- a/packages/fetch/src/body.ts +++ b/packages/fetch/src/body.ts @@ -145,7 +145,6 @@ export function toFetchBody( headers['standard-server'] = undefined headers['content-length'] = undefined - // An empty body has no media type, so unlike the bodies below it drops a given content-type if (body === undefined) { headers['content-type'] = undefined return [undefined, headers] diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index 26b8957..04008e2 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -146,7 +146,6 @@ export function toNodeHttpBody( headers['standard-server'] = undefined headers['content-length'] = undefined - // An empty body has no media type, so unlike the bodies below it drops a given content-type if (body === undefined) { headers['content-type'] = undefined return [undefined, headers]