Skip to content

Commit a214208

Browse files
authored
feat(node): add TmpFileUploadHandlerPlugin (#1863)
Adds `TmpFileUploadHandlerPlugin` to `@orpc/node`: file uploads and multipart file parts stream into per-request temporary files instead of memory, so requests far larger than available memory parse in constant memory. Kind-aware size limits let it replace the request limit plugin while sizing each kind of body to what it actually costs. Also upgrades `@standardserver/*` to 0.8.0 repo-wide and adopts its `resolveStandardBodyHint`, so the plugin decides body kinds exactly as the standard parsers do. ## Behavior - Procedures receive lazily read `TmpFile` instances (a `File` exposing its backing `path`), so an upload can be kept with a cheap rename; files are removed when the request finishes. - Streaming response bodies, event iterators and raw streams, keep the tmp files alive until they complete, so a response can read the upload while transmitting; abandoned transfers clean up only after in-flight procedure logic finishes. - `maxBodySize` groups three limits that are required together: `memory` (JSON, urlencoded, multipart fields), `file` (content spooled to disk), and `stream` (content consumed on the fly). A declared content-length over the limit rejects before any byte is read, enforcement continues while streaming so a lying length cannot bypass it, and a multipart body as a whole is bounded by the memory and file limits combined so framing cannot hide bytes. Limit and storage errors carry no implementation details to the client, and filesystem failures keep their 500 status through the decode step's client-error mapping. - Multipart parsing is a dependency-free streaming parser aligned with the standard parser: entry order, WHATWG `%22`/`%0D`/`%0A` decoding, path-preserving filenames, UTF-8 field values, preserved content-type parameters, and strict rejection of malformed parts. ## Testing - 136 unit tests, including differential fuzzing against `Response.formData()` down to 1-byte chunking with async delivery, drip-fed socket uploads, flat file-descriptor usage across 300-part bodies, and cleanup on every error path. - Integration suite covers 32MB uploads through `RPCLink`, composition with the request compression and request limit plugins, streaming-response echo, and abort cleanup; the plugin joined the all-plugins matrix test. - Full monorepo suite, type check, lint, and `docs:validate` pass on the 0.8.0 upgrade; docs page added at `docs/plugins/tmp-file-upload`.
1 parent 2e405dd commit a214208

25 files changed

Lines changed: 2839 additions & 128 deletions

apps/content/docs/binary-data.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const cors = new CORSHandlerPlugin({
2323
Procedures can accept `File` and `Blob` as input and return them directly or inside nested structures.
2424

2525
:::warning
26-
`File` and `Blob` are usually buffered in memory by default. For large files, we recommend extending the body parser for better performance and reliability.
26+
`File` and `Blob` are buffered in memory by default. For large files on Node, use the [Tmp File Upload Plugin](/docs/plugins/tmp-file-upload) to stream uploads into temporary files instead.
2727
:::
2828

2929
```ts twoslash
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
title: "Tmp File Upload Plugin"
3+
description: "Stream large file uploads into temporary files instead of memory, so requests far larger than available memory are parsed safely."
4+
sidebar:
5+
label: "Tmp File Upload"
6+
---
7+
8+
## Installation
9+
10+
```package-install
11+
npm install @orpc/node@beta
12+
```
13+
14+
## Setup
15+
16+
Use `TmpFileUploadHandlerPlugin` to parse file uploads into temporary files. Bodies the standard parser would buffer into an in-memory [File](https://developer.mozilla.org/en-US/docs/Web/API/File), and [`multipart/form-data`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types#multipartform-data) file parts, stream to disk instead. Every other body is left to the standard parser.
17+
18+
```ts
19+
import { TmpFileUploadHandlerPlugin } from '@orpc/node'
20+
import { RPCHandler } from '@orpc/server/node'
21+
22+
const handler = new RPCHandler(router, {
23+
plugins: [
24+
new TmpFileUploadHandlerPlugin({
25+
/**
26+
* The directory temporary files are created under. Each request that
27+
* spools an upload gets its own subdirectory inside it, removed when
28+
* the request finishes.
29+
*
30+
* @default os.tmpdir()
31+
*/
32+
tmpDir: './uploads',
33+
}),
34+
],
35+
})
36+
```
37+
38+
:::info
39+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
40+
:::
41+
42+
## Working with Uploaded Files
43+
44+
Procedures receive ordinary `File` instances and read them lazily from disk, in constant memory. Each one is a `TmpFile` exposing the `path` of its backing file, so an upload can be kept with a cheap [rename](https://nodejs.org/api/fs.html#fspromisesrenameoldpath-newpath) instead of a copy:
45+
46+
```ts
47+
import { TmpFile } from '@orpc/node'
48+
import { rename } from 'node:fs/promises'
49+
50+
const uploadVideo = os
51+
.input(z.object({ video: z.file() }))
52+
.handler(async ({ input }) => {
53+
if (input.video instanceof TmpFile) {
54+
await rename(input.video.path, `./videos/${crypto.randomUUID()}`)
55+
}
56+
})
57+
```
58+
59+
:::warning
60+
Temporary files are removed when the request finishes. A streaming response body, an event iterator or a raw stream, keeps them alive until it completes. Any other response is transmitted after removal, so a response that embeds the upload itself, as a `File` or inside `FormData`, needs the content copied or the file moved first. A moved or removed file can no longer be read through its `File` instance.
61+
:::
62+
63+
## Limiting Body Sizes
64+
65+
Use `maxBodySize` to limit each kind of request body by what it actually costs. All three kinds are required together, so none is left unbounded by accident; set a kind to `Number.POSITIVE_INFINITY` to deliberately leave it unlimited. A body over its limit rejects with `PAYLOAD_TOO_LARGE`.
66+
67+
```ts
68+
const handler = new RPCHandler(router, {
69+
plugins: [
70+
new TmpFileUploadHandlerPlugin({
71+
maxBodySize: {
72+
/**
73+
* Content parsed into memory: JSON, URL-encoded forms, and the
74+
* plain fields of a multipart body. Usually the lowest limit.
75+
*/
76+
memory: 1024 * 1024,
77+
78+
/**
79+
* Content streamed into temporary files: file bodies and the
80+
* file parts of a multipart body combined.
81+
*/
82+
file: 10 * 1024 * 1024 * 1024,
83+
84+
/**
85+
* Content consumed as a stream: event streams and raw binary
86+
* streams, enforced while the stream is consumed. Usually the
87+
* highest limit.
88+
*/
89+
stream: Number.POSITIVE_INFINITY,
90+
},
91+
}),
92+
],
93+
})
94+
```
95+
96+
A multipart body splits across the first two limits, fields against `memory` and file parts against `file`, and as a whole, framing included, it is bounded by the sum of both. A declared [Content-Length](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Length) over the applicable limit rejects immediately, and enforcement continues while the body streams in, so a lying length cannot bypass it.
97+
98+
With all three limits configured, the plugin subsumes the [Request Limit Plugin](/docs/plugins/request-limit). When the [Request Compression Plugin](/docs/plugins/request-compression) is present, limits apply to the decompressed payload rather than the compressed wire size.
99+
100+
## Learn More
101+
102+
For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/node/src/tmp-file-upload-handler-plugin.ts).

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@
4040
"@orpc/tanstack-query": "workspace:*",
4141
"@orpc/valibot": "workspace:*",
4242
"@orpc/zod": "workspace:*",
43-
"@standardserver/core": "^0.7.1",
44-
"@standardserver/fetch": "^0.7.1",
45-
"@standardserver/peer": "^0.7.1",
46-
"@standardserver/shared": "^0.7.1",
43+
"@standardserver/core": "^0.8.0",
44+
"@standardserver/fetch": "^0.8.0",
45+
"@standardserver/peer": "^0.8.0",
46+
"@standardserver/shared": "^0.8.0",
4747
"@testing-library/dom": "^10.4.1",
4848
"@testing-library/react": "^16.3.2",
4949
"@types/node": "^26.2.0",

packages/bun/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"@orpc/ratelimit": "workspace:*",
4141
"@orpc/server": "workspace:*",
4242
"@orpc/shared": "workspace:*",
43-
"@standardserver/core": "^0.7.1"
43+
"@standardserver/core": "^0.8.0"
4444
},
4545
"devDependencies": {
4646
"@types/bun": "^1.3.14",

packages/client/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@
6767
},
6868
"dependencies": {
6969
"@orpc/shared": "workspace:*",
70-
"@standardserver/core": "^0.7.1",
71-
"@standardserver/fetch": "^0.7.1",
72-
"@standardserver/peer": "^0.7.1"
70+
"@standardserver/core": "^0.8.0",
71+
"@standardserver/fetch": "^0.8.0",
72+
"@standardserver/peer": "^0.8.0"
7373
},
7474
"devDependencies": {
7575
"zod": "^4.4.3"

packages/cloudflare/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"@orpc/publisher": "workspace:*",
4545
"@orpc/ratelimit": "workspace:*",
4646
"@orpc/shared": "workspace:*",
47-
"@standardserver/core": "^0.7.1"
47+
"@standardserver/core": "^0.8.0"
4848
},
4949
"devDependencies": {
5050
"@cloudflare/vitest-pool-workers": "^0.21.1",

packages/evlog/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"@orpc/client": "workspace:*",
5050
"@orpc/server": "workspace:*",
5151
"@orpc/shared": "workspace:*",
52-
"@standardserver/core": "^0.7.1"
52+
"@standardserver/core": "^0.8.0"
5353
},
5454
"devDependencies": {
5555
"evlog": "^2.26.0"

packages/hibernation/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"@orpc/client": "workspace:*",
4141
"@orpc/server": "workspace:*",
4242
"@orpc/shared": "workspace:*",
43-
"@standardserver/core": "^0.7.1",
44-
"@standardserver/peer": "^0.7.1"
43+
"@standardserver/core": "^0.8.0",
44+
"@standardserver/peer": "^0.8.0"
4545
}
4646
}

packages/nest/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@
5353
"@orpc/openapi": "workspace:*",
5454
"@orpc/server": "workspace:*",
5555
"@orpc/shared": "workspace:*",
56-
"@standardserver/core": "^0.7.1",
57-
"@standardserver/node": "^0.7.1"
56+
"@standardserver/core": "^0.8.0",
57+
"@standardserver/node": "^0.8.0"
5858
},
5959
"devDependencies": {
6060
"@fastify/cookie": "^11.0.2",

packages/node/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@
3939
"dependencies": {
4040
"@orpc/server": "workspace:*",
4141
"@orpc/shared": "workspace:*",
42-
"@standardserver/core": "^0.7.1",
42+
"@standardserver/core": "^0.8.0",
43+
"@standardserver/fetch": "^0.8.0",
4344
"mime": "^4.1.0"
4445
},
4546
"devDependencies": {
47+
"@orpc/client": "workspace:*",
4648
"supertest": "^7.2.2"
4749
}
4850
}

0 commit comments

Comments
 (0)