Skip to content

fix(server): keep the response Content-Type when compressing Blob bodies - #2214

Merged
dinwwwh merged 5 commits into
mainfrom
claude/gallant-cerf-bybubc
Oct 13, 2026
Merged

dinwwwh merged 5 commits into
mainfrom
claude/gallant-cerf-bybubc

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 13, 2026

Copy link
Copy Markdown
Member

Problem

For a Blob/File response body, ResponseCompressionHandlerPlugin ignored a Content-Type header the response already had. That header can come from ResponseHeadersPlugin's context.resHeaders, from OpenAPI detailed output headers, or from another interceptor. The plugin decided whether to compress from body.type, then overwrote the header with body.type. The adapters do headers['content-type'] ??= body.type, so the header wins when the response is not compressed. The same response therefore got a different Content-Type depending on Accept-Encoding:

os.$context<ResponseHeadersPluginContext>().handler(({ context }) => {
  context.resHeaders?.set('content-type', 'text/csv; charset=utf-8')
  return new Blob([csv], { type: 'text/plain' })
})
// no Accept-Encoding    → text/csv; charset=utf-8
// Accept-Encoding: gzip → text/plain (type and charset lost)
Header Blob type Before After
text/csv; charset=utf-8 text/plain gzip, sent as text/plain gzip, header kept
text/html; charset=utf-8 '' never compressed ('' is not compressible) gzip, header kept
image/png text/plain gzip, sent as text/plain not compressed

Fix

In the Blob branch, the Content-Type is now headers['content-type'] ?? body.type, the same fallback as the adapters' ??=. That value is used as is for the output header and flattened only for the isCompressibleContentType check, which needs a single string. Because the raw value is kept, an empty-array header still counts as set, as it does in the adapters (Node then sends no Content-Type), instead of falling back to the blob type.

BatchResponseCompressionHandlerPlugin in @orpc/node is unchanged. Its Blob is the batch envelope the plugin builds itself, so body.type is right there.

Tests

One it.each in describe('blob body') covers the three cases above, plus an empty-array header. Each row sets the header with a routing interceptor, then checks the encoding and that the compressed response has the same Content-Type as the uncompressed one. All four rows fail before the fix. The existing blob tests pass unchanged.

Validation: pnpm vitest run on the response compression and batch response compression tests (84 passed), pnpm type:check and pnpm lint all pass.

Note

RequestCompressionLinkPlugin in @orpc/client has the same issue for request bodies (it uses request.body.type for both the check and the header). It is not changed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah


Generated by Claude Code

For Blob/File bodies, ResponseCompressionHandlerPlugin ignored an existing
Content-Type header: the compressibility check used `body.type` and the
compressed response overwrote the header with `body.type`. The adapters
only fall back to `body.type` when no header is set, so the same response
got a different Content-Type depending on Accept-Encoding, and an untyped
Blob with a compressible header was never compressed.

Use the effective Content-Type (the existing header, falling back to
`body.type`) for both the check and the output header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah
The three tests differed only in the blob, the injected Content-Type
header and the expected encoding, so they now share one `it.each` and a
single copy of the header-injecting plugin. Drop the extra uncompressed
request, which only re-checked adapter behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah
Use `headers['content-type'] ?? body.type` as is, like the adapters'
`??=`, and flatten it only for the compressibility check. Flattening
first turned an empty-array header into `undefined` and fell back to the
blob type, so a response the Node adapter sends without a Content-Type
was compressed and sent with `body.type`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah
Using the existing Content-Type header is the expected behavior, so it
does not need its own mention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah
Cover all four cases in one table that sets the header with a handler
routing interceptor, and check that a compressed response carries the
same Content-Type as the uncompressed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfdK1RSJyFSUuXFgSefuah
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
orpc 5ae6f20 Commit Preview URL

Branch Preview URL
Oct 13 2026, 07:04 AM

@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

  • Blob/File Content-Type preserved on compression — ResponseCompressionHandlerPlugin now derives the content type as headers['content-type'] ?? body.type, uses the flattened value only for the compressibility check, and writes the raw value to the output header, matching the adapters' ??= body.type fallback.
  • Table-driven regression tests — one it.each in describe('blob body') covers header-over-blob precedence, the charset-preservation case, the image/png no-compress case, and an empty-array header, asserting the compressed content-type equals the uncompressed one.

I confirmed the adapters (@standard-server/fetch and /peer) do headers["content-type"] ??= body.type, and that flattenStandardHeader([]) returns undefined (so the empty-array row stays uncompressed and unset, matching the adapter). BatchResponseCompressionHandlerPlugin is correctly left alone — its Blob is the envelope it builds. Ran pnpm vitest run packages/server/src/plugins/response-compression.test.ts here: 56 passed. The request-side counterpart (RequestCompressionLinkPlugin) is a pre-existing gap the author already documented as out of scope. No actionable concerns.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2214

@orpc/arktype

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

@orpc/bun

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

@orpc/client

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

@orpc/cloudflare

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

@orpc/contract

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

@orpc/experimental-effect

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

@orpc/evlog

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

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2214

@orpc/json-schema

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

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2214

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2214

@orpc/nest

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

@orpc/next

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

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2214

@orpc/openapi

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

@orpc/opentelemetry

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

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2214

@orpc/pino

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

@orpc/publisher

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

@orpc/ratelimit

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

@orpc/server

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

@orpc/shared

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

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2214

@orpc/tanstack-query

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

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2214

@orpc/valibot

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

@orpc/zod

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

commit: 5ae6f20

@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 5 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

ℹ️ 12 benchmarks have no flame graph

The flamegraph could not be generated because too much time was spent in runtime internals, making the visualization not useful for performance analysis.

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/gallant-cerf-bybubc (5ae6f20) with main (f0c1825)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@dinwwwh
dinwwwh merged commit 0dd6994 into main Oct 13, 2026
12 checks passed
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