From 90d9a73309df34984927ef782950b7c831926d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:59:29 +0000 Subject: [PATCH 1/5] fix(server): keep the response Content-Type when compressing Blob bodies For Blob/File bodies, ResponseCompressionHandlerPlugin ignored an existing Content-Type header: the compressibility check used `body.type` and the compressed response overwrote the header with `body.type`. The adapters only fall back to `body.type` when no header is set, so the same response got a different Content-Type depending on Accept-Encoding, and an untyped Blob with a compressible header was never compressed. Use the effective Content-Type (the existing header, falling back to `body.type`) for both the check and the output header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah --- .../docs/plugins/response-compression.mdx | 2 +- .../src/plugins/response-compression.test.ts | 167 ++++++++++++++++++ .../src/plugins/response-compression.ts | 7 +- 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/apps/content/docs/plugins/response-compression.mdx b/apps/content/docs/plugins/response-compression.mdx index 6e548da96d..56a73286a6 100644 --- a/apps/content/docs/plugins/response-compression.mdx +++ b/apps/content/docs/plugins/response-compression.mdx @@ -48,7 +48,7 @@ A [batch](/docs/plugins/batch) response might be left uncompressed here, dependi ### Customize Compressible Content Types -For binary transfers (streams, files, and file form-data parts), the plugin only compresses content types that benefit from compression. Override the check with `isCompressibleContentType`: +For binary transfers (streams, files, and file form-data parts), the plugin only compresses content types that benefit from compression. For a file body, the check uses the response's `Content-Type` header when one is set, and falls back to the file's own type otherwise. Override the check with `isCompressibleContentType`: ```ts handler import { ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' diff --git a/packages/server/src/plugins/response-compression.test.ts b/packages/server/src/plugins/response-compression.test.ts index 6ca850320f..9dd1658e8e 100644 --- a/packages/server/src/plugins/response-compression.test.ts +++ b/packages/server/src/plugins/response-compression.test.ts @@ -670,6 +670,173 @@ describe('responseCompressionHandlerPlugin', () => { expect(matched).toBe(true) expect(response!.headers.has('content-encoding')).toBe(false) }) + + it('should respect existing content-type header for compressed blob body', async () => { + const largeBlob = new Blob(['a,b\n'.repeat(500)], { type: 'text/plain' }) + const handler = new RPCHandler(os.handler(() => largeBlob), { + plugins: [ + { + name: 'set-content-type', + init(options) { + return { + ...options, + routingInterceptors: [ + async ({ next, ...interceptorOptions }) => { + const result = await next(interceptorOptions) + if (!result.matched) { + return result + } + return { + ...result, + response: { + ...result.response, + headers: { + ...result.response.headers, + 'content-type': 'text/csv; charset=utf-8', + }, + }, + } + }, + ...options.routingInterceptors ?? [], + ], + } + }, + }, + new ResponseCompressionHandlerPlugin({ threshold: 100 }), + ], + }) + + const { matched, response } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({ json: null }), + })) + + expect(matched).toBe(true) + expect(response!.headers.get('content-encoding')).toBe('gzip') + expect(response!.headers.get('content-type')).toBe('text/csv; charset=utf-8') + + await expect( + decompressStream(response!.body!, 'gzip'), + ).resolves.toEqual(await largeBlob.text()) + + // the uncompressed response carries the same content-type + const { response: uncompressedResponse } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + })) + + expect(uncompressedResponse!.headers.has('content-encoding')).toBe(false) + expect(uncompressedResponse!.headers.get('content-type')).toBe('text/csv; charset=utf-8') + }) + + it('should compress untyped blob body when existing content-type header is compressible', async () => { + const largeBlob = new Blob(['

large content

'.repeat(100)]) + const handler = new RPCHandler(os.handler(() => largeBlob), { + plugins: [ + { + name: 'set-content-type', + init(options) { + return { + ...options, + routingInterceptors: [ + async ({ next, ...interceptorOptions }) => { + const result = await next(interceptorOptions) + if (!result.matched) { + return result + } + return { + ...result, + response: { + ...result.response, + headers: { + ...result.response.headers, + 'content-type': 'text/html; charset=utf-8', + }, + }, + } + }, + ...options.routingInterceptors ?? [], + ], + } + }, + }, + new ResponseCompressionHandlerPlugin({ threshold: 100 }), + ], + }) + + const { matched, response } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({ json: null }), + })) + + expect(matched).toBe(true) + expect(response!.headers.get('content-encoding')).toBe('gzip') + expect(response!.headers.get('content-type')).toBe('text/html; charset=utf-8') + + await expect( + decompressStream(response!.body!, 'gzip'), + ).resolves.toEqual(await largeBlob.text()) + }) + + it('should not compress blob body when existing content-type header is non-compressible', async () => { + const largeBlob = new Blob(['large content'.repeat(100)], { type: 'text/plain' }) + const handler = new RPCHandler(os.handler(() => largeBlob), { + plugins: [ + { + name: 'set-content-type', + init(options) { + return { + ...options, + routingInterceptors: [ + async ({ next, ...interceptorOptions }) => { + const result = await next(interceptorOptions) + if (!result.matched) { + return result + } + return { + ...result, + response: { + ...result.response, + headers: { + ...result.response.headers, + 'content-type': 'image/png', + }, + }, + } + }, + ...options.routingInterceptors ?? [], + ], + } + }, + }, + new ResponseCompressionHandlerPlugin({ threshold: 100 }), + ], + }) + + const { matched, response } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({ json: null }), + })) + + expect(matched).toBe(true) + expect(response!.headers.has('content-encoding')).toBe(false) + expect(response!.headers.get('content-type')).toBe('image/png') + }) }) describe('readable stream body', () => { diff --git a/packages/server/src/plugins/response-compression.ts b/packages/server/src/plugins/response-compression.ts index 230ac8c40a..503512c77d 100644 --- a/packages/server/src/plugins/response-compression.ts +++ b/packages/server/src/plugins/response-compression.ts @@ -102,9 +102,12 @@ export class ResponseCompressionHandlerPlugin implements Stan } else if (body instanceof Blob) { + // Adapters only fall back to the blob type when no Content-Type header is set + const contentType = flattenStandardHeader(headers['content-type']) ?? body.type + if ( (!Number.isFinite(body.size) || body.size >= this.threshold) - && this.isCompressibleContentType(body.type, interceptorOptions) + && this.isCompressibleContentType(contentType, interceptorOptions) ) { const contentDisposition = headers['content-disposition'] ?? generateContentDisposition( body instanceof File ? body.name : 'blob', @@ -118,7 +121,7 @@ export class ResponseCompressionHandlerPlugin implements Stan headers: { ...toCompressedHeaders(headers, encoding), 'standard-server': 'file' satisfies StandardBodyHint, - 'content-type': body.type, + 'content-type': contentType, 'content-disposition': contentDisposition, }, }, From 9468befccd5f9af1eb41f479c6bd22ff950a1c39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:06:24 +0000 Subject: [PATCH 2/5] test(server): fold blob content-type header tests into one table The three tests differed only in the blob, the injected Content-Type header and the expected encoding, so they now share one `it.each` and a single copy of the header-injecting plugin. Drop the extra uncompressed request, which only re-checked adapter behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah --- .../src/plugins/response-compression.test.ts | 133 ++---------------- 1 file changed, 11 insertions(+), 122 deletions(-) diff --git a/packages/server/src/plugins/response-compression.test.ts b/packages/server/src/plugins/response-compression.test.ts index 9dd1658e8e..4d96c8d618 100644 --- a/packages/server/src/plugins/response-compression.test.ts +++ b/packages/server/src/plugins/response-compression.test.ts @@ -671,74 +671,12 @@ describe('responseCompressionHandlerPlugin', () => { expect(response!.headers.has('content-encoding')).toBe(false) }) - it('should respect existing content-type header for compressed blob body', async () => { - const largeBlob = new Blob(['a,b\n'.repeat(500)], { type: 'text/plain' }) - const handler = new RPCHandler(os.handler(() => largeBlob), { - plugins: [ - { - name: 'set-content-type', - init(options) { - return { - ...options, - routingInterceptors: [ - async ({ next, ...interceptorOptions }) => { - const result = await next(interceptorOptions) - if (!result.matched) { - return result - } - return { - ...result, - response: { - ...result.response, - headers: { - ...result.response.headers, - 'content-type': 'text/csv; charset=utf-8', - }, - }, - } - }, - ...options.routingInterceptors ?? [], - ], - } - }, - }, - new ResponseCompressionHandlerPlugin({ threshold: 100 }), - ], - }) - - const { matched, response } = await handler.handle(new Request('http://localhost', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept-encoding': 'gzip', - }, - body: JSON.stringify({ json: null }), - })) - - expect(matched).toBe(true) - expect(response!.headers.get('content-encoding')).toBe('gzip') - expect(response!.headers.get('content-type')).toBe('text/csv; charset=utf-8') - - await expect( - decompressStream(response!.body!, 'gzip'), - ).resolves.toEqual(await largeBlob.text()) - - // the uncompressed response carries the same content-type - const { response: uncompressedResponse } = await handler.handle(new Request('http://localhost', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) - - expect(uncompressedResponse!.headers.has('content-encoding')).toBe(false) - expect(uncompressedResponse!.headers.get('content-type')).toBe('text/csv; charset=utf-8') - }) - - it('should compress untyped blob body when existing content-type header is compressible', async () => { - const largeBlob = new Blob(['

large content

'.repeat(100)]) - const handler = new RPCHandler(os.handler(() => largeBlob), { + it.each([ + ['keeps the header instead of the blob type', new Blob(['a,b\n'.repeat(500)], { type: 'text/plain' }), 'text/csv; charset=utf-8', 'gzip'], + ['compresses an untyped blob when the header is compressible', new Blob(['

large content

'.repeat(100)]), 'text/html; charset=utf-8', 'gzip'], + ['does not compress when the header is non-compressible', new Blob(['large content'.repeat(100)], { type: 'text/plain' }), 'image/png', null], + ] as const)('uses the existing content-type header for blob body: %s', async (_label, blob, contentType, encoding) => { + const handler = new RPCHandler(os.handler(() => blob), { plugins: [ { name: 'set-content-type', @@ -757,7 +695,7 @@ describe('responseCompressionHandlerPlugin', () => { ...result.response, headers: { ...result.response.headers, - 'content-type': 'text/html; charset=utf-8', + 'content-type': contentType, }, }, } @@ -781,61 +719,12 @@ describe('responseCompressionHandlerPlugin', () => { })) expect(matched).toBe(true) - expect(response!.headers.get('content-encoding')).toBe('gzip') - expect(response!.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(response!.headers.get('content-encoding')).toBe(encoding) + expect(response!.headers.get('content-type')).toBe(contentType) await expect( - decompressStream(response!.body!, 'gzip'), - ).resolves.toEqual(await largeBlob.text()) - }) - - it('should not compress blob body when existing content-type header is non-compressible', async () => { - const largeBlob = new Blob(['large content'.repeat(100)], { type: 'text/plain' }) - const handler = new RPCHandler(os.handler(() => largeBlob), { - plugins: [ - { - name: 'set-content-type', - init(options) { - return { - ...options, - routingInterceptors: [ - async ({ next, ...interceptorOptions }) => { - const result = await next(interceptorOptions) - if (!result.matched) { - return result - } - return { - ...result, - response: { - ...result.response, - headers: { - ...result.response.headers, - 'content-type': 'image/png', - }, - }, - } - }, - ...options.routingInterceptors ?? [], - ], - } - }, - }, - new ResponseCompressionHandlerPlugin({ threshold: 100 }), - ], - }) - - const { matched, response } = await handler.handle(new Request('http://localhost', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept-encoding': 'gzip', - }, - body: JSON.stringify({ json: null }), - })) - - expect(matched).toBe(true) - expect(response!.headers.has('content-encoding')).toBe(false) - expect(response!.headers.get('content-type')).toBe('image/png') + encoding === null ? response!.text() : decompressStream(response!.body!, encoding), + ).resolves.toEqual(await blob.text()) }) }) From dbb893f2fb95a12e2badb91ff1e50bd71e16bb2d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:55:41 +0000 Subject: [PATCH 3/5] fix(server): match the adapters' Content-Type fallback for Blob bodies Use `headers['content-type'] ?? body.type` as is, like the adapters' `??=`, and flatten it only for the compressibility check. Flattening first turned an empty-array header into `undefined` and fell back to the blob type, so a response the Node adapter sends without a Content-Type was compressed and sent with `body.type`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah --- .../src/plugins/response-compression.test.ts | 27 +++++++++++++++++++ .../src/plugins/response-compression.ts | 4 +-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/server/src/plugins/response-compression.test.ts b/packages/server/src/plugins/response-compression.test.ts index 4d96c8d618..2d4777029a 100644 --- a/packages/server/src/plugins/response-compression.test.ts +++ b/packages/server/src/plugins/response-compression.test.ts @@ -726,6 +726,33 @@ describe('responseCompressionHandlerPlugin', () => { encoding === null ? response!.text() : decompressStream(response!.body!, encoding), ).resolves.toEqual(await blob.text()) }) + + it('should not fall back to the blob type when content-type header is an empty array', async () => { + const largeBlob = new Blob(['large content'.repeat(100)], { type: 'text/plain' }) + const handler = new RPCHandler(os.handler(() => largeBlob), { + plugins: [new ResponseCompressionHandlerPlugin({ threshold: 100 })], + routingInterceptors: [async ({ next }) => { + const result = await next() + + return result.matched + ? { ...result, response: { ...result.response, headers: { ...result.response.headers, 'content-type': [] } } } + : result + }], + }) + + const { matched, response } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({ json: null }), + })) + + expect(matched).toBe(true) + // Like the adapters' `??=`, an empty array counts as a set header, so the type is unknown + expect(response!.headers.has('content-encoding')).toBe(false) + }) }) describe('readable stream body', () => { diff --git a/packages/server/src/plugins/response-compression.ts b/packages/server/src/plugins/response-compression.ts index 503512c77d..e61aea7ddf 100644 --- a/packages/server/src/plugins/response-compression.ts +++ b/packages/server/src/plugins/response-compression.ts @@ -103,11 +103,11 @@ export class ResponseCompressionHandlerPlugin implements Stan else if (body instanceof Blob) { // Adapters only fall back to the blob type when no Content-Type header is set - const contentType = flattenStandardHeader(headers['content-type']) ?? body.type + const contentType = headers['content-type'] ?? body.type if ( (!Number.isFinite(body.size) || body.size >= this.threshold) - && this.isCompressibleContentType(contentType, interceptorOptions) + && this.isCompressibleContentType(flattenStandardHeader(contentType), interceptorOptions) ) { const contentDisposition = headers['content-disposition'] ?? generateContentDisposition( body instanceof File ? body.name : 'blob', From 2e0fb70ef5b381cf49183b523c295161192feac7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:57:54 +0000 Subject: [PATCH 4/5] docs(server): drop the Blob Content-Type note from response compression Using the existing Content-Type header is the expected behavior, so it does not need its own mention. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah --- apps/content/docs/plugins/response-compression.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/plugins/response-compression.mdx b/apps/content/docs/plugins/response-compression.mdx index 56a73286a6..6e548da96d 100644 --- a/apps/content/docs/plugins/response-compression.mdx +++ b/apps/content/docs/plugins/response-compression.mdx @@ -48,7 +48,7 @@ A [batch](/docs/plugins/batch) response might be left uncompressed here, dependi ### Customize Compressible Content Types -For binary transfers (streams, files, and file form-data parts), the plugin only compresses content types that benefit from compression. For a file body, the check uses the response's `Content-Type` header when one is set, and falls back to the file's own type otherwise. Override the check with `isCompressibleContentType`: +For binary transfers (streams, files, and file form-data parts), the plugin only compresses content types that benefit from compression. Override the check with `isCompressibleContentType`: ```ts handler import { ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' From 5ae6f202a255e1483adca12e038a2cde66eea8d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:02:07 +0000 Subject: [PATCH 5/5] test(server): simplify blob content-type header tests Cover all four cases in one table that sets the header with a handler routing interceptor, and check that a compressed response carries the same Content-Type as the uncompressed one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah --- .../src/plugins/response-compression.test.ts | 79 ++++--------------- 1 file changed, 15 insertions(+), 64 deletions(-) diff --git a/packages/server/src/plugins/response-compression.test.ts b/packages/server/src/plugins/response-compression.test.ts index 2d4777029a..3e912bff36 100644 --- a/packages/server/src/plugins/response-compression.test.ts +++ b/packages/server/src/plugins/response-compression.test.ts @@ -672,86 +672,37 @@ describe('responseCompressionHandlerPlugin', () => { }) it.each([ - ['keeps the header instead of the blob type', new Blob(['a,b\n'.repeat(500)], { type: 'text/plain' }), 'text/csv; charset=utf-8', 'gzip'], - ['compresses an untyped blob when the header is compressible', new Blob(['

large content

'.repeat(100)]), 'text/html; charset=utf-8', 'gzip'], - ['does not compress when the header is non-compressible', new Blob(['large content'.repeat(100)], { type: 'text/plain' }), 'image/png', null], - ] as const)('uses the existing content-type header for blob body: %s', async (_label, blob, contentType, encoding) => { - const handler = new RPCHandler(os.handler(() => blob), { - plugins: [ - { - name: 'set-content-type', - init(options) { - return { - ...options, - routingInterceptors: [ - async ({ next, ...interceptorOptions }) => { - const result = await next(interceptorOptions) - if (!result.matched) { - return result - } - return { - ...result, - response: { - ...result.response, - headers: { - ...result.response.headers, - 'content-type': contentType, - }, - }, - } - }, - ...options.routingInterceptors ?? [], - ], - } - }, - }, - new ResponseCompressionHandlerPlugin({ threshold: 100 }), - ], - }) - - const { matched, response } = await handler.handle(new Request('http://localhost', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept-encoding': 'gzip', - }, - body: JSON.stringify({ json: null }), - })) - - expect(matched).toBe(true) - expect(response!.headers.get('content-encoding')).toBe(encoding) - expect(response!.headers.get('content-type')).toBe(contentType) - - await expect( - encoding === null ? response!.text() : decompressStream(response!.body!, encoding), - ).resolves.toEqual(await blob.text()) - }) - - it('should not fall back to the blob type when content-type header is an empty array', async () => { - const largeBlob = new Blob(['large content'.repeat(100)], { type: 'text/plain' }) - const handler = new RPCHandler(os.handler(() => largeBlob), { + ['text/csv; charset=utf-8', 'text/plain', 'gzip'], + ['text/html; charset=utf-8', '', 'gzip'], + ['image/png', 'text/plain', null], + [[], 'text/plain', null], + ])('uses content-type header %j over blob type %j', async (contentType, blobType, encoding) => { + const handler = new RPCHandler(os.handler(() => new Blob(['large content'.repeat(100)], { type: blobType })), { plugins: [new ResponseCompressionHandlerPlugin({ threshold: 100 })], routingInterceptors: [async ({ next }) => { const result = await next() return result.matched - ? { ...result, response: { ...result.response, headers: { ...result.response.headers, 'content-type': [] } } } + ? { ...result, response: { ...result.response, headers: { ...result.response.headers, 'content-type': contentType } } } : result }], }) - const { matched, response } = await handler.handle(new Request('http://localhost', { + const handle = (acceptEncoding: string) => handler.handle(new Request('http://localhost', { method: 'POST', headers: { 'content-type': 'application/json', - 'accept-encoding': 'gzip', + 'accept-encoding': acceptEncoding, }, body: JSON.stringify({ json: null }), })) - expect(matched).toBe(true) - // Like the adapters' `??=`, an empty array counts as a set header, so the type is unknown - expect(response!.headers.has('content-encoding')).toBe(false) + const { response } = await handle('gzip') + const { response: uncompressed } = await handle('identity') + + expect(response!.headers.get('content-encoding')).toBe(encoding) + // Same content-type the adapter sends when the response is not compressed + expect(response!.headers.get('content-type')).toBe(uncompressed!.headers.get('content-type')) }) })