From c0a4f6692ae08274242f676aafedf3ddc7a08a80 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 14 Aug 2025 11:04:13 +0700 Subject: [PATCH 1/2] feat(server): compression plugin filter option should override default content type filter --- .../adapters/fetch/compression-plugin.test.ts | 77 ++++++++++++++++++- .../src/adapters/fetch/compression-plugin.ts | 28 +++++-- .../adapters/node/compression-plugin.test.ts | 66 +++++++++++++++- .../src/adapters/node/compression-plugin.ts | 19 +++-- 4 files changed, 172 insertions(+), 18 deletions(-) diff --git a/packages/server/src/adapters/fetch/compression-plugin.test.ts b/packages/server/src/adapters/fetch/compression-plugin.test.ts index 3b7b0ceb8..5c3130788 100644 --- a/packages/server/src/adapters/fetch/compression-plugin.test.ts +++ b/packages/server/src/adapters/fetch/compression-plugin.test.ts @@ -313,6 +313,36 @@ describe('compressionPlugin', () => { expect(response?.status).toBe(204) }) + it('should not compress when response has no content-type', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + strictGetMethodPluginEnabled: false, + plugins: [ + new CompressionPlugin(), + ], + adapterInterceptors: [ + async (options) => { + const result = await options.next() + result.response?.headers.delete('content-type') // Simulate no content-type + return result + }, + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({}), + })) + + expect(response?.headers.has('content-encoding')).toBe(false) + expect(response?.headers.has('content-type')).toBe(false) + expect(response?.status).toBe(200) + await expect(response?.text()).resolves.toContain('output') + }) + it('should not compress non-compressible content types', async () => { const handler = new RPCHandler(os.handler(() => 'output'), { plugins: [ @@ -492,7 +522,50 @@ describe('compressionPlugin', () => { expect(response).toBeUndefined() }) - it('should not compress when custom filter returns false', async () => { + it('should override filter and compress if filter return true', async () => { + const filter = vi.fn(() => true) + + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + new CompressionPlugin({ + filter, + }), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + // image/jpeg is not compressible by default + body: new Blob([largeText], { type: 'image/jpeg' }), + }, + } + }, + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({}), + })) + + expect(response?.headers.get('content-encoding')).toBe('gzip') + + expect(filter).toHaveBeenCalledWith(expect.any(Request), expect.any(Response)) + }) + + it('should not compress when filter returns false', async () => { const filter = vi.fn(() => false) const handler = new RPCHandler(os.handler(() => largeText), { @@ -524,7 +597,7 @@ describe('compressionPlugin', () => { yield 'event2' }), { plugins: [ - new CompressionPlugin(), + new CompressionPlugin({ filter: () => true }), ], }) diff --git a/packages/server/src/adapters/fetch/compression-plugin.ts b/packages/server/src/adapters/fetch/compression-plugin.ts index 0d10b94f4..0072b72ab 100644 --- a/packages/server/src/adapters/fetch/compression-plugin.ts +++ b/packages/server/src/adapters/fetch/compression-plugin.ts @@ -28,9 +28,10 @@ export interface CompressionPluginOptions { threshold?: number /** - * A filter function to determine if a response should be compressed. - * This function is called in addition to the default compression checks - * and allows for custom compression logic based on the request and response. + * Override the default content-type filter used to determine which responses should be compressed. + * + * @warning [Event Iterator](https://orpc.unnoq.com/docs/event-iterator) responses are never compressed, regardless of this filter's return value. + * @default only responses with compressible content types are compressed. */ filter?: (request: Request, response: Response) => boolean } @@ -45,12 +46,26 @@ export interface CompressionPluginOptions { export class CompressionPlugin implements FetchHandlerPlugin { private readonly encodings: Exclude private readonly threshold: Exclude - private readonly filter: CompressionPluginOptions['filter'] + private readonly filter: Exclude constructor(options: CompressionPluginOptions = {}) { this.encodings = options.encodings ?? ORDERED_SUPPORTED_ENCODINGS this.threshold = options.threshold ?? 1024 - this.filter = options.filter + this.filter = (request, response) => { + const hasContentDisposition = response.headers.has('content-disposition') + const contentType = response.headers.get('content-type') + + /** + * Never compress Event Iterator responses. + */ + if (!hasContentDisposition && contentType?.startsWith('text/event-stream')) { + return false + } + + return options.filter + ? options.filter(request, response) + : isCompressibleContentType(contentType) + } } initRuntimeAdapter(options: FetchHandlerOptions): void { @@ -71,7 +86,6 @@ export class CompressionPlugin implements FetchHandlerPlugin< if ( response.headers.has('content-encoding') // already encoded || response.headers.has('transfer-encoding') // already encoded or chunked - || !isCompressibleContentType(response.headers.get('content-type')) // not compressible || isNoTransformCacheControl(response.headers.get('cache-control')) // no-transform directive ) { return result @@ -93,7 +107,7 @@ export class CompressionPlugin implements FetchHandlerPlugin< return result } - if (this.filter && !this.filter(options.request, response)) { + if (!this.filter(options.request, response)) { return result } diff --git a/packages/server/src/adapters/node/compression-plugin.test.ts b/packages/server/src/adapters/node/compression-plugin.test.ts index 1398926cf..ae8324df5 100644 --- a/packages/server/src/adapters/node/compression-plugin.test.ts +++ b/packages/server/src/adapters/node/compression-plugin.test.ts @@ -5,6 +5,8 @@ import { CompressionPlugin } from './compression-plugin' import { RPCHandler } from './rpc-handler' describe('compressionPlugin', () => { + const largeText = 'x'.repeat(2000) // 2KB of text, above default threshold + it('should compress response when accept-encoding includes gzip', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { const handler = new RPCHandler(os.handler(() => 'output'), { @@ -100,6 +102,68 @@ describe('compressionPlugin', () => { expect(res.headers['content-encoding']).toBe('deflate') }) + it('should override default filter and compress if filter returns true', async () => { + const filter = vi.fn(() => true) + + const res = await request(async (req: IncomingMessage, res: ServerResponse) => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin({ filter }), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + // image/jpeg is not compressible by default + body: new Blob([largeText], { type: 'image/jpeg' }), + }, + } + }, + ], + }) + + await handler.handle(req, res) + }) + .post('/') + .set('accept-encoding', 'gzip, deflate') + .send({ input: 'test' }) + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBe('gzip') + + expect(filter).toHaveBeenCalledWith(expect.any(Object), expect.any(Object)) + }) + + it('should not compress if filter return false', async () => { + const filter = vi.fn(() => false) + + const res = await request(async (req: IncomingMessage, res: ServerResponse) => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin({ filter }), + ], + }) + + await handler.handle(req, res) + }) + .post('/') + .set('accept-encoding', 'gzip, deflate') + .send({ input: 'test' }) + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBeUndefined() + + expect(filter).toHaveBeenCalledWith(expect.any(Object), expect.any(Object)) + }) + it('should throw if rootInterceptor throws', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { const handler = new RPCHandler(os.handler(() => 'output'), { @@ -158,7 +222,7 @@ describe('compressionPlugin', () => { yield 'yield2' }), { plugins: [ - new CompressionPlugin(), + new CompressionPlugin({ filter: () => true }), ], }) diff --git a/packages/server/src/adapters/node/compression-plugin.ts b/packages/server/src/adapters/node/compression-plugin.ts index b0bba3eff..8b3cd508e 100644 --- a/packages/server/src/adapters/node/compression-plugin.ts +++ b/packages/server/src/adapters/node/compression-plugin.ts @@ -6,9 +6,10 @@ import compression from '@orpc/interop/compression' export interface CompressionPluginOptions extends compression.CompressionOptions { /** - * A filter function to determine if a response should be compressed. - * This function is called in addition to the default compression checks - * and allows for custom compression logic based on the request and response. + * Override the default content-type filter used to determine which responses should be compressed. + * + * @warning [Event Iterator](https://orpc.unnoq.com/docs/event-iterator) responses are never compressed, regardless of this filter's return value. + * @default only responses with compressible content types are compressed. */ filter?: (req: NodeHttpRequest, res: NodeHttpResponse) => boolean } @@ -24,17 +25,19 @@ export class CompressionPlugin implements NodeHttpHandlerPlug this.compressionHandler = compression({ ...options, filter: (req, res) => { - if (res.getHeader('content-type')?.toString().startsWith('text/event-stream')) { - return false - } + const hasContentDisposition = res.getHeader('content-disposition') !== undefined + const contentType = res.getHeader('content-type')?.toString() - if (!compression.filter(req, res)) { + /** + * Never compress Event Iterator responses. + */ + if (!hasContentDisposition && contentType?.startsWith('text/event-stream')) { return false } return options.filter ? options.filter(req, res) - : true + : compression.filter(req, res) }, }) } From 583fd980048ceabf7b0c22481173b4e0436c2023 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 14 Aug 2025 11:07:11 +0700 Subject: [PATCH 2/2] Update packages/server/src/adapters/node/compression-plugin.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/server/src/adapters/node/compression-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/adapters/node/compression-plugin.ts b/packages/server/src/adapters/node/compression-plugin.ts index 8b3cd508e..a43e75c73 100644 --- a/packages/server/src/adapters/node/compression-plugin.ts +++ b/packages/server/src/adapters/node/compression-plugin.ts @@ -25,7 +25,7 @@ export class CompressionPlugin implements NodeHttpHandlerPlug this.compressionHandler = compression({ ...options, filter: (req, res) => { - const hasContentDisposition = res.getHeader('content-disposition') !== undefined + const hasContentDisposition = res.hasHeader('content-disposition') const contentType = res.getHeader('content-type')?.toString() /**