Skip to content

Commit b6b8746

Browse files
ghdoergelohclaudedinwwwh
authored
feat(server,openapi): support ReadableStream<Uint8Array> as handler return value (#1535)
## 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. > **Note:** Full ecosystem support (RPCHandler/Link, OpenAPILink, WebSocket adapters, etc.) is planned for v2 — see [#1058](#1058 (comment)). This PR intentionally does **not** close #1058. ### Stream passthrough - **`@orpc/standard-server`** — adds `ReadableStream<Uint8Array>` to the `StandardBody` union type - **`@orpc/server`** — `StandardRPCCodec.encode()` detects `ReadableStream` and passes it through without serializing - **`@orpc/openapi`** — `StandardOpenAPICodec.encode()` detects `ReadableStream` (both as direct return and inside `{ body: stream, headers }` detailed output), respects the contract's `successStatus`, and bypasses serialization - **`@orpc/standard-server-fetch`** — `toFetchBody()` returns the stream directly; the `ReadableStream` branch runs before header cleanup so caller-set `content-type`/`content-disposition` are preserved - **`@orpc/standard-server-node`** — `toNodeHttpBody()` converts via `Readable.fromWeb()`; same header-preservation fix - **`@orpc/standard-server`** — `generateContentDisposition()` gets an optional `disposition` parameter (defaults to `'inline'`, fully backward-compatible) ### OpenAPI spec cleanup (fixes #1536) - **`@orpc/openapi`** — `separateObjectSchema` no longer leaves empty `properties: {}` / `required: []` on extracted schemas (replaces them with `undefined`) - **`@orpc/openapi`** — new `isNeverSchema()` helper identifies `false` / `{ not: true }` / `{ not: {} }` schemas; `toOpenAPIContent()` now skips the spurious `application/json: { schema: { not: {} } }` entry that used to appear alongside binary content (e.g. when using `oz.openapi(z.instanceof(ReadableStream), { contentMediaType: 'application/zip' })`) - **`@orpc/openapi`** — generator skips emitting an empty `requestBody` for POST routes whose input consists only of path parameters Tests 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. - Previously, returning a `ReadableStream` from a handler would result in it being serialized as `{}` (or a runtime error), which is not useful behaviour anyone would rely on. - `generateContentDisposition()`'s new `disposition` parameter defaults to `'inline'`, preserving existing output. - The OpenAPI spec cleanups only suppress output that was previously noise (empty bodies, `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 (returning `new 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 test` - [ ] `pnpm --filter @orpc/server test` - [ ] `pnpm --filter @orpc/openapi test` - [ ] `pnpm --filter @orpc/standard-server-fetch test` - [ ] `pnpm --filter @orpc/standard-server-node test` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Dinh Le <[email protected]>
1 parent 0ee06ff commit b6b8746

17 files changed

Lines changed: 297 additions & 14 deletions

File tree

apps/content/docs/openapi/openapi-handler.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ The `OpenAPIHandler` enables communication with clients over RESTful APIs, adher
2727
- **Blob** (unsupported in `AsyncIteratorObject`)
2828
- **File** (unsupported in `AsyncIteratorObject`)
2929
- **AsyncIteratorObject** (only at the root level; powers the [Event Iterator](/docs/event-iterator))
30+
- **ReadableStream\<Uint8Array\>** (supported only at the root level in `OpenAPIHandler`; not supported by client-side `OpenAPILink` until v2)
3031

3132
::: warning
3233
If a payload contains `Blob` or `File` outside the root level, it must use `multipart/form-data`. In such cases, oRPC applies [Bracket Notation](/docs/openapi/bracket-notation) and converts other types to strings (exclude `null` and `undefined` will not be represented).

packages/openapi/src/adapters/standard/openapi-codec.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,26 @@ describe('standardOpenAPICodec', () => {
182182
expect(serializer.serialize).toHaveBeenCalledWith('__output__')
183183
})
184184

185+
it('with ReadableStream bypasses serialization and respects successStatus', () => {
186+
const procedure = new Procedure({
187+
...ping['~orpc'],
188+
route: {
189+
successStatus: 202,
190+
},
191+
})
192+
const stream = new ReadableStream<Uint8Array>()
193+
194+
const response = codec.encode(stream, procedure)
195+
196+
expect(response).toEqual({
197+
status: 202,
198+
headers: {},
199+
body: stream,
200+
})
201+
202+
expect(serializer.serialize).not.toHaveBeenCalled()
203+
})
204+
185205
describe('with detailed structure', async () => {
186206
const procedure = new Procedure({
187207
...ping['~orpc'],
@@ -251,6 +271,24 @@ describe('standardOpenAPICodec', () => {
251271
expect(serializer.serialize).toHaveBeenCalledWith('__output__')
252272
})
253273

274+
it('works with ReadableStream body', () => {
275+
const stream = new ReadableStream<Uint8Array>()
276+
const output = {
277+
body: stream,
278+
headers: { 'content-type': 'application/zip' },
279+
}
280+
281+
const response = codec.encode(output, procedure)
282+
283+
expect(response).toEqual({
284+
status: 298,
285+
headers: { 'content-type': 'application/zip' },
286+
body: stream,
287+
})
288+
289+
expect(serializer.serialize).not.toHaveBeenCalled()
290+
})
291+
254292
it.each([
255293
'invalid',
256294
{ status: 'invalid' },

packages/openapi/src/adapters/standard/openapi-codec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,18 @@ export class StandardOpenAPICodec implements StandardCodec {
7373

7474
encode(output: unknown, procedure: AnyProcedure): StandardResponse {
7575
const successStatus = fallbackContractConfig('defaultSuccessStatus', procedure['~orpc'].route.successStatus)
76+
7677
const outputStructure = fallbackContractConfig('defaultOutputStructure', procedure['~orpc'].route.outputStructure)
7778

7879
if (outputStructure === 'compact') {
80+
if (output instanceof ReadableStream) {
81+
return {
82+
status: successStatus,
83+
headers: {},
84+
body: output,
85+
}
86+
}
87+
7988
return {
8089
status: successStatus,
8190
headers: {},
@@ -97,6 +106,14 @@ export class StandardOpenAPICodec implements StandardCodec {
97106
`)
98107
}
99108

109+
if (output.body instanceof ReadableStream) {
110+
return {
111+
status: output.status ?? successStatus,
112+
headers: output.headers ?? {},
113+
body: output.body,
114+
}
115+
}
116+
100117
return {
101118
status: output.status ?? successStatus,
102119
headers: output.headers ?? {},

packages/openapi/src/openapi-generator.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ const inputTests: TestCase[] = [
136136
},
137137
},
138138
},
139+
{
140+
name: 'dynamic params only (no body)',
141+
contract: oc.route({ method: 'POST', path: '/planets/{id}' }).input(z.object({ id: z.string() })),
142+
expected: {
143+
'/planets/{id}': {
144+
post: expect.toSatisfy((v: any) => v.parameters?.length === 1 && !v.requestBody),
145+
},
146+
},
147+
},
139148
{
140149
name: 'query + params',
141150
contract: oc.route({ path: '/planets/{id}', method: 'GET' }).input(
@@ -1428,7 +1437,6 @@ describe('openAPIGenerator', () => {
14281437
properties: {
14291438
parent: { $ref: '#/components/schemas/User' },
14301439
},
1431-
required: [],
14321440
},
14331441
},
14341442
},
@@ -1707,7 +1715,6 @@ describe('openAPIGenerator', () => {
17071715
a: { type: 'string' },
17081716
b: { type: 'number' },
17091717
},
1710-
required: [],
17111718
},
17121719
},
17131720
},

packages/openapi/src/openapi-generator.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ export class OpenAPIGenerator {
307307
},
308308
)
309309

310+
let omitResponseBody = false
311+
310312
if (isAnySchema(schema) && !dynamicParams?.length) {
311313
return
312314
}
@@ -329,6 +331,7 @@ export class OpenAPIGenerator {
329331

330332
schema = rest
331333
required = rest.required ? rest.required.length !== 0 : false
334+
omitResponseBody = !required && !rest.properties
332335

333336
if (!checkParamsSchema(paramsSchema, dynamicParams)) {
334337
throw error
@@ -348,7 +351,7 @@ export class OpenAPIGenerator {
348351
ref.parameters ??= []
349352
ref.parameters.push(...toOpenAPIParameters(schema, 'query'))
350353
}
351-
else {
354+
else if (!omitResponseBody) {
352355
ref.requestBody = {
353356
required,
354357
content: toOpenAPIContent(schema),

packages/openapi/src/openapi-utils.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,34 @@ describe('toOpenAPIContent', () => {
6969
})
7070
})
7171

72+
it('omits unconstrained non-file branches', () => {
73+
expect(toOpenAPIContent({
74+
anyOf: [
75+
fileSchema,
76+
{},
77+
],
78+
})).toEqual({
79+
'image/png': {
80+
schema: fileSchema,
81+
},
82+
})
83+
84+
expect(toOpenAPIContent({ properties: undefined })).toEqual({})
85+
})
86+
87+
it('omits never non-file branches', () => {
88+
expect(toOpenAPIContent({
89+
anyOf: [
90+
fileSchema,
91+
{ not: {} },
92+
],
93+
})).toEqual({
94+
'image/png': {
95+
schema: fileSchema,
96+
},
97+
})
98+
})
99+
72100
it('body contain file schema', () => {
73101
const schema: JSONSchema = {
74102
type: 'object',

packages/openapi/src/openapi-utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { OpenAPI } from '@orpc/contract'
33
import type { FileSchema, JSONSchema, ObjectSchema } from './schema'
44
import { standardizeHTTPPath } from '@orpc/openapi-client/standard'
55
import { findDeepMatches, isObject, stringifyJSON, toArray } from '@orpc/shared'
6-
import { expandArrayableSchema, filterSchemaBranches, isFileSchema, isObjectSchema, isPrimitiveSchema } from './schema-utils'
6+
import { expandArrayableSchema, filterSchemaBranches, isAnySchema, isFileSchema, isNeverSchema, isObjectSchema, isPrimitiveSchema } from './schema-utils'
77

88
/**
99
* @internal
@@ -33,7 +33,7 @@ export function toOpenAPIContent(schema: JSONSchema): Record<string, OpenAPI.Med
3333
}
3434
}
3535

36-
if (restSchema !== undefined) {
36+
if (restSchema !== undefined && !isAnySchema(restSchema) && !isNeverSchema(restSchema)) {
3737
content['application/json'] = {
3838
schema: toOpenAPISchema(restSchema),
3939
}

packages/openapi/src/schema-utils.test.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
filterSchemaBranches,
88
isAnySchema,
99
isFileSchema,
10+
isNeverSchema,
1011
isObjectSchema,
1112
isPrimitiveSchema,
1213
separateObjectSchema,
@@ -36,6 +37,59 @@ it('isAnySchema', () => {
3637
expect(isAnySchema({})).toBe(true)
3738
expect(isAnySchema({ type: 'string' })).toBe(false)
3839
expect(isAnySchema({ description: 'description' })).toBe(true)
40+
expect(isAnySchema({ properties: undefined, required: undefined })).toBe(true)
41+
})
42+
43+
describe('isNeverSchema', () => {
44+
describe('returns true for never schemas', () => {
45+
it('returns true for boolean false', () => {
46+
expect(isNeverSchema(false)).toBe(true)
47+
})
48+
49+
it('returns true for { not: true }', () => {
50+
expect(isNeverSchema({ not: true })).toBe(true)
51+
})
52+
53+
it('returns true for { not: {} }', () => {
54+
expect(isNeverSchema({ not: {} })).toBe(true)
55+
})
56+
})
57+
58+
describe('returns false for non-never schemas', () => {
59+
it('returns false for boolean true', () => {
60+
expect(isNeverSchema(true)).toBe(false)
61+
})
62+
63+
it('returns false for an empty schema {}', () => {
64+
expect(isNeverSchema({})).toBe(false)
65+
})
66+
67+
it('returns false for a type constraint', () => {
68+
expect(isNeverSchema({ type: 'string' })).toBe(false)
69+
})
70+
71+
it('returns false for { not: false } (double negation = always true)', () => {
72+
expect(isNeverSchema({ not: false })).toBe(false)
73+
})
74+
75+
it('returns false for { not: { type: \'string\' } } (only rejects strings)', () => {
76+
expect(isNeverSchema({ not: { type: 'string' } })).toBe(false)
77+
})
78+
79+
it('returns false for a schema with only additionalProperties', () => {
80+
expect(isNeverSchema({ additionalProperties: false })).toBe(false)
81+
})
82+
83+
it('returns false for a complex schema', () => {
84+
expect(
85+
isNeverSchema({
86+
type: 'object',
87+
properties: { id: { type: 'number' } },
88+
required: ['id'],
89+
}),
90+
).toBe(false)
91+
})
92+
})
3993
})
4094

4195
describe('separateObjectSchema', () => {
@@ -121,7 +175,6 @@ describe('separateObjectSchema', () => {
121175
properties: {
122176
b: { type: 'string' },
123177
},
124-
required: [],
125178
additionalProperties: true,
126179
})
127180
})
@@ -152,11 +205,28 @@ describe('separateObjectSchema', () => {
152205

153206
const [matched, rest] = separateObjectSchema(schema, ['a'])
154207

155-
expect(matched).toEqual({
156-
...schema,
208+
expect(matched).toEqual(schema)
209+
expect(rest).toEqual(schema)
210+
})
211+
212+
it('with empty properties & required', () => {
213+
const schema: ObjectSchema = {
214+
type: 'object',
215+
description: 'description',
157216
properties: {},
217+
required: [],
218+
}
219+
220+
const [matched, rest] = separateObjectSchema(schema, ['a'])
221+
222+
expect(matched).toEqual({
223+
type: 'object',
224+
description: 'description',
225+
})
226+
expect(rest).toEqual({
227+
type: 'object',
228+
description: 'description',
158229
})
159-
expect(rest).toEqual(schema)
160230
})
161231
})
162232

packages/openapi/src/schema-utils.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,39 @@ export function isAnySchema(schema: JSONSchema): boolean {
2424
return true
2525
}
2626

27-
if (Object.keys(schema).every(k => !LOGIC_KEYWORDS.includes(k))) {
27+
if (
28+
Object
29+
.keys(schema)
30+
.filter(v => schema[v as keyof typeof schema] !== undefined)
31+
.every(k => !LOGIC_KEYWORDS.includes(k))
32+
) {
2833
return true
2934
}
3035

3136
return false
3237
}
3338

39+
export function isNeverSchema(schema: JSONSchema): boolean {
40+
// Boolean `false` is the shorthand never-schema
41+
if (schema === false) {
42+
return true
43+
}
44+
45+
if (typeof schema === 'object' && schema.not !== undefined) {
46+
// `{ not: true }` — negation of the boolean catch-all
47+
if (schema.not === true) {
48+
return true
49+
}
50+
51+
// `{ not: {} }` — negation of the empty object, which accepts everything
52+
if (typeof schema.not === 'object' && Object.keys(schema.not).length === 0) {
53+
return true
54+
}
55+
}
56+
57+
return false
58+
}
59+
3460
/**
3561
* @internal
3662
*/
@@ -57,8 +83,16 @@ export function separateObjectSchema(schema: ObjectSchema, separatedProperties:
5783
return acc
5884
}, {})
5985

86+
if (Object.keys(matched.properties).length === 0) {
87+
matched.properties = undefined
88+
}
89+
6090
matched.required = schema.required?.filter(key => separatedProperties.includes(key))
6191

92+
if (matched.required?.length === 0) {
93+
matched.required = undefined
94+
}
95+
6296
matched.examples = schema.examples?.map((example) => {
6397
if (!isObject(example)) {
6498
return example
@@ -75,13 +109,17 @@ export function separateObjectSchema(schema: ObjectSchema, separatedProperties:
75109

76110
rest.properties = schema.properties && Object.entries(schema.properties)
77111
.filter(([key]) => !separatedProperties.includes(key))
78-
.reduce((acc, [key, value]) => {
112+
.reduce((acc: Record<string, JSONSchema> = {}, [key, value]) => {
79113
acc[key] = value
80114
return acc
81-
}, {} as Record<string, JSONSchema>)
115+
}, undefined)
82116

83117
rest.required = schema.required?.filter(key => !separatedProperties.includes(key))
84118

119+
if (rest.required?.length === 0) {
120+
rest.required = undefined
121+
}
122+
85123
rest.examples = schema.examples?.map((example) => {
86124
if (!isObject(example)) {
87125
return example

0 commit comments

Comments
 (0)