Skip to content
You are reading the v2 docs, currently in beta.V1 docs
oRPC
Esc
↑↓navigate↵open⌘Jpreview
On this page

Tmp File Upload Plugin

Stream large file uploads into temporary files instead of memory, so requests far larger than available memory are parsed safely.

Installation

npm install @orpc/node@beta
pnpm add @orpc/node@beta
yarn add @orpc/node@beta
bun add @orpc/node@beta
nub add @orpc/node@beta
aube add @orpc/node@beta

Setup

Use TmpFileUploadHandlerPlugin to parse file uploads into temporary files. Bodies the standard parser would buffer into an in-memory File, and multipart/form-data file parts, stream to disk instead once they outgrow the spool threshold. Every other body is left to the standard parser.

import { TmpFileUploadHandlerPlugin } from '@orpc/node'
import { RPCHandler } from '@orpc/server/node'

const handler = new RPCHandler(router, {
  plugins: [
    new TmpFileUploadHandlerPlugin({
      /**
       * The directory temporary files are created under. Each request that
       * spools an upload gets its own subdirectory inside it, removed when
       * the request finishes.
       *
       * @default os.tmpdir()
       */
      tmpDir: './uploads',
    }),
  ],
})

Working with Uploaded Files

Procedures receive ordinary File instances. An upload spooled to disk is a TmpFile, read lazily in constant memory and exposing the path of its backing file, so it can be kept with a cheap rename instead of a copy. A smaller upload stays in memory as a plain File without a path, so check which one you received:

import { TmpFile } from '@orpc/node'
import { rename, writeFile } from 'node:fs/promises'

const uploadVideo = os
  .input(z.object({ video: z.file() }))
  .handler(async ({ input }) => {
    const destination = `./videos/${crypto.randomUUID()}`

    if (input.video instanceof TmpFile) {
      await rename(input.video.path, destination)
    }
    else {
      await writeFile(destination, input.video.stream())
    }
  })

Limiting Body Sizes

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.

const handler = new RPCHandler(router, {
  plugins: [
    new TmpFileUploadHandlerPlugin({
      maxBodySize: {
        /**
         * Content buffered into memory: JSON, URL-encoded forms,
         * multipart fields, part headers and in-memory files, and each
         * event of an event stream. Usually the lowest limit.
         */
        memory: 1024 * 1024, // 1MB

        /**
         * Uploaded files: file bodies and the file parts of a multipart
         * body combined, whether held in memory or spooled to disk.
         */
        file: 500 * 1024 * 1024, // 500MB

        /**
         * Content consumed as a stream: event streams and raw binary
         * streams, enforced while the stream is consumed. Usually the
         * highest limit, but keep it finite so no client can stream
         * indefinitely.
         */
        stream: 500 * 1024 * 1024, // 500MB
      },
    }),
  ],
})

A multipart body splits across the first two limits, fields and part headers against memory and file parts against file, those held in memory against memory as well, and as a whole, framing included, it is bounded by the sum of both. An event stream counts against stream in total and each of its events against memory, since the decoder buffers an event until its blank line arrives. A raw binary stream reaches the procedure chunk by chunk, so only stream applies. A declared Content-Length over the applicable limit rejects immediately, and enforcement continues while the body streams in, so a lying length cannot bypass it.

With all three limits configured, the plugin subsumes the Request Limit Plugin. When the Request Compression Plugin is present, limits apply to the decompressed payload rather than the compressed wire size.

Keeping Small Uploads in Memory

Uploads up to spoolThreshold stay in memory as plain Files, so small and empty files never cost a temporary file.

const handler = new RPCHandler(router, {
  plugins: [
    new TmpFileUploadHandlerPlugin({
      /**
       * The size in bytes an uploaded file must exceed before it is
       * spooled to a temporary file. Set to `0` to spool every
       * non-empty upload.
       *
       * @default 64 * 1024
       */
      spoolThreshold: 256 * 1024, // 256KB
    }),
  ],
})

In-memory uploads count against maxBodySize.memory, shared with the body’s fields, and spool to disk once they no longer fit, so size memory for both.

Per-Request Limits

maxBodySize also accepts a function, sync or async, receiving the handler context, request, and prefix, so limits can follow who is uploading:

const handler = new RPCHandler(router, {
  plugins: [
    new TmpFileUploadHandlerPlugin({
      maxBodySize: async ({ context }) => ({
        memory: 1024 * 1024, // 1MB
        file: context.user === undefined
          ? 5 * 1024 * 1024 // 5MB for guests
          : 500 * 1024 * 1024, // 500MB for authenticated users
        stream: 500 * 1024 * 1024, // 500MB
      }),
    }),
  ],
})

Learn More

For implementation details, see the source code.

Last updated on

Was this page helpful?