From 47f2935fda9993b7fd96ee0c4c63ca55f2d5c4b5 Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 12 Aug 2025 16:37:30 +0700 Subject: [PATCH 1/8] CompressionPlugin - node adapter --- packages/server/package.json | 2 + .../adapters/node/compression-plugin.test.ts | 140 ++++++++++++++++++ .../src/adapters/node/compression-plugin.ts | 68 +++++++++ pnpm-lock.yaml | 44 ++++++ 4 files changed, 254 insertions(+) create mode 100644 packages/server/src/adapters/node/compression-plugin.test.ts create mode 100644 packages/server/src/adapters/node/compression-plugin.ts diff --git a/packages/server/package.json b/packages/server/package.json index 937913356..adc0851a0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -132,6 +132,8 @@ "@orpc/standard-server-fetch": "workspace:*", "@orpc/standard-server-node": "workspace:*", "@orpc/standard-server-peer": "workspace:*", + "@types/compression": "^1.8.1", + "compression": "^1.8.1", "cookie": "^1.0.2" }, "devDependencies": { diff --git a/packages/server/src/adapters/node/compression-plugin.test.ts b/packages/server/src/adapters/node/compression-plugin.test.ts new file mode 100644 index 000000000..c44ccf9e2 --- /dev/null +++ b/packages/server/src/adapters/node/compression-plugin.test.ts @@ -0,0 +1,140 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import request from 'supertest' +import { os } from '../../builder' +import { CompressionPlugin } from './compression-plugin' +import { RPCHandler } from './rpc-handler' + +describe('compressionPlugin', () => { + const output = 'x'.repeat(1024) // Large enough to trigger compression + + 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), { + plugins: [ + new CompressionPlugin(), + ], + }) + + 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') + // Just check that we get a response, not trying to decompress for now + expect(res.body).toBeDefined() + }) + + it('should not compress response when accept-encoding does not include compression', async () => { + const res = await request(async (req: IncomingMessage, res: ServerResponse) => { + const handler = new RPCHandler(os.handler(() => output), { + plugins: [ + new CompressionPlugin(), + ], + }) + + await handler.handle(req, res) + }) + .post('/') + .set('accept-encoding', 'identity') + .send({ input: 'test' }) + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBeUndefined() + expect(res.text).toContain(output) + }) + + it('should not compress responses if not satisfying filter', async () => { + const res = await request(async (req: IncomingMessage, res: ServerResponse) => { + const handler = new RPCHandler(os.handler(() => output), { + plugins: [ + new CompressionPlugin({ + filter: () => false, // Disable compression entirely for this test + }), + ], + }) + + 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(res.text).toContain(output) + }) + + it('should work with deflate compression', async () => { + const res = await request(async (req: IncomingMessage, res: ServerResponse) => { + const handler = new RPCHandler(os.handler(() => output), { + plugins: [ + new CompressionPlugin(), + ], + }) + + await handler.handle(req, res) + }) + .post('/') + .set('accept-encoding', 'deflate') + .send({ input: 'test' }) + + expect(res.status).toBe(200) + expect(res.headers['content-encoding']).toBe('deflate') + }) + + it('should throw if rootInterceptor throws', async () => { + const res = await request(async (req: IncomingMessage, res: ServerResponse) => { + const rootInterceptors: any[] = [] + const handler = new RPCHandler(os.handler(() => output), { + plugins: [ + new CompressionPlugin(), + ], + rootInterceptors, + }) + + // ensure rootInterceptor run after CompressionPlugin + rootInterceptors.push(async () => { + throw new Error('Test error') + }) + + await expect(handler.handle(req, res)).rejects.toThrow('Test error') + + res.statusCode = 500 + res.end() + }) + .post('/') + .set('accept-encoding', 'gzip, deflate') + .send({ input: 'test' }) + + expect(res.status).toBe(500) + expect(res.headers['content-encoding']).toBeUndefined() + }) + + it('should throw if compression options throw', async () => { + const res = await request(async (req: IncomingMessage, res: ServerResponse) => { + const handler = new RPCHandler(os.handler(() => output), { + plugins: [ + new CompressionPlugin({ + filter: () => { + throw new Error('Test error') + }, + }), + ], + }) + + await expect(handler.handle(req, res)).rejects.toThrow('Test error') + + res.statusCode = 500 + res.end() + }) + .post('/') + .set('accept-encoding', 'gzip, deflate') + .send({ input: 'test' }) + + expect(res.status).toBe(500) + expect(res.headers['content-encoding']).toBeUndefined() + }) +}) diff --git a/packages/server/src/adapters/node/compression-plugin.ts b/packages/server/src/adapters/node/compression-plugin.ts new file mode 100644 index 000000000..99ee071ef --- /dev/null +++ b/packages/server/src/adapters/node/compression-plugin.ts @@ -0,0 +1,68 @@ +import type { Context } from '../../context' +import type { NodeHttpHandlerOptions } from './handler' +import type { NodeHttpHandlerPlugin } from './plugin' +import compression from 'compression' + +export interface CompressionPluginOptions extends compression.CompressionOptions { +} +/** + * The Compression Plugin adds response compression to the Node.js HTTP Server. + * + * @see {@link https://orpc.unnoq.com/docs/plugins/compression Compression Plugin Docs} + */ +export class CompressionPlugin implements NodeHttpHandlerPlugin { + private readonly compressionHandler: ReturnType + + constructor(options: CompressionPluginOptions = {}) { + this.compressionHandler = compression(options) + } + + initRuntimeAdapter(options: NodeHttpHandlerOptions): void { + options.adapterInterceptors ??= [] + options.adapterInterceptors.push(async (options) => { + let resolve: (value: Awaited>) => void + let reject: (reason?: any) => void + const promise = new Promise>>((res, rej) => { + resolve = res + reject = rej + }) + + /** + * These methods are proxied by the compression handler, so we need to + * store the original methods to call them after compression is done + * to prevent side effects to other code outside of this plugin. + */ + const originalWrite = options.response.write + const originalEnd = options.response.end + const originalOn = options.response.on + + this.compressionHandler( + options.request as any, + options.response as any, + async (err) => { + /* v8 ignore next 3 - this never happen in realtime: https://github.com/expressjs/compression/blob/master/index.js#L243 */ + if (err) { + reject(err) + } + else { + try { + resolve(await options.next()) + } + catch (error) { + reject(error) + } + } + }, + ) + + try { + return await promise + } + finally { + options.response.write = originalWrite + options.response.end = originalEnd + options.response.on = originalOn + } + }) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a636aa98..60f4a2ccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,6 +559,12 @@ importers: '@orpc/standard-server-peer': specifier: workspace:* version: link:../standard-server-peer + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 + compression: + specifier: ^1.8.1 + version: 1.8.1 cookie: specifier: ^1.0.2 version: 1.0.2 @@ -5976,6 +5982,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -7738,6 +7747,14 @@ packages: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -11203,6 +11220,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -19849,6 +19870,11 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.3 + '@types/node': 22.17.0 + '@types/connect@3.4.38': dependencies: '@types/node': 22.17.0 @@ -22207,6 +22233,22 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} concat-stream@1.6.2: @@ -26476,6 +26518,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 From f7b103a4619722f8896385d45091c8c6e168c4d0 Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 12 Aug 2025 16:39:17 +0700 Subject: [PATCH 2/8] improve --- packages/server/src/adapters/node/compression-plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/adapters/node/compression-plugin.ts b/packages/server/src/adapters/node/compression-plugin.ts index 99ee071ef..2e28acd8a 100644 --- a/packages/server/src/adapters/node/compression-plugin.ts +++ b/packages/server/src/adapters/node/compression-plugin.ts @@ -31,6 +31,7 @@ export class CompressionPlugin implements NodeHttpHandlerPlug * These methods are proxied by the compression handler, so we need to * store the original methods to call them after compression is done * to prevent side effects to other code outside of this plugin. + * https://github.com/expressjs/compression/blob/master/index.js#L97-L153 */ const originalWrite = options.response.write const originalEnd = options.response.end From f7ed7a61e8d96949b39ffbe8a6192523b72aa61e Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 12 Aug 2025 16:51:52 +0700 Subject: [PATCH 3/8] docs --- apps/content/.vitepress/config.ts | 1 + apps/content/docs/plugins/body-limit.md | 4 +++ apps/content/docs/plugins/compression.md | 32 ++++++++++++++++++++++ packages/server/src/adapters/node/index.ts | 1 + 4 files changed, 38 insertions(+) create mode 100644 apps/content/docs/plugins/compression.md diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index de1138a58..42f6aeb76 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -141,6 +141,7 @@ export default withMermaid(defineConfig({ { text: 'Dedupe Requests', link: '/docs/plugins/dedupe-requests' }, { text: 'Batch Requests', link: '/docs/plugins/batch-requests' }, { text: 'Client Retry', link: '/docs/plugins/client-retry' }, + { text: 'Compression', link: '/docs/plugins/compression' }, { text: 'Body Limit', link: '/docs/plugins/body-limit' }, { text: 'Simple CSRF Protection', link: '/docs/plugins/simple-csrf-protection' }, { text: 'Strict GET method', link: '/docs/plugins/strict-get-method' }, diff --git a/apps/content/docs/plugins/body-limit.md b/apps/content/docs/plugins/body-limit.md index 32d002adf..d64207d1e 100644 --- a/apps/content/docs/plugins/body-limit.md +++ b/apps/content/docs/plugins/body-limit.md @@ -29,3 +29,7 @@ const handler = new RPCHandler(router, { ], }) ``` + +::: info +The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. +::: diff --git a/apps/content/docs/plugins/compression.md b/apps/content/docs/plugins/compression.md new file mode 100644 index 000000000..6130a5cc6 --- /dev/null +++ b/apps/content/docs/plugins/compression.md @@ -0,0 +1,32 @@ +--- +title: Compression Plugin +description: A plugin for oRPC that compresses request and response bodies. +--- + +# Compression Plugin + +The **Compression Plugin** compresses response bodies to reduce bandwidth usage and improve performance. + +## Import + +Depending on your adapter, import the corresponding plugin: + +```ts +import { CompressionPlugin } from '@orpc/server/node' +``` + +## Setup + +Add the plugin to your handler configuration: + +```ts +const handler = new RPCHandler(router, { + plugins: [ + new CompressionPlugin(), + ], +}) +``` + +::: info +The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. +::: diff --git a/packages/server/src/adapters/node/index.ts b/packages/server/src/adapters/node/index.ts index ccefaa536..c5e8584af 100644 --- a/packages/server/src/adapters/node/index.ts +++ b/packages/server/src/adapters/node/index.ts @@ -1,4 +1,5 @@ export * from './body-limit-plugin' +export * from './compression-plugin' export * from './handler' export * from './plugin' export * from './rpc-handler' From 77ff182b31e26017e7e38eaa83fe456d52396436 Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 13 Aug 2025 10:43:10 +0700 Subject: [PATCH 4/8] fetch adapter --- apps/content/docs/plugins/compression.md | 1 + packages/server/build.config.ts | 5 + packages/server/package.json | 4 +- .../adapters/fetch/compression-plugin.test.ts | 520 ++++++++++++++++++ .../src/adapters/fetch/compression-plugin.ts | 132 +++++ packages/server/src/adapters/fetch/index.ts | 1 + .../adapters/node/compression-plugin.test.ts | 12 +- .../src/adapters/node/compression-plugin.ts | 6 +- pnpm-lock.yaml | 12 +- 9 files changed, 677 insertions(+), 16 deletions(-) create mode 100644 packages/server/build.config.ts create mode 100644 packages/server/src/adapters/fetch/compression-plugin.test.ts create mode 100644 packages/server/src/adapters/fetch/compression-plugin.ts diff --git a/apps/content/docs/plugins/compression.md b/apps/content/docs/plugins/compression.md index 6130a5cc6..ccef5384b 100644 --- a/apps/content/docs/plugins/compression.md +++ b/apps/content/docs/plugins/compression.md @@ -13,6 +13,7 @@ Depending on your adapter, import the corresponding plugin: ```ts import { CompressionPlugin } from '@orpc/server/node' +import { CompressionPlugin } from '@orpc/server/fetch' ``` ## Setup diff --git a/packages/server/build.config.ts b/packages/server/build.config.ts new file mode 100644 index 000000000..55a3e87ae --- /dev/null +++ b/packages/server/build.config.ts @@ -0,0 +1,5 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + failOnWarn: false, +}) diff --git a/packages/server/package.json b/packages/server/package.json index adc0851a0..28b574b21 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -132,12 +132,12 @@ "@orpc/standard-server-fetch": "workspace:*", "@orpc/standard-server-node": "workspace:*", "@orpc/standard-server-peer": "workspace:*", - "@types/compression": "^1.8.1", - "compression": "^1.8.1", "cookie": "^1.0.2" }, "devDependencies": { + "@types/compression": "^1.8.1", "@types/ws": "^8.18.1", + "compression": "^1.8.1", "crossws": "^0.4.1", "next": "^15.4.5", "supertest": "^7.1.4", diff --git a/packages/server/src/adapters/fetch/compression-plugin.test.ts b/packages/server/src/adapters/fetch/compression-plugin.test.ts new file mode 100644 index 000000000..77e5a639f --- /dev/null +++ b/packages/server/src/adapters/fetch/compression-plugin.test.ts @@ -0,0 +1,520 @@ +import { os } from '../../builder' +import { CompressionPlugin } from './compression-plugin' +import { RPCHandler } from './rpc-handler' + +describe('compressionPlugin', () => { + const largeText = 'x'.repeat(2000) // 2KB of text, above default threshold + const smallText = 'small response' // Small text, below threshold + + it('should not compress response when no accept-encoding header', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin(), + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({}), + })) + + await expect(response?.text()).resolves.toContain('output') + expect(response?.headers.has('content-encoding')).toBe(false) + expect(response?.status).toBe(200) + }) + + it('should compress response with gzip when client accepts it', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin(), + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip, deflate', + }, + body: JSON.stringify({}), + })) + + expect(response?.headers.get('content-encoding')).toBe('gzip') + expect(response?.status).toBe(200) + + // Verify the response can be decompressed + const decompressed = response?.body?.pipeThrough(new DecompressionStream('gzip')) + const text = await new Response(decompressed).text() + expect(text).toContain('output') + }) + + it('should compress response with deflate when client accepts it', async () => { + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + new CompressionPlugin(), + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'deflate', + }, + body: JSON.stringify({}), + })) + + expect(response?.headers.get('content-encoding')).toBe('deflate') + expect(response?.status).toBe(200) + + // Verify the response can be decompressed + const decompressed = response?.body?.pipeThrough(new DecompressionStream('deflate')) + const text = await new Response(decompressed).text() + expect(text).toContain(largeText) + }) + + it('should prefer gzip over deflate when both are supported', async () => { + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + new CompressionPlugin(), + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'deflate, gzip', + }, + body: JSON.stringify({}), + })) + + expect(response?.headers.get('content-encoding')).toBe('gzip') + }) + + it('should respect custom encoding order', async () => { + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + new CompressionPlugin({ encodings: ['deflate', 'gzip'] }), + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip, deflate', + }, + body: JSON.stringify({}), + })) + + expect(response?.headers.get('content-encoding')).toBe('deflate') + }) + + it('should not compress response below threshold', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin({ threshold: 1024 }), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + body: new Blob([smallText], { type: 'text/plain' }), + }, + } + }, + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + 'content-length': smallText.length.toString(), + }, + body: JSON.stringify({}), + })) + + await expect(response?.text()).resolves.toContain(smallText) + expect(response?.headers.has('content-encoding')).toBe(false) + }) + + it('should compress response above custom threshold', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + strictGetMethodPluginEnabled: false, + plugins: [ + new CompressionPlugin({ threshold: 1024 }), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + body: new Blob([largeText], { type: 'text/plain' }), + }, + } + }, + ], + }) + + 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') + }) + + it('should not compress when response already has content-encoding', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + strictGetMethodPluginEnabled: false, + plugins: [ + new CompressionPlugin(), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + headers: { + ...result.response.headers, + 'content-encoding': 'br', + }, + body: new Blob([largeText], { type: 'text/plain' }), + }, + } + }, + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({}), + })) + + await expect(response?.text()).resolves.toBe(largeText) + expect(response?.headers.get('content-encoding')).toBe('br') + }) + + it('should not compress when response has transfer-encoding', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + strictGetMethodPluginEnabled: false, + plugins: [ + new CompressionPlugin(), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + headers: { + ...result.response.headers, + 'transfer-encoding': 'chunked', + }, + body: new Blob([largeText], { type: 'text/plain' }), + }, + } + }, + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({}), + })) + + await expect(response?.text()).resolves.toBe(largeText) + expect(response?.headers.has('content-encoding')).toBe(false) + }) + + it('should not compress when response has no body', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + strictGetMethodPluginEnabled: false, + plugins: [ + new CompressionPlugin(), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + status: 204, + body: undefined, + }, + } + }, + ], + }) + + 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?.body).toBe(null) + expect(response?.headers.has('content-encoding')).toBe(false) + expect(response?.status).toBe(204) + }) + + it('should not compress non-compressible content types', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin(), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + 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({}), + })) + + await expect(response?.text()).resolves.toBe(largeText) + expect(response?.headers.has('content-encoding')).toBe(false) + }) + + it.each([ + 'text/plain', + 'text/html', + 'application/json', + 'application/javascript', + 'application/xml', + 'font/otf', + 'application/vnd.ms-fontobject', + ])('should compress compressible content types: %s', async (contentType) => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin(), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + body: new Blob([largeText], { type: contentType }), + }, + } + }, + ], + }) + + 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(response?.headers.get('content-type')).toBe(contentType) + expect(response?.headers.get('content-length')).toBeNull() // CompressionStream changes content length + }) + + it('should not compress when cache-control has no-transform', async () => { + const handler = new RPCHandler(os.handler(() => 'output'), { + plugins: [ + new CompressionPlugin(), + ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + headers: { + ...result.response.headers, + 'cache-control': 'no-cache, no-transform, max-age=0', + }, + body: new Blob([largeText], { type: 'text/plain' }), + }, + } + }, + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({}), + })) + + await expect(response?.text()).resolves.toBe(largeText) + expect(response?.headers.has('content-encoding')).toBe(false) + }) + + it('should compress when response has unknown content-length', async () => { + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + new CompressionPlugin({ threshold: 1024 }), + ], + }) + + 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') + }) + + it('should not compress when no matching encoding is found', async () => { + const handler = new RPCHandler(os.handler(() => largeText), { + strictGetMethodPluginEnabled: false, + plugins: [ + new CompressionPlugin({ encodings: ['gzip'] }), + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'br, compress', + }, + body: JSON.stringify({}), + })) + + await expect(response?.text()).resolves.toContain(largeText) + expect(response?.headers.has('content-encoding')).toBe(false) + }) + + it('should handle non-matched routes without compression', async () => { + const handler = new RPCHandler(os.handler(() => 'ping'), { + strictGetMethodPluginEnabled: false, + plugins: [ + new CompressionPlugin(), + ], + }) + + // Simulate a non-matched route by using a request that doesn't match + const { matched, response } = await handler.handle(new Request('https://example.com/nonexistent')) + + expect(matched).toBe(false) + expect(response).toBeUndefined() + }) + + it('should not compress when custom filter returns false', async () => { + const filter = vi.fn(() => false) + + const handler = new RPCHandler(os.handler(() => largeText), { + plugins: [ + new CompressionPlugin({ + filter, + }), + ], + }) + + const { response } = await handler.handle(new Request('https://example.com/', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-encoding': 'gzip', + }, + body: JSON.stringify({}), + })) + + await expect(response?.text()).resolves.toContain(largeText) + expect(response?.headers.has('content-encoding')).toBe(false) + + expect(filter).toHaveBeenCalledWith(response, expect.any(Request)) + }) +}) diff --git a/packages/server/src/adapters/fetch/compression-plugin.ts b/packages/server/src/adapters/fetch/compression-plugin.ts new file mode 100644 index 000000000..44e2823f3 --- /dev/null +++ b/packages/server/src/adapters/fetch/compression-plugin.ts @@ -0,0 +1,132 @@ +/** + * This plugin is heavily inspired by the [Hono Compression Plugin](https://github.com/honojs/hono/blob/main/src/middleware/compress/index.ts) + */ + +import type { Context } from '../../context' +import type { FetchHandlerOptions } from './handler' +import type { FetchHandlerPlugin } from './plugin' + +const ORDERED_SUPPORTED_ENCODINGS = ['gzip', 'deflate'] as const + +export interface CompressionPluginOptions { + /** + * The compression schemes to use for response compression. + * Schemes are prioritized by their order in this array and + * only applied if the client supports them. + * + * @default ['gzip', 'deflate'] + */ + encodings?: readonly (typeof ORDERED_SUPPORTED_ENCODINGS)[number][] + + /** + * The minimum response size in bytes required to trigger compression. + * Responses smaller than this threshold will not be compressed to avoid overhead. + * If the response size cannot be determined, compression will still be applied. + * + * @default 1024 (1KB) + */ + 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. + */ + filter?: (response: Response, request: Request) => boolean +} + +/** + * The Compression Plugin adds response compression to the Fetch Server. + * Build on top of [CompressionStream](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream) + * You might need to polyfill it if your environment does not support it. + * + * @see {@link https://orpc.unnoq.com/docs/plugins/compression Compression Plugin Docs} + */ +export class CompressionPlugin implements FetchHandlerPlugin { + private readonly encodings: Exclude + private readonly threshold: Exclude + private readonly filter: CompressionPluginOptions['filter'] + + constructor(options: CompressionPluginOptions = {}) { + this.encodings = options.encodings ?? ORDERED_SUPPORTED_ENCODINGS + this.threshold = options.threshold ?? 1024 + this.filter = options.filter + } + + initRuntimeAdapter(options: FetchHandlerOptions): void { + options.adapterInterceptors ??= [] + + /** + * use `unshift` to ensure this runs before user-defined adapter interceptors + */ + options.adapterInterceptors.unshift(async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + const response = result.response + + 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 + } + + const contentLength = response.headers.get('content-length') + if (contentLength && Number(contentLength) < this.threshold) { + return result + } + + const acceptEncoding = options.request.headers.get('accept-encoding') + const encoding = this.encodings.find(enc => acceptEncoding?.includes(enc)) + + if (!response.body || encoding === undefined) { + return result + } + + if (this.filter && !this.filter(response, options.request)) { + return result + } + + const compressedBody = response.body.pipeThrough(new CompressionStream(encoding)) + const compressedHeaders = new Headers(response.headers) + compressedHeaders.delete('content-length') // CompressionStream will change the content length + compressedHeaders.set('content-encoding', encoding) + + return { + ...result, + response: new Response(compressedBody, { + ...response, + headers: compressedHeaders, + }), + } + }) + } +} + +/** + * https://github.com/honojs/hono/blob/main/src/utils/compress.ts#L9 + */ +const COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i +function isCompressibleContentType(contentType: string | null): boolean { + if (contentType === null) { + return false + } + return COMPRESSIBLE_CONTENT_TYPE_REGEX.test(contentType) +} + +/** + * https://github.com/honojs/hono/blob/main/src/middleware/compress/index.ts#L10 + */ +const CACHE_CONTROL_NO_TRANSFORM_REGEX = /(?:^|,)\s*no-transform\s*(?:,|$)/i +function isNoTransformCacheControl(cacheControl: string | null): boolean { + if (cacheControl === null) { + return false + } + return CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl) +} diff --git a/packages/server/src/adapters/fetch/index.ts b/packages/server/src/adapters/fetch/index.ts index ccefaa536..c5e8584af 100644 --- a/packages/server/src/adapters/fetch/index.ts +++ b/packages/server/src/adapters/fetch/index.ts @@ -1,4 +1,5 @@ export * from './body-limit-plugin' +export * from './compression-plugin' export * from './handler' export * from './plugin' export * from './rpc-handler' diff --git a/packages/server/src/adapters/node/compression-plugin.test.ts b/packages/server/src/adapters/node/compression-plugin.test.ts index c44ccf9e2..fb97d94c7 100644 --- a/packages/server/src/adapters/node/compression-plugin.test.ts +++ b/packages/server/src/adapters/node/compression-plugin.test.ts @@ -87,17 +87,15 @@ describe('compressionPlugin', () => { it('should throw if rootInterceptor throws', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { - const rootInterceptors: any[] = [] const handler = new RPCHandler(os.handler(() => output), { plugins: [ new CompressionPlugin(), ], - rootInterceptors, - }) - - // ensure rootInterceptor run after CompressionPlugin - rootInterceptors.push(async () => { - throw new Error('Test error') + rootInterceptors: [ + async () => { + throw new Error('Test error') + }, + ], }) await expect(handler.handle(req, res)).rejects.toThrow('Test error') diff --git a/packages/server/src/adapters/node/compression-plugin.ts b/packages/server/src/adapters/node/compression-plugin.ts index 2e28acd8a..91fb8c1b7 100644 --- a/packages/server/src/adapters/node/compression-plugin.ts +++ b/packages/server/src/adapters/node/compression-plugin.ts @@ -19,7 +19,11 @@ export class CompressionPlugin implements NodeHttpHandlerPlug initRuntimeAdapter(options: NodeHttpHandlerOptions): void { options.adapterInterceptors ??= [] - options.adapterInterceptors.push(async (options) => { + + /** + * use `unshift` to ensure this runs before user-defined adapter interceptors + */ + options.adapterInterceptors.unshift(async (options) => { let resolve: (value: Awaited>) => void let reject: (reason?: any) => void const promise = new Promise>>((res, rej) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60f4a2ccd..eb6d393ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,19 +559,19 @@ importers: '@orpc/standard-server-peer': specifier: workspace:* version: link:../standard-server-peer - '@types/compression': - specifier: ^1.8.1 - version: 1.8.1 - compression: - specifier: ^1.8.1 - version: 1.8.1 cookie: specifier: ^1.0.2 version: 1.0.2 devDependencies: + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 '@types/ws': specifier: ^8.18.1 version: 8.18.1 + compression: + specifier: ^1.8.1 + version: 1.8.1 crossws: specifier: ^0.4.1 version: 0.4.1 From e2df4d7ab8c5f44014420744f30ce8113fce68f2 Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 13 Aug 2025 10:45:28 +0700 Subject: [PATCH 5/8] improve --- .github/dependabot.yml | 1 + packages/server/build.config.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0860fbc1c..8976d609d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,6 +22,7 @@ updates: - '@antfu/eslint-config' - 'eslint-plugin-*' - '@hey-api/*' + - compression # inline dependency update-types: - minor - patch diff --git a/packages/server/build.config.ts b/packages/server/build.config.ts index 55a3e87ae..1b9ef0e0f 100644 --- a/packages/server/build.config.ts +++ b/packages/server/build.config.ts @@ -1,5 +1,9 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ + /** + * Disable warnings as errors because we need to inline the `compression` package, + * which is not ESModule-friendly. + */ failOnWarn: false, }) From 5de7178557a125f0b97ed499563f05f8efa1c066 Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 13 Aug 2025 10:50:06 +0700 Subject: [PATCH 6/8] Update apps/content/docs/plugins/compression.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/content/docs/plugins/compression.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/plugins/compression.md b/apps/content/docs/plugins/compression.md index ccef5384b..7ae9a25d2 100644 --- a/apps/content/docs/plugins/compression.md +++ b/apps/content/docs/plugins/compression.md @@ -1,6 +1,6 @@ --- title: Compression Plugin -description: A plugin for oRPC that compresses request and response bodies. +description: A plugin for oRPC that compresses response bodies. --- # Compression Plugin From 69549004f183da7c9be1a6978af0272c92bffa2b Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 13 Aug 2025 13:29:04 +0700 Subject: [PATCH 7/8] improve --- packages/server/src/adapters/fetch/compression-plugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/server/src/adapters/fetch/compression-plugin.ts b/packages/server/src/adapters/fetch/compression-plugin.ts index 44e2823f3..869a74fff 100644 --- a/packages/server/src/adapters/fetch/compression-plugin.ts +++ b/packages/server/src/adapters/fetch/compression-plugin.ts @@ -82,7 +82,11 @@ export class CompressionPlugin implements FetchHandlerPlugin< return result } - const acceptEncoding = options.request.headers.get('accept-encoding') + const acceptEncoding = options.request.headers + .get('accept-encoding') + ?.split(',') + .map(enc => enc.trim().split(';')[0]!) + const encoding = this.encodings.find(enc => acceptEncoding?.includes(enc)) if (!response.body || encoding === undefined) { From 0f760c0c9fa433c3685996976bd6ca295c116cea Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 13 Aug 2025 13:41:27 +0700 Subject: [PATCH 8/8] improve --- .../adapters/fetch/compression-plugin.test.ts | 2 +- .../src/adapters/fetch/compression-plugin.ts | 4 +- .../adapters/node/compression-plugin.test.ts | 39 +++++++++++++------ 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/server/src/adapters/fetch/compression-plugin.test.ts b/packages/server/src/adapters/fetch/compression-plugin.test.ts index 77e5a639f..53227d592 100644 --- a/packages/server/src/adapters/fetch/compression-plugin.test.ts +++ b/packages/server/src/adapters/fetch/compression-plugin.test.ts @@ -515,6 +515,6 @@ describe('compressionPlugin', () => { await expect(response?.text()).resolves.toContain(largeText) expect(response?.headers.has('content-encoding')).toBe(false) - expect(filter).toHaveBeenCalledWith(response, expect.any(Request)) + expect(filter).toHaveBeenCalledWith(expect.any(Request), response) }) }) diff --git a/packages/server/src/adapters/fetch/compression-plugin.ts b/packages/server/src/adapters/fetch/compression-plugin.ts index 869a74fff..0d10b94f4 100644 --- a/packages/server/src/adapters/fetch/compression-plugin.ts +++ b/packages/server/src/adapters/fetch/compression-plugin.ts @@ -32,7 +32,7 @@ export interface CompressionPluginOptions { * This function is called in addition to the default compression checks * and allows for custom compression logic based on the request and response. */ - filter?: (response: Response, request: Request) => boolean + filter?: (request: Request, response: Response) => boolean } /** @@ -93,7 +93,7 @@ export class CompressionPlugin implements FetchHandlerPlugin< return result } - if (this.filter && !this.filter(response, options.request)) { + if (this.filter && !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 fb97d94c7..3d47a0a7a 100644 --- a/packages/server/src/adapters/node/compression-plugin.test.ts +++ b/packages/server/src/adapters/node/compression-plugin.test.ts @@ -5,11 +5,9 @@ import { CompressionPlugin } from './compression-plugin' import { RPCHandler } from './rpc-handler' describe('compressionPlugin', () => { - const output = 'x'.repeat(1024) // Large enough to trigger compression - 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), { + const handler = new RPCHandler(os.handler(() => 'output'), { plugins: [ new CompressionPlugin(), ], @@ -29,7 +27,7 @@ describe('compressionPlugin', () => { it('should not compress response when accept-encoding does not include compression', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { - const handler = new RPCHandler(os.handler(() => output), { + const handler = new RPCHandler(os.handler(() => 'output'), { plugins: [ new CompressionPlugin(), ], @@ -43,17 +41,34 @@ describe('compressionPlugin', () => { expect(res.status).toBe(200) expect(res.headers['content-encoding']).toBeUndefined() - expect(res.text).toContain(output) + expect(res.text).toContain('output') }) - it('should not compress responses if not satisfying filter', async () => { + it('should not compress responses if below threshold', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { - const handler = new RPCHandler(os.handler(() => output), { + const handler = new RPCHandler(os.handler(() => 'output'), { plugins: [ new CompressionPlugin({ - filter: () => false, // Disable compression entirely for this test + threshold: 1000, // Set a high threshold }), ], + interceptors: [ + async (options) => { + const result = await options.next() + + if (!result.matched) { + return result + } + + return { + ...result, + response: { + ...result.response, + body: new Blob(['small'], { type: 'text/plain' }), + }, + } + }, + ], }) await handler.handle(req, res) @@ -64,12 +79,12 @@ describe('compressionPlugin', () => { expect(res.status).toBe(200) expect(res.headers['content-encoding']).toBeUndefined() - expect(res.text).toContain(output) + expect(res.text).toContain('small') }) it('should work with deflate compression', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { - const handler = new RPCHandler(os.handler(() => output), { + const handler = new RPCHandler(os.handler(() => 'output'), { plugins: [ new CompressionPlugin(), ], @@ -87,7 +102,7 @@ describe('compressionPlugin', () => { it('should throw if rootInterceptor throws', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { - const handler = new RPCHandler(os.handler(() => output), { + const handler = new RPCHandler(os.handler(() => 'output'), { plugins: [ new CompressionPlugin(), ], @@ -113,7 +128,7 @@ describe('compressionPlugin', () => { it('should throw if compression options throw', async () => { const res = await request(async (req: IncomingMessage, res: ServerResponse) => { - const handler = new RPCHandler(os.handler(() => output), { + const handler = new RPCHandler(os.handler(() => 'output'), { plugins: [ new CompressionPlugin({ filter: () => {