Skip to content

feat(server): compress batch responses with flush-per-message zlib stream#1645

Open
Prains wants to merge 2 commits into
middleapi:mainfrom
Prains:feat/flushable-batch-compression
Open

feat(server): compress batch responses with flush-per-message zlib stream#1645
Prains wants to merge 2 commits into
middleapi:mainfrom
Prains:feat/flushable-batch-compression

Conversation

@Prains

@Prains Prains commented Jul 8, 2026

Copy link
Copy Markdown

Problem

Batch responses (orpc-batch request header) carry an application/octet-stream body (length-prefixed peer messages), which the default content-type filter of BodyCompressionHandlerPlugin never compresses — even though the payload is mostly JSON and compresses ~10x.

Simply allowing them through the filter wouldn't work either: the web CompressionStream API cannot flush between chunks (Z_NO_FLUSH semantics), 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:zlib and node:stream via process.getBuiltinModule (Node.js ≥ 22.3, Bun), batch responses are compressed with a zlib gzip/deflate stream configured with flush: Z_SYNC_FLUSH, bridged into web streams via stream.Duplex.toWeb. Each batch message is delivered compressed as soon as it is ready.

  • Detection is capability-based: runtimes without these builtins keep the previous (uncompressed) behavior.
  • Event stream responses stay never-compressed, exactly as before.
  • Non-batch application/octet-stream responses stay never-compressed by default.
  • A custom filter is still consulted and can reject batch compression.

Tests

  • integration tests through the real BatchHandlerPlugin (not synthetic responses)
  • deterministic flush test: a fast and a deliberately-unresolved slow handler in one streaming batch; the fast message must decompress on the client before the slow handler resolves (a buffering compressor would time the test out)
  • gzip + deflate happy paths, buffered-batch JSON regression, custom-filter rejection, reader.cancel() mid-stream

Verified end-to-end in a production app (Nuxt/Nitro + oRPC): real Chromium receives 207 + content-encoding: gzip on streaming batches, decompresses incrementally, and fast batch items render ~1.2s before slow ones; wire size 8218 → 574 bytes on a representative batch.

Notes

  • The node adapter (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.
  • Naming/option surface is flexible: if you'd prefer an explicit opt-in option instead of the capability-based default, I can rework it.

…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.
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orpc Ready Ready Preview, Comment Jul 8, 2026 7:48pm

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compressionBodyCompressionHandlerPlugin now special-cases batch responses with application/octet-stream bodies, switching from web CompressionStream (which cannot flush between chunks) to a Node.js zlib gzip/deflate stream configured with Z_SYNC_FLUSH when 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 filter option 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:108 comment 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-63 calls isFlushCompressibleBatchResponse once 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-checking getNodeCompressionCompat.
  • packages/server/src/adapters/fetch/body-compression-plugin.ts:216 falls back to compat.zlib.createDeflate(zlibOptions) for any non-gzip encoding. Because encoding is already validated to be one of the supported values, this is safe, but an explicit else if (encoding === 'deflate') would make the branch self-documenting.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Comment thread packages/server/src/adapters/fetch/body-compression-plugin.test.ts
Comment thread packages/server/src/adapters/fetch/body-compression-plugin.test.ts
Comment thread packages/server/src/adapters/fetch/body-compression-plugin.ts
Comment thread packages/server/src/adapters/fetch/body-compression-plugin.ts
…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)
@Prains

Prains commented Jul 8, 2026

Copy link
Copy Markdown
Author

Addressed the review in e0b933d:

  • Cancellation propagation: added a test that observes the source stream's cancel() callback through the flushable zlib pipe (vi.waitFor), so a broken upstream propagation now fails the test instead of leaking silently. The batch-integration cancel test stays as an end-to-end smoke — note that aborting the pending handler itself is transport-level (request.signal) and out of this plugin's scope: cancelling the response body in a direct handler.handle() call never aborts the request signal, with or without compression.
  • Flush test failure mode: the first read loop now handles done and fails with a clear assertion (expect.unreachable) instead of a TextDecoder TypeError on premature stream end. A genuine buffering regression still surfaces as a test timeout — that's inherent to what's being proven (bytes that never arrive).
  • Double probe: memoized the node builtins probe, so filter + interceptor share one lookup per process.
  • Wording: aligned the code comment with the docs (flush-per-message).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.ts now handles done and 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 upstream ReadableStream source.
  • 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 probegetNodeCompressionCompat stores its result so the zlib/stream availability check executes at most once per process.
  • Aligned wording — the source comment in packages/server/src/adapters/fetch/body-compression-plugin.ts now says flush-per-message to match the docs.

Pullfrog  | View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown
More templates

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@1645

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@1645

@orpc/client

npm i https://pkg.pr.new/@orpc/client@1645

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@1645

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@1645

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@1645

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@1645

@orpc/interop

npm i https://pkg.pr.new/@orpc/interop@1645

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@1645

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@1645

@orpc/next

npm i https://pkg.pr.new/@orpc/next@1645

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@1645

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@1645

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@1645

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@1645

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@1645

@orpc/server

npm i https://pkg.pr.new/@orpc/server@1645

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@1645

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@1645

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@1645

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@1645

commit: e0b933d

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.54839% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...rver/src/adapters/fetch/body-compression-plugin.ts 93.54% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@dinwwwh

dinwwwh commented Jul 9, 2026

Copy link
Copy Markdown
Member

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

@dinwwwh

dinwwwh commented Jul 10, 2026

Copy link
Copy Markdown
Member

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?

@Prains

Prains commented Jul 10, 2026

Copy link
Copy Markdown
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants