Skip to content

Commit b77809d

Browse files
feat(server): compression plugin filter option should override default content type filter (#880)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Added support to override default compression decisions via a custom filter, enabling compression for custom content types. - Bug Fixes - Ensured event-stream (Server-Sent Events) and Event Iterator responses are never compressed, even with a custom filter. - Confirmed no compression occurs when responses lack a content-type. - Documentation - Clarified default compression behavior and the filter option, including the event-stream exception. - Tests - Expanded coverage for custom filter behavior, event-stream handling, and missing content-type scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent d53d856 commit b77809d

File tree

4 files changed

+172
-18
lines changed

4 files changed

+172
-18
lines changed

packages/server/src/adapters/fetch/compression-plugin.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,36 @@ describe('compressionPlugin', () => {
313313
expect(response?.status).toBe(204)
314314
})
315315

316+
it('should not compress when response has no content-type', async () => {
317+
const handler = new RPCHandler(os.handler(() => 'output'), {
318+
strictGetMethodPluginEnabled: false,
319+
plugins: [
320+
new CompressionPlugin(),
321+
],
322+
adapterInterceptors: [
323+
async (options) => {
324+
const result = await options.next()
325+
result.response?.headers.delete('content-type') // Simulate no content-type
326+
return result
327+
},
328+
],
329+
})
330+
331+
const { response } = await handler.handle(new Request('https://example.com/', {
332+
method: 'POST',
333+
headers: {
334+
'content-type': 'application/json',
335+
'accept-encoding': 'gzip',
336+
},
337+
body: JSON.stringify({}),
338+
}))
339+
340+
expect(response?.headers.has('content-encoding')).toBe(false)
341+
expect(response?.headers.has('content-type')).toBe(false)
342+
expect(response?.status).toBe(200)
343+
await expect(response?.text()).resolves.toContain('output')
344+
})
345+
316346
it('should not compress non-compressible content types', async () => {
317347
const handler = new RPCHandler(os.handler(() => 'output'), {
318348
plugins: [
@@ -492,7 +522,50 @@ describe('compressionPlugin', () => {
492522
expect(response).toBeUndefined()
493523
})
494524

495-
it('should not compress when custom filter returns false', async () => {
525+
it('should override filter and compress if filter return true', async () => {
526+
const filter = vi.fn(() => true)
527+
528+
const handler = new RPCHandler(os.handler(() => largeText), {
529+
plugins: [
530+
new CompressionPlugin({
531+
filter,
532+
}),
533+
],
534+
interceptors: [
535+
async (options) => {
536+
const result = await options.next()
537+
538+
if (!result.matched) {
539+
return result
540+
}
541+
542+
return {
543+
...result,
544+
response: {
545+
...result.response,
546+
// image/jpeg is not compressible by default
547+
body: new Blob([largeText], { type: 'image/jpeg' }),
548+
},
549+
}
550+
},
551+
],
552+
})
553+
554+
const { response } = await handler.handle(new Request('https://example.com/', {
555+
method: 'POST',
556+
headers: {
557+
'content-type': 'application/json',
558+
'accept-encoding': 'gzip',
559+
},
560+
body: JSON.stringify({}),
561+
}))
562+
563+
expect(response?.headers.get('content-encoding')).toBe('gzip')
564+
565+
expect(filter).toHaveBeenCalledWith(expect.any(Request), expect.any(Response))
566+
})
567+
568+
it('should not compress when filter returns false', async () => {
496569
const filter = vi.fn(() => false)
497570

498571
const handler = new RPCHandler(os.handler(() => largeText), {
@@ -524,7 +597,7 @@ describe('compressionPlugin', () => {
524597
yield 'event2'
525598
}), {
526599
plugins: [
527-
new CompressionPlugin(),
600+
new CompressionPlugin({ filter: () => true }),
528601
],
529602
})
530603

packages/server/src/adapters/fetch/compression-plugin.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ export interface CompressionPluginOptions {
2828
threshold?: number
2929

3030
/**
31-
* A filter function to determine if a response should be compressed.
32-
* This function is called in addition to the default compression checks
33-
* and allows for custom compression logic based on the request and response.
31+
* Override the default content-type filter used to determine which responses should be compressed.
32+
*
33+
* @warning [Event Iterator](https://orpc.unnoq.com/docs/event-iterator) responses are never compressed, regardless of this filter's return value.
34+
* @default only responses with compressible content types are compressed.
3435
*/
3536
filter?: (request: Request, response: Response) => boolean
3637
}
@@ -45,12 +46,26 @@ export interface CompressionPluginOptions {
4546
export class CompressionPlugin<T extends Context> implements FetchHandlerPlugin<T> {
4647
private readonly encodings: Exclude<CompressionPluginOptions['encodings'], undefined>
4748
private readonly threshold: Exclude<CompressionPluginOptions['threshold'], undefined>
48-
private readonly filter: CompressionPluginOptions['filter']
49+
private readonly filter: Exclude<CompressionPluginOptions['filter'], undefined>
4950

5051
constructor(options: CompressionPluginOptions = {}) {
5152
this.encodings = options.encodings ?? ORDERED_SUPPORTED_ENCODINGS
5253
this.threshold = options.threshold ?? 1024
53-
this.filter = options.filter
54+
this.filter = (request, response) => {
55+
const hasContentDisposition = response.headers.has('content-disposition')
56+
const contentType = response.headers.get('content-type')
57+
58+
/**
59+
* Never compress Event Iterator responses.
60+
*/
61+
if (!hasContentDisposition && contentType?.startsWith('text/event-stream')) {
62+
return false
63+
}
64+
65+
return options.filter
66+
? options.filter(request, response)
67+
: isCompressibleContentType(contentType)
68+
}
5469
}
5570

5671
initRuntimeAdapter(options: FetchHandlerOptions<T>): void {
@@ -71,7 +86,6 @@ export class CompressionPlugin<T extends Context> implements FetchHandlerPlugin<
7186
if (
7287
response.headers.has('content-encoding') // already encoded
7388
|| response.headers.has('transfer-encoding') // already encoded or chunked
74-
|| !isCompressibleContentType(response.headers.get('content-type')) // not compressible
7589
|| isNoTransformCacheControl(response.headers.get('cache-control')) // no-transform directive
7690
) {
7791
return result
@@ -93,7 +107,7 @@ export class CompressionPlugin<T extends Context> implements FetchHandlerPlugin<
93107
return result
94108
}
95109

96-
if (this.filter && !this.filter(options.request, response)) {
110+
if (!this.filter(options.request, response)) {
97111
return result
98112
}
99113

packages/server/src/adapters/node/compression-plugin.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { CompressionPlugin } from './compression-plugin'
55
import { RPCHandler } from './rpc-handler'
66

77
describe('compressionPlugin', () => {
8+
const largeText = 'x'.repeat(2000) // 2KB of text, above default threshold
9+
810
it('should compress response when accept-encoding includes gzip', async () => {
911
const res = await request(async (req: IncomingMessage, res: ServerResponse) => {
1012
const handler = new RPCHandler(os.handler(() => 'output'), {
@@ -100,6 +102,68 @@ describe('compressionPlugin', () => {
100102
expect(res.headers['content-encoding']).toBe('deflate')
101103
})
102104

105+
it('should override default filter and compress if filter returns true', async () => {
106+
const filter = vi.fn(() => true)
107+
108+
const res = await request(async (req: IncomingMessage, res: ServerResponse) => {
109+
const handler = new RPCHandler(os.handler(() => 'output'), {
110+
plugins: [
111+
new CompressionPlugin({ filter }),
112+
],
113+
interceptors: [
114+
async (options) => {
115+
const result = await options.next()
116+
117+
if (!result.matched) {
118+
return result
119+
}
120+
121+
return {
122+
...result,
123+
response: {
124+
...result.response,
125+
// image/jpeg is not compressible by default
126+
body: new Blob([largeText], { type: 'image/jpeg' }),
127+
},
128+
}
129+
},
130+
],
131+
})
132+
133+
await handler.handle(req, res)
134+
})
135+
.post('/')
136+
.set('accept-encoding', 'gzip, deflate')
137+
.send({ input: 'test' })
138+
139+
expect(res.status).toBe(200)
140+
expect(res.headers['content-encoding']).toBe('gzip')
141+
142+
expect(filter).toHaveBeenCalledWith(expect.any(Object), expect.any(Object))
143+
})
144+
145+
it('should not compress if filter return false', async () => {
146+
const filter = vi.fn(() => false)
147+
148+
const res = await request(async (req: IncomingMessage, res: ServerResponse) => {
149+
const handler = new RPCHandler(os.handler(() => 'output'), {
150+
plugins: [
151+
new CompressionPlugin({ filter }),
152+
],
153+
})
154+
155+
await handler.handle(req, res)
156+
})
157+
.post('/')
158+
.set('accept-encoding', 'gzip, deflate')
159+
.send({ input: 'test' })
160+
161+
expect(res.status).toBe(200)
162+
expect(res.headers['content-encoding']).toBeUndefined()
163+
164+
expect(filter).toHaveBeenCalledWith(expect.any(Object), expect.any(Object))
165+
})
166+
103167
it('should throw if rootInterceptor throws', async () => {
104168
const res = await request(async (req: IncomingMessage, res: ServerResponse) => {
105169
const handler = new RPCHandler(os.handler(() => 'output'), {
@@ -158,7 +222,7 @@ describe('compressionPlugin', () => {
158222
yield 'yield2'
159223
}), {
160224
plugins: [
161-
new CompressionPlugin(),
225+
new CompressionPlugin({ filter: () => true }),
162226
],
163227
})
164228

packages/server/src/adapters/node/compression-plugin.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import compression from '@orpc/interop/compression'
66

77
export interface CompressionPluginOptions extends compression.CompressionOptions {
88
/**
9-
* A filter function to determine if a response should be compressed.
10-
* This function is called in addition to the default compression checks
11-
* and allows for custom compression logic based on the request and response.
9+
* Override the default content-type filter used to determine which responses should be compressed.
10+
*
11+
* @warning [Event Iterator](https://orpc.unnoq.com/docs/event-iterator) responses are never compressed, regardless of this filter's return value.
12+
* @default only responses with compressible content types are compressed.
1213
*/
1314
filter?: (req: NodeHttpRequest, res: NodeHttpResponse) => boolean
1415
}
@@ -24,17 +25,19 @@ export class CompressionPlugin<T extends Context> implements NodeHttpHandlerPlug
2425
this.compressionHandler = compression({
2526
...options,
2627
filter: (req, res) => {
27-
if (res.getHeader('content-type')?.toString().startsWith('text/event-stream')) {
28-
return false
29-
}
28+
const hasContentDisposition = res.hasHeader('content-disposition')
29+
const contentType = res.getHeader('content-type')?.toString()
3030

31-
if (!compression.filter(req, res)) {
31+
/**
32+
* Never compress Event Iterator responses.
33+
*/
34+
if (!hasContentDisposition && contentType?.startsWith('text/event-stream')) {
3235
return false
3336
}
3437

3538
return options.filter
3639
? options.filter(req, res)
37-
: true
40+
: compression.filter(req, res)
3841
},
3942
})
4043
}

0 commit comments

Comments
 (0)