Skip to content

Support receiving a raw streaming response body (ReadableStream) on the client without buffering into a Blob #1575

@PurpleTape

Description

@PurpleTape

Describe the feature

Summary

When a procedure returns binary data, the typed client (OpenAPILink / RPCLink) deserializes the HTTP response into a Blob/File, which reads the entire body into memory before handing it back. There is currently no way to receive the response body as a lazy ReadableStream<Uint8Array> through the typed client.

This makes it impossible to consume a streaming binary response progressively (e.g. feeding chunks into MediaSource as they arrive). The only true client-side streaming primitive is the Event Iterator (SSE), which base64-encodes binary chunks and adds framing overhead — not suitable for low-latency raw audio.

I'd like to request first-class support for a pass-through streaming response body on the client.

Use case

Streaming TTS audio for live playback:

  • Server streams encoded audio bytes (e.g. audio/mpeg) as the response body.
  • Client should append chunks to a MediaSource SourceBuffer as they arrive, so playback can start before the full clip is downloaded.

For this, the client needs the raw response.body (ReadableStream<Uint8Array>), not a buffered Blob.

Current behavior

Contract (binary response via outputStructure: 'detailed'):

readMessage: oc
  .route({ method: 'POST', path: '/chats/read-message', outputStructure: 'detailed' })
  .input(z.object({ messageId: z.uuid() }))
  .output(
    z.object({
      headers: z.object({ 'content-type': z.string() }),
      body: z.custom<ReadableStream<Uint8Array>>(),
    }),
  )

Client:

const { headers, body } = await client.chats.readMessage({ messageId })
// `body` is a fully-downloaded Blob, not a lazy ReadableStream.
// The whole response is buffered before this line resolves.

Observations:

  • z.custom<ReadableStream<Uint8Array>>() only affects TypeScript types; at runtime the link still produces a buffered Blob/File.
  • Using a lazy file on the server (e.g. @mjackson/lazy-file, Bun.file) helps server memory and time-to-first-byte over the wire, but the client still buffers the whole body into a Blob.
  • Event Iterator works through the client but is SSE + base64 (≈ +33% size, per-chunk decode, framing latency), which is wasteful for raw audio.

Expected / proposed behavior

A first-class way to receive the response body lazily through the typed client, without buffering. Some possible shapes (open to whatever fits the architecture best):

  1. A link option or per-call client-context flag, e.g. responseType: 'stream', that returns the raw Response / response.body and skips body deserialization.
  2. Treating ReadableStream (and/or Response) as a native, pass-through output type that the codec does not consume.
  3. A documented, supported escape hatch on OpenAPILink / RPCLink to obtain the raw Response for a given call while still using the contract to build the request (URL, method, headers, body serialization).

Ideally the request would still be built from the contract (so auth headers, URL, and input serialization stay consistent with the rest of the client), and only the response side would be left unparsed.

Current workaround

Bypassing the typed client for this one call with a plain fetch works, but loses the contract integration on the response side:

const res = await fetch(`${BASE_URL}/chats/read-message`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' /* + auth */ },
  body: JSON.stringify({ messageId }),
  signal,
})
const stream = res.body // real ReadableStream<Uint8Array>

It's also possible to use the fetch option of OpenAPILink as a "request builder" (capture the built Request, then fetch it manually and read response.body), but that relies on aborting the pipeline before deserialization and feels like a hack rather than an intended API.

What I already checked

  • The RPC protocol exposes streaming only via AsyncIteratorObject (event iterator); binary values are Blob/File, which are read fully on decode. There is no pass-through ReadableStream output type.
  • Discussion How to serve json file using ORPC #1052 confirms the intended file-download path is returning a File directly — i.e. a buffered File, not a lazy stream.
  • The server-side lazy-file recommendation (@mjackson/lazy-file, Bun.file) reduces server memory but does not change client-side buffering.

I couldn't find an existing issue/discussion covering raw streaming response bodies on the client; apologies if I missed one.

Questions

  • Is raw streaming response support already planned (the React Native docs reference a "Support Stream" tracking item)? If so, is there an issue to follow?
  • Is there a recommended/supported way to get response.body through the typed client today that I've missed?

Additional information

  • Would you be willing to help implement this feature?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions