Repository navigation
feat(rpc)!: carry batched cookies and shared headers on the batch response - #2264
Conversation
Browsers only read the headers of the batch response, so cookies set in a batched call were never stored, and they ended up in the batch body where JavaScript could read them, even HttpOnly ones. BatchHandlerPlugin now starts the batch response once every call has responded, and streams it only when a call returns an event iterator or a ReadableStream: - set-cookie never stays on a call. The batch response carries every call's cookies, each distinct value once. - A header every call sets to the same value moves onto the batch response, and the link merges it back into each call. Body headers (content-*) stay on their call, and the headers option wins. - A new mapSubresponse option decides what stays on each call. Its default removes set-cookie and the headers the batch response already has with the same value. BatchLinkPlugin loses its mode option and BatchLinkPluginMode type, and sends `orpc-batch: 1`. Links from before this change still work with the new server. BREAKING CHANGE: BatchLinkPlugin no longer accepts `mode`, and `BatchLinkPluginMode` is removed. A batch no longer sends each response as soon as it is ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtE8iZthQGzPQQoMXCSgzc
…ption opt out Each call now hands the batch only its headers and whether it streams, so a large result is freed once it is sent instead of living until the longest stream in the batch ends. The headers option wins over the headers the calls share by spread order, so setting one to undefined keeps it off the batch response. internalError moves to module scope, and tests and comments left over from streaming mode are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtE8iZthQGzPQQoMXCSgzc
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
orpc | 98a0792 | Commit Preview URL Branch Preview URL |
Oct 19 2026, 03:53 AM |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/experimental-msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Merging this PR will not alter performance
|
There was a problem hiding this comment.
ℹ️ Minor suggestions only — nothing blocking.
Reviewed changes
- Batch API simplification —
BatchLinkPluginno longer acceptsmode, theBatchLinkPluginModetype is gone, and theorpc-batchrequest header is always'1'(the server treats any non-empty value as a batch). - Server-side header lifting — the batch response now waits for every subresponse's head and lifts the cookies all subresponses set plus the headers they all share, with a new
mapSubresponseoption to customize what stays on each subresponse. - Always stream when needed — the server streams the batch response iff any subresponse body is a
ReadableStreamorAsyncIteratorObject, replacing the mode-driven split; keep-alive now applies only to batches that stream. - Docs and tests —
batch.mdxreworked (Response Modes→Streaming Responses+Response Headers), migration and better-auth docs updated, and the unit + e2e suites reworked around the new semantics.
ℹ️ The batch now blocks on the slowest sub-response, and abort cannot release one that ignores its signal
const { batchHeaders, streams } = await lifting (packages/server/src/plugins/batch.ts:421) gates the entire batch response on every subresponse reaching markReady (batch.ts:382). A procedure whose interceptorOptions.next never settles — e.g. one that ignores its signal — leaves lifting pending forever, so the routing interceptor never returns a response and the per-subrequest abort (peer close) cannot break the wait. Previously the interceptor raced (Promise.race([streaming.promise, subrequests.then(...)])) and returned as soon as a stream appeared, so the other subresponses still streamed.
Technical details
# Batches can hang on a never-settling subresponse
## Affected sites
- `packages/server/src/plugins/batch.ts:316-324` — `ready`/`lifting` wait for all heads.
- `packages/server/src/plugins/batch.ts:375-386` — `markReady` runs only after `next` settles.
- `packages/server/src/plugins/batch.ts:401` — the `.finally(resolve(undefined))` safety net is on `peer.message`, which itself awaits the handler, so it does not cover this case.
- `packages/server/src/plugins/batch.ts:421` — `await lifting` never returns while one `ready[i]` is pending.
## Required outcome
Decide whether a subrequest that never settles (including one that ignores its abort signal)
should be allowed to hold the whole batch open indefinitely. If not, the batch needs a
fallback that returns a response (or closes) without waiting on every head.
## Open questions for the human
- Is "wait for every procedure" a hard requirement of carrying shared headers/cookies, or
acceptable to relax (e.g. start once headers are known from the responded subset)?
- Should an aborted batch request release the interceptor even if a handler ignores `signal`?
</details>
<!--
Pullfrog review metadata. These findings were written against ec32616;
if commits have landed on claude/sleepy-albattani-tzcpib since, treat every specific bug, file, or
line callout as POTENTIALLY STALE and re-diff before acting on it.
- Mode: Review (initial)
- Files reviewed: 18
- Commits reviewed: 2
- Base: main (f8504ba)
- Head: claude/sleepy-albattani-tzcpib (ec32616)
- Reviewed commits:
- ec326162 — refactor(server): free batched bodies once sent and let the headers option opt out
- 245b26a2 — feat!: carry batched cookies and shared headers on the batch response
- Prior pullfrog review: none
-->
<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->
<sup><a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a> | [Fix all ➔](https://pullfrog.com/trigger/middleapi/orpc/2264?action=fix&review_id=5566654127) | [Fix 👍s ➔](https://pullfrog.com/trigger/middleapi/orpc/2264?action=fix-approved&review_id=5566654127) | [View workflow run](https://github.com/middleapi/orpc/actions/runs/39195135495/job/117876178112) | Using `deepseek-v4.1-flash` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss)) | [𝕏](https://x.com/pullfrogai)</sup>There was a problem hiding this comment.
🟡 Changes recommended
Cache variation can be lost during header lifting, and mapped streaming bodies can prevent batch responses from starting.
2 open issues
What changed in this PR
Simplifies oRPC batching by removing response modes and carrying cookies and shared headers on the batch response.
Changes:
- Uses
orpc-batch: 1and automatically selects streaming. - Adds header lifting and a
mapSubresponsehook. - Updates tests, integration helpers, and migration documentation.
| File | Description |
|---|---|
| tests/plugins/all-plugins.test.ts | Tests batched cookies and shared headers. |
| tests/batch/batch-plugin.test.ts | Updates mixed-response coverage. |
| tests/batch/__shared__/client-server.ts | Removes mode options. |
| tests/batch/__shared__/client-server.node-http.ts | Updates Node HTTP setup. |
| tests/batch/__shared__/client-server.hono-fetch.ts | Updates Hono setup. |
| tests/batch/__shared__/client-server.compression-node-http.ts | Updates compressed Node setup. |
| tests/batch/__shared__/client-server.compression-hono-fetch.ts | Updates compressed Hono setup. |
| packages/server/src/plugins/get-method-csrf-protection.test.ts | Updates the batch header value. |
| packages/server/src/plugins/batch.ts | Implements header lifting and automatic streaming. |
| packages/server/src/plugins/batch.test.ts | Tests headers, mapping, streaming, and cancellation. |
| packages/node/src/batch-response-compression-handler-plugin.ts | Clarifies a compression comment. |
| packages/node/src/batch-response-compression-handler-plugin.test.ts | Adapts compression tests to automatic streaming. |
| packages/client/src/plugins/batch.ts | Removes mode configuration. |
| packages/client/src/plugins/batch.test.ts | Updates client batching tests. |
| apps/content/docs/plugins/batch.mdx | Documents streaming and response headers. |
| apps/content/docs/plugins/batch-response-compression.mdx | Revises the streaming explanation. |
| apps/content/docs/migrations/from-v1.mdx | Explains mode removal. |
| apps/content/docs/integrations/better-auth.mdx | Removes the batching cookie limitation. |
Review effort: Balanced
| markReady({ | ||
| headers: response.headers, | ||
| stream: response.body instanceof ReadableStream || isAsyncIteratorObject(response.body), | ||
| }) |
…sponse A cache-control every call shares moves onto the batch response, but a Vary only some calls set stayed inside the body, so a shared cache could reuse the batch across languages or, with Vary: Cookie, across sessions. The batch response now varies on every field any call or the headers option varies on, and on everything when one varies on `*`. The headers option docs now say set-cookie and vary merge instead of being replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtE8iZthQGzPQQoMXCSgzc
There was a problem hiding this comment.
✅ No new issues found. The latest commit only extends header lifting to
Varyand was reviewed in full.
Reviewed changes
Varyunion in header lifting —liftSubresponseHeaders()now collectsVaryfields from the pluginheadersoption and every subresponse, splits and trims them, de-dupes case-insensitively (keeping first-seen casing), joins with,, and collapses to*when any subresponse varies on everything.- Docs and JSDoc —
batch.mdxand theheadersoption now state that the batch response carries theVaryfields of its subresponses, and thatset-cookieandvarymerge rather than being suppressible withundefined. - Tests — added coverage for the field union across subresponses, the
headersoption ordering, and the*case.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏



Summary
Removes the
modeoption from the batch plugins on both client and server, simplifying the batch API. The server now always starts the batch response once every sub-request has responded (allowing it to carry shared headers and cookies), and streams the response if any sub-response is a stream. This eliminates the need for users to choose between "buffered" and "streaming" modes.Key Changes
modeparameter from batch requests; theorpc-batchheader now always has value'1'instead of'buffered'or'streaming'mapSubresponseoption to customize this behaviormodeoption andBatchLinkPluginModetype exportImplementation Details
SubresponseHeadinterface to track headers and stream status before full responses are readyliftSubresponseHeaders()function to intelligently merge sub-response headers onto the batch responseinternalError()helper to safely handle errors in the routing interceptorhttps://claude.ai/code/session_01UtE8iZthQGzPQQoMXCSgzc