feat(server,openapi): support ReadableStream<Uint8Array> as handler return value#1535
Conversation
…eturn value Allow oRPC handlers to return a ReadableStream<Uint8Array> directly, bypassing JSON serialization. The stream is passed through the codec layer unchanged and forwarded to the fetch/Node HTTP adapters. Also adds an optional `disposition` parameter to `generateContentDisposition` (defaults to `'inline'` for backward compatibility). Fixes https://github.com/unnoq/orpc/issues/1058 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds explicit support for Web Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Handler
participant Codec
participant Serializer
participant Adapter
participant Runtime
Client->>Handler: invoke handler()
Handler-->>Client: returns ReadableStream<Uint8Array>
Client->>Codec: encode(output)
alt output is ReadableStream
Codec-->>Client: StandardResponse(status, headers, stream)
else non-stream output
Codec->>Serializer: serialize(output)
Serializer-->>Codec: serializedBody
Codec-->>Client: StandardResponse(status, headers, serializedBody)
end
Client->>Adapter: toFetchBody / toNodeHttpBody(response.body)
alt Web ReadableStream
Adapter-->>Runtime: pass-through stream or Readable.fromWeb(stream)
end
Runtime-->>Client: HTTP response with stream body
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces support for ReadableStream in the framework's adapters and server implementations, allowing binary data to be streamed directly. It updates the StandardBody type, modifies the fetch and node body converters, and enhances the generateContentDisposition utility. Feedback suggests that the StandardOpenAPICodec should be updated to respect contract-defined success statuses and support streams within detailed response objects to allow for custom headers and status codes.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
packages/standard-server-node/src/body.ts (1)
65-97:⚠️ Potential issue | 🟠 MajorPreserve explicit stream response headers.
Line 95 returns the stream only after Lines 67-68 delete
content-typeandcontent-disposition, so a streamed ZIP response losesapplication/zipand attachment filename metadata. Move the stream branch before the generic header cleanup and only clear stale JSON defaults.🐛 Proposed fix
export function toNodeHttpBody( body: StandardBody, headers: StandardHeaders, options: ToNodeHttpBodyOptions = {}, ): Readable | undefined | string { + if (body instanceof ReadableStream) { + if (flattenHeader(headers['content-type'])?.startsWith('application/json')) { + delete headers['content-type'] + } + + return Readable.fromWeb(body) + } + const currentContentDisposition = flattenHeader(headers['content-disposition']) delete headers['content-type'] delete headers['content-disposition'] @@ - if (body instanceof ReadableStream) { - return Readable.fromWeb(body) - } - if (isAsyncIteratorObject(body)) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/standard-server-node/src/body.ts` around lines 65 - 97, The stream branch currently runs after we unconditionally delete headers['content-type'] and headers['content-disposition'], which strips explicit stream response headers (e.g., application/zip and attachment filename); move the "if (body instanceof ReadableStream) return Readable.fromWeb(body)" branch to before the header deletion so explicit response headers are preserved (refer to the identifier body and Readable.fromWeb), and change the header cleanup around lines that touch headers['content-type'] / headers['content-disposition'] to only remove stale JSON defaults (do not remove existing content-type/content-disposition when body is a stream or Blob/File—see currentContentDisposition and generateContentDisposition usage).packages/standard-server-fetch/src/body.ts (1)
70-100:⚠️ Potential issue | 🟠 MajorDo not strip explicit headers for raw streams.
Line 98 returns the stream after Lines 72-73 remove
content-typeandcontent-disposition. That leaves binary stream responses without media type or filename metadata.🐛 Proposed fix
export function toFetchBody( body: StandardBody, headers: Headers, options: ToFetchBodyOptions = {}, ): string | Blob | FormData | URLSearchParams | undefined | ReadableStream<Uint8Array> { + if (body instanceof ReadableStream) { + if (headers.get('content-type')?.startsWith('application/json')) { + headers.delete('content-type') + } + + return body + } + const currentContentDisposition = headers.get('content-disposition') headers.delete('content-type') headers.delete('content-disposition') @@ - if (body instanceof ReadableStream) { - return body - } - if (isAsyncIteratorObject(body)) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/standard-server-fetch/src/body.ts` around lines 70 - 100, The code currently unconditionally deletes content-type and content-disposition (headers.delete('content-type') / headers.delete('content-disposition')) before branching on body types, which strips metadata for raw ReadableStream responses; change the logic so headers are only removed for body types that will be re-set (e.g., Blob, File, FormData, URLSearchParams) and do not delete these headers when body is a ReadableStream — i.e., move or gate the header deletion behind the non-stream branches (or early-return for ReadableStream) so ReadableStream responses keep their existing content-type and content-disposition (reference variables/functions: headers, body, currentContentDisposition, Blob, File, ReadableStream, generateContentDisposition).packages/openapi/src/adapters/standard/openapi-codec.ts (1)
75-112:⚠️ Potential issue | 🟠 MajorKeep OpenAPI status/headers semantics for streamed bodies.
Line 75 bypasses the contract-derived success status and Line 111 still serializes
output.bodyfor detailed outputs. This makes streamed OpenAPI responses unable to use configured status codes or detailed{ headers, body }metadata.🐛 Proposed fix
encode(output: unknown, procedure: AnyProcedure): StandardResponse { + const successStatus = fallbackContractConfig('defaultSuccessStatus', procedure['~orpc'].route.successStatus) + const outputStructure = fallbackContractConfig('defaultOutputStructure', procedure['~orpc'].route.outputStructure) + if (output instanceof ReadableStream) { return { - status: 200, + status: successStatus, headers: {}, body: output, } } - const successStatus = fallbackContractConfig('defaultSuccessStatus', procedure['~orpc'].route.successStatus) - const outputStructure = fallbackContractConfig('defaultOutputStructure', procedure['~orpc'].route.outputStructure) - if (outputStructure === 'compact') { return { status: successStatus, @@ return { status: output.status ?? successStatus, headers: output.headers ?? {}, - body: this.serializer.serialize(output.body), + body: output.body instanceof ReadableStream + ? output.body + : this.serializer.serialize(output.body), } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/openapi/src/adapters/standard/openapi-codec.ts` around lines 75 - 112, The current ReadableStream branch short-circuits and ignores contract-derived status/headers and later always calls this.serializer.serialize(output.body); update the logic so streamed bodies retain OpenAPI status/headers semantics: use fallbackContractConfig('defaultSuccessStatus', procedure['~orpc'].route.successStatus) for compact streams (return {status: successStatus, headers:{}, body: stream}) and treat detailed outputs that have a stream in output.body as valid in `#isDetailedOutput` path but do not call this.serializer.serialize on stream bodies—return the stream directly; ensure `#isDetailedOutput` still validates objects with optional status/headers/body and that headers default to {} and status defaults to successStatus when omitted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/standard-server-fetch/src/body.test.ts`:
- Around line 280-298: The test currently expects all stream-related headers to
be stripped; update it to assert that non-JSON stream headers are preserved by
toFetchBody: create the ReadableStream and pass headers that include
'content-type': 'application/zip' and 'content-disposition': 'attachment;
filename="foo.zip"' alongside the existing custom header, call
toFetchBody(stream, headers, {}), assert the returned body is the original
stream, then assert that the headers iterable still contains x-custom-header
plus content-type and content-disposition entries, and finally verify the stream
can be read (e.g., via new Response(body).arrayBuffer() or .text()) to ensure
conversion didn't consume/alter the stream. Ensure you reference toFetchBody and
the test case name (readable stream) when making the change.
In `@packages/standard-server-node/src/body.test.ts`:
- Around line 408-426: Update the "readable stream" test to include and assert
preservation of media metadata: add 'content-type' and 'content-disposition'
into the headers (e.g. extend baseHeaders before calling toNodeHttpBody) and
after calling toNodeHttpBody assert those header keys and their values are still
present (alongside 'x-custom-header'); reference the test symbols headers,
baseHeaders and the toNodeHttpBody call so the test ensures content-type and
content-disposition are preserved for raw stream payloads.
---
Outside diff comments:
In `@packages/openapi/src/adapters/standard/openapi-codec.ts`:
- Around line 75-112: The current ReadableStream branch short-circuits and
ignores contract-derived status/headers and later always calls
this.serializer.serialize(output.body); update the logic so streamed bodies
retain OpenAPI status/headers semantics: use
fallbackContractConfig('defaultSuccessStatus',
procedure['~orpc'].route.successStatus) for compact streams (return {status:
successStatus, headers:{}, body: stream}) and treat detailed outputs that have a
stream in output.body as valid in `#isDetailedOutput` path but do not call
this.serializer.serialize on stream bodies—return the stream directly; ensure
`#isDetailedOutput` still validates objects with optional status/headers/body and
that headers default to {} and status defaults to successStatus when omitted.
In `@packages/standard-server-fetch/src/body.ts`:
- Around line 70-100: The code currently unconditionally deletes content-type
and content-disposition (headers.delete('content-type') /
headers.delete('content-disposition')) before branching on body types, which
strips metadata for raw ReadableStream responses; change the logic so headers
are only removed for body types that will be re-set (e.g., Blob, File, FormData,
URLSearchParams) and do not delete these headers when body is a ReadableStream —
i.e., move or gate the header deletion behind the non-stream branches (or
early-return for ReadableStream) so ReadableStream responses keep their existing
content-type and content-disposition (reference variables/functions: headers,
body, currentContentDisposition, Blob, File, ReadableStream,
generateContentDisposition).
In `@packages/standard-server-node/src/body.ts`:
- Around line 65-97: The stream branch currently runs after we unconditionally
delete headers['content-type'] and headers['content-disposition'], which strips
explicit stream response headers (e.g., application/zip and attachment
filename); move the "if (body instanceof ReadableStream) return
Readable.fromWeb(body)" branch to before the header deletion so explicit
response headers are preserved (refer to the identifier body and
Readable.fromWeb), and change the header cleanup around lines that touch
headers['content-type'] / headers['content-disposition'] to only remove stale
JSON defaults (do not remove existing content-type/content-disposition when body
is a stream or Blob/File—see currentContentDisposition and
generateContentDisposition usage).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4e3c9da6-a1c8-4975-8416-3f7ed5eca230
📒 Files selected for processing (9)
packages/openapi/src/adapters/standard/openapi-codec.tspackages/server/src/adapters/standard/rpc-codec.test.tspackages/server/src/adapters/standard/rpc-codec.tspackages/standard-server-fetch/src/body.test.tspackages/standard-server-fetch/src/body.tspackages/standard-server-node/src/body.test.tspackages/standard-server-node/src/body.tspackages/standard-server/src/types.tspackages/standard-server/src/utils.ts
|
Happy to merge this to partially support ReadableStream in OpenAPIHandler (only) |
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/client
@orpc/contract
@orpc/experimental-durable-iterator
@orpc/hey-api
@orpc/interop
@orpc/json-schema
@orpc/nest
@orpc/openapi
@orpc/openapi-client
@orpc/otel
@orpc/experimental-pino
@orpc/experimental-publisher
@orpc/experimental-publisher-durable-object
@orpc/experimental-ratelimit
@orpc/react
@orpc/react-query
@orpc/experimental-react-swr
@orpc/server
@orpc/shared
@orpc/solid-query
@orpc/standard-server
@orpc/standard-server-aws-lambda
@orpc/standard-server-fastify
@orpc/standard-server-fetch
@orpc/standard-server-node
@orpc/standard-server-peer
@orpc/svelte-query
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/vue-colada
@orpc/vue-query
@orpc/zod
commit: |
…support detailed stream output
- Move ReadableStream branch before header deletion in toFetchBody and
toNodeHttpBody so caller-set content-type/content-disposition are preserved
- In StandardOpenAPICodec, read successStatus before the ReadableStream check
so contract-configured status codes are respected
- Support ReadableStream inside detailed output ({ body: stream, headers })
in StandardOpenAPICodec, allowing custom headers alongside streaming responses
- Update tests to assert content-type/content-disposition preservation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…a branches are empty
When a schema union has file branches (e.g. contentMediaType: application/zip)
and all remaining branches resolve to nothing (empty anyOf/oneOf), the generator
was emitting a spurious 'application/json: { schema: { anyOf: [] } }' entry.
Skip the application/json content entry when the rest schema is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/openapi/src/openapi-utils.ts`:
- Around line 36-42: The current isEmptyRest flag incorrectly treats a boolean
false schema as empty (because of "restSchema === false"), causing
toOpenAPIContent(false) to return {} and drop "{ not: {} }"; change the logic so
only object schemas with extracted file-branch unions are considered empty:
remove the "restSchema === false" clause and compute isEmptyRest only when
isObject(restSchema) && ((Array.isArray(restSchema.anyOf) &&
restSchema.anyOf.length === 0) || (Array.isArray(restSchema.oneOf) &&
restSchema.oneOf.length === 0)); ensure the subsequent if (restSchema !==
undefined && !isEmptyRest) still includes boolean false cases so the original
JSON schema (e.g., { not: {} }) is preserved. Use the restSchema and isEmptyRest
symbols in openapi-utils.ts to locate and update this check.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0e9ff75d-c111-4ce7-8c55-504bb610554d
📒 Files selected for processing (2)
packages/openapi/src/openapi-utils.test.tspackages/openapi/src/openapi-utils.ts
|
Hey @ghdoergeloh, I’ve updated this PR and also fixed issue #1536. When you have a moment, could you please take another look? |
- Extend isAnySchema to treat { not: {} } as "any" — this is the JSON
Schema representation of z.instanceof(X), which has no meaningful
constraint. Without this, toOpenAPIContent would emit a spurious
application/json: { schema: not: {} } entry alongside binary content.
- In the generator, skip emitting requestBody when separateObjectSchema
leaves an empty remainder (no properties, no required) after extracting
path params — { type: 'object' } alone is not a meaningful body schema.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
On top of @dinwwwh's fix, I've added two more things: 1.
2. Generator skips
|
Per review feedback: { not: {} } means "unsupported" (nothing matches),
not "any" — so it shouldn't be reinterpreted in isAnySchema. Instead,
suppress the application/json content entry in toOpenAPIContent when the
rest branch is false/{not:{}} AND a file branch was extracted, keeping
the original semantics for standalone unsupported schemas.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Base on your update, I made a few tweaks. I think it looks really solid now - when you have a moment, please take a look @ghdoergeloh. |
|
Much cleaner with |
|
Updated the PR description:
|
Summary
Adds partial support for returning a
ReadableStream<Uint8Array>directly from an oRPC handler (OpenAPIHandler only), bypassing JSON serialization. This enables streaming binary responses (ZIP archives, large file downloads, chunked transfer) without buffering the full response in memory.Stream passthrough
@orpc/standard-server— addsReadableStream<Uint8Array>to theStandardBodyunion type@orpc/server—StandardRPCCodec.encode()detectsReadableStreamand passes it through without serializing@orpc/openapi—StandardOpenAPICodec.encode()detectsReadableStream(both as direct return and inside{ body: stream, headers }detailed output), respects the contract'ssuccessStatus, and bypasses serialization@orpc/standard-server-fetch—toFetchBody()returns the stream directly; theReadableStreambranch runs before header cleanup so caller-setcontent-type/content-dispositionare preserved@orpc/standard-server-node—toNodeHttpBody()converts viaReadable.fromWeb(); same header-preservation fix@orpc/standard-server—generateContentDisposition()gets an optionaldispositionparameter (defaults to'inline', fully backward-compatible)OpenAPI spec cleanup (fixes #1536)
@orpc/openapi—separateObjectSchemano longer leaves emptyproperties: {}/required: []on extracted schemas (replaces them withundefined)@orpc/openapi— newisNeverSchema()helper identifiesfalse/{ not: true }/{ not: {} }schemas;toOpenAPIContent()now skips the spuriousapplication/json: { schema: { not: {} } }entry that used to appear alongside binary content (e.g. when usingoz.openapi(z.instanceof(ReadableStream), { contentMediaType: 'application/zip' }))@orpc/openapi— generator skips emitting an emptyrequestBodyfor POST routes whose input consists only of path parametersTests added for all new paths including header preservation, detailed-output streaming, never-schema detection, and the empty-requestBody case.
Backward compatibility
This is a non-breaking change.
ReadableStreamfrom a handler would result in it being serialized as{}(or a runtime error), which is not useful behaviour anyone would rely on.generateContentDisposition()'s newdispositionparameter defaults to'inline', preserving existing output.not: {}alongside real content).Existing handlers and generated specs are unaffected.
Motivation
We are migrating a service from Hapi.js to Hono + oRPC. One endpoint streams a ZIP archive on-the-fly using
archiver— the archive must be piped directly to the response. All existing workarounds (returningnew Response(stream),oz.blob(), casting) either fail at runtime or require bypassing oRPC entirely, losing auth middleware and client-library support.Relates to #1058 (partial — OpenAPIHandler only)
Closes #1536
Test plan
pnpm --filter @orpc/standard-server testpnpm --filter @orpc/server testpnpm --filter @orpc/openapi testpnpm --filter @orpc/standard-server-fetch testpnpm --filter @orpc/standard-server-node test🤖 Generated with Claude Code