Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/content/docs/plugins/body-compression.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ const handler = new RPCHandler(router, {

<!--@include: @/shared/common-plugin-handler-compatibility.md -->

## Batch and Event Stream Responses

Event stream responses are never compressed: the web `CompressionStream` API cannot flush between chunks, so early events would sit in the compressor buffer until the stream ends, defeating streaming semantics.

[Batch requests](/docs/plugins/batch-requests) (identified by the `orpc-batch` header) with an `application/octet-stream` body are compressed by the fetch adapter when the runtime exposes the Node.js `zlib` and `stream` builtins via `process.getBuiltinModule` (Node.js ≥ 22.3, Bun, and compatible runtimes): a flush-per-message `zlib` stream is used instead of `CompressionStream`, so each response in a streaming batch is delivered to the client as soon as it is ready — compressed. In runtimes without these builtins, such batch responses stay uncompressed.

## Learn More

For implementation details, see the [fetch adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/fetch/body-compression-plugin.ts) and the [node adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/node/body-compression-plugin.ts).
201 changes: 201 additions & 0 deletions packages/server/src/adapters/fetch/body-compression-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { FetchHandlerFetchInterceptor } from './handler'
import { os } from '../../builder'
import { BatchHandlerPlugin } from '../../plugins/batch'
import { BodyCompressionHandlerPlugin } from './body-compression-plugin'
import { RPCHandler } from './rpc-handler'

Expand Down Expand Up @@ -176,4 +178,203 @@ describe('bodyCompressionHandlerPlugin', () => {
await expect(response!.text()).resolves.toBe(largeText)
expect(response!.headers.has('content-encoding')).toBe(false)
})

it('does not compress octet-stream responses for non-batch requests', async () => {
const body = new TextEncoder().encode(largeText)

const handler = createHandler(new Response(body, {
headers: {
'content-length': body.byteLength.toString(),
'content-type': 'application/octet-stream',
},
}))

const { matched, response } = await handler.handle(new Request('https://example.com/ping', {
headers: {
'accept-encoding': 'gzip',
},
}))

expect(matched).toBe(true)
await expect(response!.text()).resolves.toBe(largeText)
expect(response!.headers.has('content-encoding')).toBe(false)
})

describe('batch responses', () => {
const largeValue = 'a'.repeat(500)

function makePeerRequestMessage(id: number, url: string) {
return {
kind: 'request',
id,
json: { method: 'POST', url, headers: {}, body: undefined },
binary: undefined,
}
}

function createBatchRequest(mode: 'buffered' | 'streaming', messages: unknown) {
return new Request('https://example.com/__batch__', {
method: 'POST',
headers: {
'accept-encoding': 'gzip',
'content-type': 'application/json',
'orpc-batch': mode,
},
body: JSON.stringify(messages),
})
}

function createBatchHandler(
router: Parameters<typeof RPCHandler>[0],
options: ConstructorParameters<typeof BodyCompressionHandlerPlugin>[0] = {},
): RPCHandler<any> {
return new RPCHandler(router, {
plugins: [new BatchHandlerPlugin(), new BodyCompressionHandlerPlugin(options)],
})
}

it('compresses streaming batch responses', async () => {
const handler = createBatchHandler({
ping: os.handler(() => largeValue),
})

const { matched, response } = await handler.handle(createBatchRequest('streaming', [
makePeerRequestMessage(0, '/ping'),
makePeerRequestMessage(1, '/ping'),
]))

expect(matched).toBe(true)
expect(response!.status).toBe(207)
expect(response!.headers.get('content-encoding')).toBe('gzip')

const decompressed = response!.body!.pipeThrough(new DecompressionStream('gzip'))
const text = await new Response(decompressed).text()
expect(text).toContain(largeValue)
})

it('flushes each compressed batch message without waiting for the whole batch', async () => {
let resolveSlow!: (value: string) => void
const slow = new Promise<string>((resolve) => {
resolveSlow = resolve
})

const handler = createBatchHandler({
fast: os.handler(() => largeValue),
slow: os.handler(() => slow),
})

const { response } = await handler.handle(createBatchRequest('streaming', [
makePeerRequestMessage(0, '/fast'),
makePeerRequestMessage(1, '/slow'),
]))

expect(response!.headers.get('content-encoding')).toBe('gzip')

const reader = response!.body!.pipeThrough(new DecompressionStream('gzip')).getReader()
const decoder = new TextDecoder()

/**
* A flush-capable compressor delivers the fast message while the slow one
* is still pending. A buffering compressor (e.g. `CompressionStream`)
* would hold it back until the stream ends and these reads would never
* resolve (test times out) because the slow handler is not resolved yet.
*/
let received = ''
Comment thread
pullfrog[bot] marked this conversation as resolved.
while (!received.includes(largeValue)) {
const { value } = await reader.read()
received += decoder.decode(value, { stream: true })
}

resolveSlow('slow-result')

while (!received.includes('slow-result')) {
const { done, value } = await reader.read()
if (done) {
break
}
received += decoder.decode(value, { stream: true })
}

expect(received).toContain('slow-result')
})

it('compresses streaming batch responses with deflate when preferred', async () => {
const handler = createBatchHandler({
ping: os.handler(() => largeValue),
})

const { response } = await handler.handle(new Request('https://example.com/__batch__', {
method: 'POST',
headers: {
'accept-encoding': 'deflate',
'content-type': 'application/json',
'orpc-batch': 'streaming',
},
body: JSON.stringify([makePeerRequestMessage(0, '/ping')]),
}))

expect(response!.headers.get('content-encoding')).toBe('deflate')

const decompressed = response!.body!.pipeThrough(new DecompressionStream('deflate'))
const text = await new Response(decompressed).text()
expect(text).toContain(largeValue)
})

it('compresses buffered batch responses as json (unchanged behavior)', async () => {
const handler = createBatchHandler({
ping: os.handler(() => largeValue),
})

const { response } = await handler.handle(createBatchRequest('buffered', [
makePeerRequestMessage(0, '/ping'),
makePeerRequestMessage(1, '/ping'),
]))

expect(response!.status).toBe(207)
expect(response!.headers.get('content-type')).toContain('application/json')
expect(response!.headers.get('content-encoding')).toBe('gzip')

const decompressed = response!.body!.pipeThrough(new DecompressionStream('gzip'))
const text = await new Response(decompressed).text()
expect(text).toContain(largeValue)
})

it('respects a custom filter that rejects batch responses', async () => {
const filter = vi.fn(() => false)

const handler = createBatchHandler({
ping: os.handler(() => largeValue),
}, { filter })

const { response } = await handler.handle(createBatchRequest('streaming', [
makePeerRequestMessage(0, '/ping'),
]))

expect(filter).toHaveBeenCalled()
expect(response!.headers.has('content-encoding')).toBe(false)

const text = await new Response(response!.body).text()
expect(text).toContain(largeValue)
})

it('supports cancelling a compressed streaming batch response', async () => {
const handler = createBatchHandler({
fast: os.handler(() => largeValue),
never: os.handler(() => new Promise(() => {})),
})

const { response } = await handler.handle(createBatchRequest('streaming', [
makePeerRequestMessage(0, '/fast'),
makePeerRequestMessage(1, '/never'),
]))

expect(response!.headers.get('content-encoding')).toBe('gzip')

const reader = response!.body!.getReader()
const { value } = await reader.read()
Comment thread
pullfrog[bot] marked this conversation as resolved.
expect(value!.byteLength).toBeGreaterThan(0)

await expect(reader.cancel()).resolves.toBeUndefined()
})
})
})
84 changes: 82 additions & 2 deletions packages/server/src/adapters/fetch/body-compression-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export interface BodyCompressionHandlerPluginOptions {
* Override the default content-type filter used to determine which responses should be compressed.
*
* @warning Event stream responses are never compressed, regardless of this filter's return value.
* @default only responses with compressible content types are compressed.
* @default only responses with compressible content types are compressed. Batch responses
* (`orpc-batch` request header) with an `application/octet-stream` body are also compressed
* when the runtime exposes Node.js `zlib`/`stream` builtins — a flush-per-message compressor
* keeps streaming batch semantics intact.
*/
filter?: (request: Request, response: Response) => boolean
}
Expand All @@ -51,6 +54,10 @@ export class BodyCompressionHandlerPlugin<T extends Context> implements FetchHan
return false
}

if (isFlushCompressibleBatchResponse(request, response)) {
return options.filter ? options.filter(request, response) : true
}

return options.filter
? options.filter(request, response)
: isCompressibleContentType(contentType)
Expand Down Expand Up @@ -98,7 +105,22 @@ export class BodyCompressionHandlerPlugin<T extends Context> implements FetchHan
return result
}

const compressedBody = response.body.pipeThrough(new CompressionStream(encoding))
/**
* `CompressionStream` cannot flush between chunks, so early messages of a
* streaming batch response would sit in the compressor buffer until the
Comment thread
pullfrog[bot] marked this conversation as resolved.
* stream ends, defeating streaming semantics. Batch responses therefore
* use a flush-per-chunk zlib stream instead (the filter guarantees it is
* available whenever a batch response passes through).
*/
const compression = isFlushCompressibleBatchResponse(interceptorOptions.request, response)
Comment thread
pullfrog[bot] marked this conversation as resolved.
? createFlushableCompressionStream(encoding)
: new CompressionStream(encoding)

if (compression === undefined) {
return result
}

const compressedBody = response.body.pipeThrough(compression)
const compressedHeaders = new Headers(response.headers)

compressedHeaders.delete('content-length')
Expand Down Expand Up @@ -136,3 +158,61 @@ function isNoTransformCacheControl(cacheControl: string | null): boolean {

return CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl)
}

/**
* Batch responses carry a `application/octet-stream` body (length-prefixed peer messages),
* which the default content-type filter would never compress even though the payload is
* mostly JSON. They are only compressible when a flush-capable compressor is available,
* otherwise streaming batch responses would lose their streaming semantics.
*/
function isFlushCompressibleBatchResponse(request: Request, response: Response): boolean {
return request.headers.has('orpc-batch')
&& !response.headers.has('content-disposition')
&& (response.headers.get('content-type')?.startsWith('application/octet-stream') ?? false)
&& getNodeCompressionCompat() !== undefined
}

interface NodeCompressionCompat {
zlib: typeof import('node:zlib')
stream: typeof import('node:stream')
}

function getNodeCompressionCompat(): NodeCompressionCompat | undefined {
try {
/**
* Deliberately accessed via `globalThis` (not imported) so this cross-runtime
* file never hard-depends on node builtins — runtimes without them fall back
* to the uncompressed behavior.
*/
// eslint-disable-next-line node/prefer-global/process
const zlib = globalThis.process?.getBuiltinModule?.('node:zlib')
// eslint-disable-next-line node/prefer-global/process
const stream = globalThis.process?.getBuiltinModule?.('node:stream')

if (zlib !== undefined && typeof stream?.Duplex?.toWeb === 'function') {
return { zlib, stream }
}
}
catch {
// runtimes without node builtins fall through to undefined
}

return undefined
}

function createFlushableCompressionStream(
encoding: (typeof ORDERED_SUPPORTED_ENCODINGS)[number],
): ReadableWritablePair<Uint8Array, Uint8Array> | undefined {
const compat = getNodeCompressionCompat()

if (compat === undefined) {
return undefined
}

const zlibOptions = { flush: compat.zlib.constants.Z_SYNC_FLUSH }
const compressor = encoding === 'gzip'
? compat.zlib.createGzip(zlibOptions)
: compat.zlib.createDeflate(zlibOptions)

return compat.stream.Duplex.toWeb(compressor) as ReadableWritablePair<Uint8Array, Uint8Array>
}