feat(server): compress batch responses with flush-per-message zlib stream#1645
feat(server): compress batch responses with flush-per-message zlib stream#1645Prains wants to merge 2 commits into
Conversation
…ream Batch responses use an application/octet-stream body that the default content-type filter never compresses, even though the payload is mostly JSON. CompressionStream cannot flush between chunks, so compressing a streaming batch would trap early messages in the compressor buffer until the stream ends, defeating streaming semantics. When the runtime exposes node:zlib and node:stream via process.getBuiltinModule (Node.js >= 22.3, Bun), batch responses (orpc-batch request header) are now compressed with a zlib gzip/deflate stream configured with Z_SYNC_FLUSH, delivering each batch message compressed as soon as it is ready. Runtimes without these builtins keep the previous uncompressed behavior.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Reviewed changes — added flush-per-message gzip/deflate compression for streaming orpc-batch responses in the fetch adapter, plus integration tests through the real BatchHandlerPlugin.
- Fetch adapter batch compression —
BodyCompressionHandlerPluginnow special-cases batch responses withapplication/octet-streambodies, switching from webCompressionStream(which cannot flush between chunks) to a Node.jszlibgzip/deflate stream configured withZ_SYNC_FLUSHwhen runtime builtins are available. - Capability-gated fallback — detection via
process.getBuiltinModule('node:zlib')/'node:stream'means unsupported runtimes silently fall back to uncompressed batch responses; event streams and default non-batch octet-stream responses remain uncompressed. - Custom filter handling — the
filteroption is still consulted for batch responses, so users can opt out explicitly. - Test coverage — integration tests cover streaming gzip/deflate, a deliberate flush-vs-buffer test, buffered-batch JSON regression, custom-filter rejection, and mid-stream cancellation.
ℹ️ Cancellation test only exercises the consumer side
The test that calls reader.cancel() asserts only that cancel() resolves; it does not verify that cancellation propagates upstream and stops the pending never handler. If the Node zlib Duplex or pipeThrough failed to propagate cancellation (or if the batch response simply ran to completion), the test would still pass.
Technical details
# Cancellation test does not assert upstream abort
## Affected sites
- packages/server/src/adapters/fetch/body-compression-plugin.test.ts:360–378
## Required outcome
- Cancellation must stop the upstream batch peer from running the slow handler to completion and must release the underlying zlib/stream resources.
- The test failure mode should be observable if upstream cancellation is accidentally omitted, not just whether `reader.cancel()` itself resolves.
## Suggested approach
Replace the unresolvable `never` handler with one that observes an `AbortSignal` and rejects after cancellation (for example, by wrapping the inner request or by making the handler long-but-abortable). Then assert that the handler rejects/aborts within a short timeout after `reader.cancel()`. Alternatively, if the test framework supports it, assert that the `never` Promise is rejected by the stream abort.
## Open questions for the human
- Is there an existing pattern in the codebase for testing upstream abort propagation for streamed Responses? If so, align with that pattern.ℹ️ Flush test relies on timeout as the failure signal
The key test that distinguishes a flushing compressor from a buffering one is meaningful, but its only failure mode for a regression is a full test timeout. It also ignores done in the first read loop, so a premature stream end would throw an opaque TypeError from decoder.decode(undefined) rather than producing a clean assertion failure.
Technical details
# Flush test determinism and failure mode
## Affected sites
- packages/server/src/adapters/fetch/body-compression-plugin.test.ts:255–299
## Required outcome
- A buffering compressor should fail fast with a clear assertion, not only by hanging the test.
- The test should not throw a `TypeError` if the stream ends before the fast message is received.
## Suggested approach
- Record whether `largeValue` was received before `resolveSlow()` is called and assert that explicitly.
- In the first `while` loop, handle `done` and fail with a dedicated message if the stream closes early.
- Consider capping the wait with a short explicit timeout/retry loop so the assertion failure is quicker and clearer.
## Open questions for the human
- The test already passes under the new implementation; this is purely about tightening the regression signal. Decide if the extra robustness is worth the added complexity.ℹ️ Nitpicks
packages/server/src/adapters/fetch/body-compression-plugin.ts:108comment says “flush-per-chunk” while docs say “flush-per-message”; align the wording for consistency.packages/server/src/adapters/fetch/body-compression-plugin.ts:57-63callsisFlushCompressibleBatchResponseonce in the constructor filter and again at line 115 to select the compressor. The result is deterministic, but you could capture the boolean once and reuse it to avoid double work and the small TOCTOU redundancy of re-checkinggetNodeCompressionCompat.packages/server/src/adapters/fetch/body-compression-plugin.ts:216falls back tocompat.zlib.createDeflate(zlibOptions)for any non-gzip encoding. Becauseencodingis already validated to be one of the supported values, this is safe, but an explicitelse if (encoding === 'deflate')would make the branch self-documenting.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
…t failure mode - add a test proving cancellation propagates through the flushable zlib pipe to the source stream (vi.waitFor on the source cancel callback) - handle done in the flush test read loop so a premature stream end fails with a clear assertion instead of a TextDecoder TypeError - memoize the node builtins probe so the filter and the interceptor do not probe twice per response - align code comment wording with docs (flush-per-message)
|
Addressed the review in e0b933d:
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — since the prior review, the author addressed all outstanding feedback on the fetch adapter's batch compression implementation and tests.
- Tightened the flush regression test — the first read loop in
packages/server/src/adapters/fetch/body-compression-plugin.test.tsnow handlesdoneand fails with a clear message if the stream ends before the fast batch message arrives. - Added upstream-cancellation coverage — a new test verifies that
reader.cancel()propagates through the zlib compressing transform and reaches the upstreamReadableStreamsource. - Kept a focused mid-batch cancellation contract test — the remaining cancellation test now covers only that
reader.cancel()resolves when cancelled mid-batch. - Cached the Node.js builtin probe —
getNodeCompressionCompatstores its result so thezlib/streamavailability check executes at most once per process. - Aligned wording — the source comment in
packages/server/src/adapters/fetch/body-compression-plugin.tsnow saysflush-per-messageto match the docs.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
More templates
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/interop
@orpc/json-schema
@orpc/nest
@orpc/next
@orpc/openapi
@orpc/opentelemetry
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/tanstack-query
@orpc/valibot
@orpc/zod
commit: |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
I'm currently rewriting the compression plugin from scratch with new architecture to support all adapters in one, and I'll consider adding this feature, #1643 |
|
What do you think about providing an option to override CompressionStream, along with an option to compress stream, instead of relying on built-in support through magic globals? Or should we introduce a dedicated Node.js plugin instead? |
IMO everything is better then magic globals, since they work under the hood and their actions can be unexpected for users. Plugin might be overkill in my eyes, but override options feels like a sweet spot for this |

Problem
Batch responses (
orpc-batchrequest header) carry anapplication/octet-streambody (length-prefixed peer messages), which the default content-type filter ofBodyCompressionHandlerPluginnever compresses — even though the payload is mostly JSON and compresses ~10x.Simply allowing them through the filter wouldn't work either: the web
CompressionStreamAPI cannot flush between chunks (Z_NO_FLUSHsemantics), so early messages of a streaming batch would sit in the compressor buffer until the stream ends — silently degrading streaming batches back into buffered ones.So today users must choose: streaming batch delivery or compression, not both.
Solution
When the runtime exposes
node:zlibandnode:streamviaprocess.getBuiltinModule(Node.js ≥ 22.3, Bun), batch responses are compressed with azlibgzip/deflate stream configured withflush: Z_SYNC_FLUSH, bridged into web streams viastream.Duplex.toWeb. Each batch message is delivered compressed as soon as it is ready.application/octet-streamresponses stay never-compressed by default.filteris still consulted and can reject batch compression.Tests
BatchHandlerPlugin(not synthetic responses)reader.cancel()mid-streamVerified end-to-end in a production app (Nuxt/Nitro + oRPC): real Chromium receives
207+content-encoding: gzipon streaming batches, decompresses incrementally, and fast batch items render ~1.2s before slow ones; wire size 8218 → 574 bytes on a representative batch.Notes
adapters/node/body-compression-plugin.ts, express-compression-based) still hard-excludes event streams and doesn't cover batches; happy to follow up there if this approach is accepted.