Skip to content

Commit 5f023ca

Browse files
authored
feat(node): resolve tmp file upload body limits per request (#1881)
`TmpFileUploadHandlerPlugin`'s `maxBodySize` now accepts a function, sync or async, alongside the fixed object it already took. The function receives the routing interceptor options (`context`, `request`, `prefix`), so an upload allowance can follow the request that carries it, such as a larger file limit for an authenticated user than for a guest. ## Behavior - A fixed `maxBodySize` object behaves exactly as before, and remains the default when the option is omitted. - The resolver runs only when a body is actually parsed, so a request that carries no body never pays for the lookup. - It receives the request as it arrived, before the plugin wraps body resolution. - All three limits now come from one resolution per parsed body, so a multipart body enforces `memory`, `file`, and their combined total from a single consistent snapshot. ## Types `TmpFileUploadHandlerPluginOptions` is now generic in the handler context, matching `BatchHandlerPluginOptions`. Existing call sites that pass a fixed object keep compiling untouched; `new TmpFileUploadHandlerPlugin<AppContext>({ ... })` types `context` inside the resolver. ## Testing Three cases cover context-driven limits rejecting a guest and admitting an authenticated user for memory-parsed bodies and for spooled files, with temporary files still cleaned up on rejection, plus a case asserting the resolver is skipped when no body is parsed. 145 tests pass across `packages/node`; root type check and lint are clean.
1 parent f02cd98 commit 5f023ca

3 files changed

Lines changed: 173 additions & 28 deletions

File tree

apps/content/docs/plugins/tmp-file-upload.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,26 @@ A multipart body splits across the first two limits, fields against `memory` and
101101

102102
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.
103103

104+
## Per-Request Limits
105+
106+
`maxBodySize` also accepts a function, sync or async, receiving the handler `context`, `request`, and `prefix`, so limits can follow who is uploading:
107+
108+
```ts
109+
const handler = new RPCHandler(router, {
110+
plugins: [
111+
new TmpFileUploadHandlerPlugin({
112+
maxBodySize: async ({ context }) => ({
113+
memory: 1024 * 1024, // 1MB
114+
file: context.user === undefined
115+
? 10 * 1024 * 1024 // 10MB for guests
116+
: 2 * 1024 * 1024 * 1024, // 2GB for authenticated users
117+
stream: Number.POSITIVE_INFINITY,
118+
}),
119+
}),
120+
],
121+
})
122+
```
123+
104124
## Learn More
105125

106126
For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/node/src/tmp-file-upload-handler-plugin.ts).

packages/node/src/tmp-file-upload-handler-plugin.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,4 +1125,111 @@ describe('tmpFileUploadHandlerPlugin', () => {
11251125
})
11261126
})
11271127
})
1128+
1129+
describe('per-request size limits', () => {
1130+
interface AuthContext { user?: string }
1131+
1132+
/**
1133+
* Runs a request through a plugin whose limits follow the request context:
1134+
* 16 bytes of every kind for a guest, 4096 for an authenticated user.
1135+
*/
1136+
async function runWithContextualLimits(options: {
1137+
context: AuthContext
1138+
headers?: Record<string, string>
1139+
body?: Buffer
1140+
/** How many times the routed request resolves its body. */
1141+
resolutions?: number
1142+
/** Runs while the request scope is still open, before tmp files are removed. */
1143+
inspect?: (resolved: unknown[]) => void | Promise<void>
1144+
}): Promise<{ resolverCalls: Array<{ context: AuthContext, url: string }>, resolved: unknown[] }> {
1145+
const resolverCalls: Array<{ context: AuthContext, url: string }> = []
1146+
1147+
const plugin = new TmpFileUploadHandlerPlugin<AuthContext>({
1148+
tmpDir,
1149+
maxBodySize: async ({ context, request }) => {
1150+
resolverCalls.push({ context, url: request.url })
1151+
1152+
const limit = context.user === undefined ? 16 : 4096
1153+
1154+
return { memory: limit, file: limit, stream: limit }
1155+
},
1156+
})
1157+
1158+
const interceptor = plugin.init({}).routingInterceptors![0]!
1159+
const resolved: unknown[] = []
1160+
1161+
await interceptor({
1162+
context: options.context,
1163+
prefix: undefined,
1164+
request: {
1165+
method: 'POST',
1166+
url: '/upload',
1167+
headers: options.headers ?? { 'content-type': 'application/json' },
1168+
resolveBody: async () => toStream(options.body ?? Buffer.alloc(0)),
1169+
},
1170+
next: async (nextOptions) => {
1171+
for (let i = 0; i < (options.resolutions ?? 1); i++) {
1172+
resolved.push(await nextOptions!.request.resolveBody())
1173+
}
1174+
1175+
await options.inspect?.(resolved)
1176+
1177+
return { matched: false }
1178+
},
1179+
})
1180+
1181+
return { resolverCalls, resolved }
1182+
}
1183+
1184+
it('limits memory-parsed bodies by what the resolver returns for the request', async () => {
1185+
const body = Buffer.from(JSON.stringify({ padding: 'x'.repeat(64) }))
1186+
1187+
const authed = await runWithContextualLimits({ context: { user: 'admin' }, body })
1188+
1189+
expect(authed.resolved).toEqual([{ padding: 'x'.repeat(64) }])
1190+
expect(authed.resolverCalls).toEqual([{ context: { user: 'admin' }, url: '/upload' }])
1191+
1192+
await expect(runWithContextualLimits({ context: {}, body })).rejects.toSatisfy((error) => {
1193+
expect(error).toBeInstanceOf(ORPCError)
1194+
expect((error as ORPCError<string, unknown>).code).toBe('PAYLOAD_TOO_LARGE')
1195+
return true
1196+
})
1197+
})
1198+
1199+
it('limits spooled files by what the resolver returns for the request', async () => {
1200+
const headers = { 'content-type': 'application/octet-stream', 'standard-server': 'file' }
1201+
const body = Buffer.alloc(64, 7)
1202+
1203+
await runWithContextualLimits({
1204+
context: { user: 'admin' },
1205+
headers,
1206+
body,
1207+
inspect: async ([resolvedBody]) => {
1208+
expect(resolvedBody).toBeInstanceOf(TmpFile)
1209+
expect(Buffer.from(await (resolvedBody as TmpFile).arrayBuffer()).equals(body)).toBe(true)
1210+
},
1211+
})
1212+
1213+
await expect(runWithContextualLimits({ context: {}, headers, body })).rejects.toSatisfy((error) => {
1214+
expect(error).toBeInstanceOf(ORPCError)
1215+
expect((error as ORPCError<string, unknown>).code).toBe('PAYLOAD_TOO_LARGE')
1216+
return true
1217+
})
1218+
1219+
expect(readdirSync(tmpDir)).toHaveLength(0)
1220+
})
1221+
1222+
it('skips the resolver when no body is parsed', async () => {
1223+
// The routed request never resolves its body
1224+
const untouched = await runWithContextualLimits({ context: { user: 'admin' }, resolutions: 0 })
1225+
1226+
expect(untouched.resolverCalls).toHaveLength(0)
1227+
1228+
// A request whose headers describe no body is left to the standard parser
1229+
const bodyless = await runWithContextualLimits({ context: { user: 'admin' }, headers: {} })
1230+
1231+
expect(bodyless.resolverCalls).toHaveLength(0)
1232+
expect(bodyless.resolved[0]).toBeInstanceOf(ReadableStream)
1233+
})
1234+
})
11281235
})

packages/node/src/tmp-file-upload-handler-plugin.ts

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Context } from '@orpc/server'
2-
import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard'
2+
import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard'
3+
import type { Promisable, Value } from '@orpc/shared'
34
import type { StandardBody, StandardBodyHint, StandardHeaders, StandardLazyRequest } from '@standardserver/core'
45
import type { FilePropertyBag } from 'node:buffer'
56
import { Buffer } from 'node:buffer'
@@ -8,7 +9,7 @@ import { appendFile, mkdtemp, rm } from 'node:fs/promises'
89
import { tmpdir } from 'node:os'
910
import path from 'node:path'
1011
import { ORPCError } from '@orpc/server'
11-
import { isAsyncIteratorObject, override, toArray, wrapAsyncIterator, wrapReadableStream } from '@orpc/shared'
12+
import { isAsyncIteratorObject, override, toArray, value, wrapAsyncIterator, wrapReadableStream } from '@orpc/shared'
1213
import { flattenStandardHeader, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standardserver/core'
1314
import { toFetchHeaders, toStandardBody } from '@standardserver/fetch'
1415
import { parseHeaderParameters, parseMultipart } from './multipart'
@@ -38,7 +39,7 @@ export interface TmpFileUploadHandlerPluginMaxBodySize {
3839
stream: number
3940
}
4041

41-
export interface TmpFileUploadHandlerPluginOptions {
42+
export interface TmpFileUploadHandlerPluginOptions<T extends Context> {
4243
/**
4344
* The directory temporary files are created under. Each request that spools
4445
* an upload gets its own subdirectory inside it, removed when the request
@@ -49,13 +50,20 @@ export interface TmpFileUploadHandlerPluginOptions {
4950
tmpDir?: string
5051

5152
/**
52-
* The size limit for each kind of request body. Every kind is required when
53-
* the option is given, so none is left unbounded by accident; set a kind to
54-
* `Number.POSITIVE_INFINITY` to deliberately leave it unlimited.
53+
* The size limit for each kind of request body, either fixed or resolved
54+
* per request, so limits can follow the request itself, such as a larger
55+
* allowance for an authenticated user than for a guest. Every kind is
56+
* required when the option is given, so none is left unbounded by accident;
57+
* set a kind to `Number.POSITIVE_INFINITY` to deliberately leave it
58+
* unlimited.
59+
*
60+
* A resolver runs only when a body is parsed, so a request without one never
61+
* pays for it, and it receives the request as it arrived, before this plugin
62+
* wraps its body resolution.
5563
*
5664
* @default unlimited for every kind
5765
*/
58-
maxBodySize?: TmpFileUploadHandlerPluginMaxBodySize
66+
maxBodySize?: Value<Promisable<TmpFileUploadHandlerPluginMaxBodySize>, [options: StandardHandlerRoutingInterceptorOptions<T>]>
5967
}
6068

6169
/**
@@ -96,8 +104,8 @@ export class TmpFile extends File {
96104
* to the standard parser.
97105
*
98106
* Request body sizes are limited per content category: memory-parsed, spooled to
99-
* disk, and streamed. This subsumes the request limit plugin while sizing each
100-
* kind of body to what it actually costs.
107+
* disk, and streamed, each limit fixed or resolved per request. This subsumes the
108+
* request limit plugin while sizing each kind of body to what it actually costs.
101109
*
102110
* @remarks
103111
* Temporary files are removed when the request finishes. A streaming response body,
@@ -123,9 +131,9 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
123131
before = ['~request-limit', '~request-compression']
124132

125133
private readonly tmpDir: string
126-
private readonly maxBodySize: TmpFileUploadHandlerPluginMaxBodySize
134+
private readonly maxBodySize: Exclude<TmpFileUploadHandlerPluginOptions<T>['maxBodySize'], undefined>
127135

128-
constructor(options: TmpFileUploadHandlerPluginOptions = {}) {
136+
constructor(options: TmpFileUploadHandlerPluginOptions<T> = {}) {
129137
this.tmpDir = options.tmpDir ?? tmpdir()
130138
this.maxBodySize = options.maxBodySize ?? {
131139
memory: Number.POSITIVE_INFINITY,
@@ -144,7 +152,7 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
144152
...interceptorOptions,
145153
request: {
146154
...interceptorOptions.request,
147-
resolveBody: hint => this.resolveBody(interceptorOptions.request, hint, tmpFiles),
155+
resolveBody: hint => this.resolveBody(interceptorOptions, hint, tmpFiles),
148156
},
149157
})
150158

@@ -202,33 +210,43 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
202210
}
203211
}
204212

205-
private async resolveBody(request: StandardLazyRequest, hint: StandardBodyHint | undefined, tmpFiles: RequestTmpFiles): Promise<StandardBody> {
213+
private async resolveBody(
214+
interceptorOptions: StandardHandlerRoutingInterceptorOptions<T>,
215+
hint: StandardBodyHint | undefined,
216+
tmpFiles: RequestTmpFiles,
217+
): Promise<StandardBody> {
218+
const { request } = interceptorOptions
219+
206220
// The same resolution order the standard body parsers apply
207221
const resolvedHint = hint ?? resolveStandardBodyHint(request.headers)
208222

223+
// A body-less request has nothing to limit or spool
224+
if (resolvedHint === 'none') {
225+
return request.resolveBody(hint)
226+
}
227+
228+
const maxBodySize = await value(this.maxBodySize, interceptorOptions)
229+
209230
/**
210231
* A form-data hint always means multipart here, even though it can also
211232
* arrive explicitly or through the standard-server header on a body that
212233
* is not. Such a body fails the multipart parse, unlike the standard
213234
* parser, which would fall back to urlencoded form data.
214235
*/
215236
if (resolvedHint === 'form-data') {
216-
return this.parseMultipartBody(request, tmpFiles)
237+
return this.parseMultipartBody(request, tmpFiles, maxBodySize)
217238
}
218239

219240
if (resolvedHint === 'file') {
220-
return this.spoolFileBody(request, tmpFiles)
241+
return this.spoolFileBody(request, tmpFiles, maxBodySize.file)
221242
}
222243

223244
if (resolvedHint === 'json' || resolvedHint === 'url-search-params') {
224-
return this.parseLimitedBody(request, hint, resolvedHint, this.maxBodySize.memory)
225-
}
226-
227-
if (resolvedHint === 'event-stream' || resolvedHint === 'octet-stream') {
228-
return this.parseLimitedBody(request, hint, resolvedHint, this.maxBodySize.stream)
245+
return this.parseLimitedBody(request, hint, resolvedHint, maxBodySize.memory)
229246
}
230247

231-
return request.resolveBody(hint)
248+
// Event streams and raw binary streams, the kinds consumed on the fly
249+
return this.parseLimitedBody(request, hint, resolvedHint, maxBodySize.stream)
232250
}
233251

234252
/**
@@ -261,8 +279,8 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
261279
return toStandardBody(response, { hint: resolvedHint })
262280
}
263281

264-
private async spoolFileBody(request: StandardLazyRequest, tmpFiles: RequestTmpFiles): Promise<StandardBody> {
265-
assertContentLengthWithin(request.headers, this.maxBodySize.file)
282+
private async spoolFileBody(request: StandardLazyRequest, tmpFiles: RequestTmpFiles, fileLimit: number): Promise<StandardBody> {
283+
assertContentLengthWithin(request.headers, fileLimit)
266284

267285
const stream = await request.resolveBody('octet-stream')
268286

@@ -275,7 +293,7 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
275293
const fileName = contentDisposition !== undefined ? getFilenameFromContentDisposition(contentDisposition) : undefined
276294
const contentType = flattenStandardHeader(request.headers['content-type'])
277295

278-
const limited = this.maxBodySize.file === Number.POSITIVE_INFINITY ? stream : limitStream(stream, this.maxBodySize.file)
296+
const limited = fileLimit === Number.POSITIVE_INFINITY ? stream : limitStream(stream, fileLimit)
279297

280298
const tmpPath = await tmpFiles.allocate()
281299

@@ -286,7 +304,7 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
286304
return tmpFiles.seal(tmpPath, fileName ?? 'blob', contentType ?? '')
287305
}
288306

289-
private async parseMultipartBody(request: StandardLazyRequest, tmpFiles: RequestTmpFiles): Promise<StandardBody> {
307+
private async parseMultipartBody(request: StandardLazyRequest, tmpFiles: RequestTmpFiles, maxBodySize: TmpFileUploadHandlerPluginMaxBodySize): Promise<StandardBody> {
290308
const contentType = flattenStandardHeader(request.headers['content-type'])
291309
const boundary = contentType === undefined ? undefined : parseHeaderParameters(contentType).get('boundary')
292310

@@ -299,7 +317,7 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
299317
* content categories allow together, which also bounds bodies that hide
300318
* their size in part headers rather than part content.
301319
*/
302-
const totalLimit = this.maxBodySize.memory + this.maxBodySize.file
320+
const totalLimit = maxBodySize.memory + maxBodySize.file
303321

304322
assertContentLengthWithin(request.headers, totalLimit)
305323

@@ -326,7 +344,7 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
326344
write: (chunk) => {
327345
memoryUsed += chunk.length
328346

329-
if (memoryUsed > this.maxBodySize.memory) {
347+
if (memoryUsed > maxBodySize.memory) {
330348
throw new ORPCError('PAYLOAD_TOO_LARGE')
331349
}
332350

@@ -348,7 +366,7 @@ export class TmpFileUploadHandlerPlugin<T extends Context> implements StandardHa
348366
write: async (chunk) => {
349367
fileUsed += chunk.length
350368

351-
if (fileUsed > this.maxBodySize.file) {
369+
if (fileUsed > maxBodySize.file) {
352370
throw new ORPCError('PAYLOAD_TOO_LARGE')
353371
}
354372

0 commit comments

Comments
 (0)