Files
documenso/trpc-upload.md
T
David Nguyen e2b3597c36 fix: wip
2026-02-19 11:36:36 +11:00

9.4 KiB

tRPC File Upload Flow (Documenso)

This document explains how Documenso uploads files via tRPC using multipart/form-data, from client to server validation and persistence.

1) Client: send FormData to a tRPC mutation

In apps/remix/app/components/general/envelope/envelope-upload-button.tsx, the UI builds a FormData payload and calls the mutation directly:

  • payload is appended as a JSON string.
  • each file is appended with the same key (files) so it becomes a repeatable field.

Pattern:

const formData = new FormData();
formData.append('payload', JSON.stringify(payload));

for (const file of files) {
  formData.append('files', file);
}

await createEnvelope(formData);

Important details:

  • The mutation call is trpc.envelope.create.useMutation() and accepts FormData for this route.
  • The client also does pre-check UX (limits, max files, size messaging), but server still enforces authoritative validation.

2) Route contract: multipart + zod-form-data

In packages/trpc/server/envelope-router/create-envelope.types.ts:

  • OpenAPI metadata explicitly marks the route as multipart:
    • contentTypes: ['multipart/form-data']
  • request schema uses a custom zodFormData(...) wrapper.
  • payload is parsed from JSON with zfd.json(...).
  • files is parsed as repeated files with zfd.repeatableOfType(zfd.file()).

Pattern:

export const ZCreateEnvelopeRequestSchema = zodFormData({
  payload: zfd.json(ZCreateEnvelopePayloadSchema),
  files: zfd.repeatableOfType(zfd.file()),
});

This gives a strongly typed input:

  • input.payload is a validated object.
  • input.files is a validated File[].

3) Why zodFormData exists

In packages/trpc/utils/zod-form-data.ts, zodFormData is a thin preprocess helper:

  • if input is FormData, it converts it into a plain object.
  • duplicate keys become arrays (getAll(key) behavior).
  • then runs z.object(schema) validation.

Reason in code comments:

  • it replaces zfd.formData() due to pipeline/openapi edge cases where undefined can surface and break parsing.

So this helper is a compatibility layer that still behaves like normal form-data parsing for Zod.

Full file:

import type { ZodRawShape } from 'zod';
import z from 'zod';

/**
 * This helper takes the place of the `z.object` at the root of your schema.
 * It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
 * and transforms it into a regular object.
 * If the `FormData` contains multiple entries with the same field name,
 * it will automatically turn that field into an array.
 *
 * This is used instead of `zfd.formData()` because it receives `undefined`
 * somewhere in the pipeline of our openapi schema generation and throws
 * an error. This provides the same functionality as `zfd.formData()` but
 * can be considered somewhat safer.
 */
export const zodFormData = <T extends ZodRawShape>(schema: T) => {
  return z.preprocess((data) => {
    if (data instanceof FormData) {
      const formData: Record<string, unknown> = {};

      for (const key of data.keys()) {
        const values = data.getAll(key);

        formData[key] = values.length > 1 ? values : values[0];
      }

      return formData;
    }

    return data;
  }, z.object(schema));
};

4) Server mutation: validate and process each uploaded file

In packages/trpc/server/envelope-router/create-envelope.ts:

  1. input is already schema-validated (.input(ZCreateEnvelopeRequestSchema)).
  2. server enforces limits and file rules:
    • monthly doc limit
    • max envelope item count
    • MIME must start with application/pdf
  3. each uploaded file is processed:
    • convert to buffer via await file.arrayBuffer()
    • optionally inject form values into PDF
    • normalize PDF
    • extract placeholders
    • upload file bytes server-side (putPdfFileServerSide)
  4. resulting uploaded file IDs (documentDataId) are attached to envelope items.
  5. envelope is created with those items + recipient mapping logic.

Key loop:

const envelopeItems = await Promise.all(
  files.map(async (file) => {
    let pdf = Buffer.from(await file.arrayBuffer());
    // ... optional transform + normalize + placeholder extraction
    const { id: documentDataId } = await putPdfFileServerSide({
      name: file.name,
      type: 'application/pdf',
      arrayBuffer: async () => Promise.resolve(cleanedPdf),
    });
    return { title: file.name, documentDataId, placeholders };
  }),
);

5) Multipart parsing infrastructure (critical)

The multipart body support is implemented in packages/trpc/utils/openapi-fetch-handler.ts.

For multipart requests, it:

  • reads req.formData(),
  • converts entries into a plain object (accumulating repeated keys into arrays),
  • supports key[] sent by some SDKs by normalizing to key,
  • rewrites request content-type to application/json for the OpenAPI node handler interop,
  • and passes parsed body downstream.

This is why multipart routes can be validated by normal Zod/tRPC schemas in this codebase.

Key multipart handling code:

const CONTENT_TYPE_JSON = 'application/json';
const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
const CONTENT_TYPE_MULTIPART = 'multipart/form-data';

const getMultipartBody = async (req: Request) => {
  const formData = await req.formData();

  const data: Record<string, unknown> = {};

  for (const [key, value] of formData.entries()) {
    // !: Handles cases where our generated SDKs send key[] syntax for arrays.
    const normalizedKey = key.endsWith('[]') ? key.slice(0, -2) : key;

    if (data[normalizedKey] === undefined) {
      data[normalizedKey] = value;
    } else if (Array.isArray(data[normalizedKey])) {
      data[normalizedKey].push(value);
    } else {
      data[normalizedKey] = [data[normalizedKey], value];
    }
  }

  return data;
};

const getRequestBody = async (req: Request) => {
  try {
    const contentType = req.headers.get('content-type') || '';

    if (contentType.includes(CONTENT_TYPE_JSON)) {
      return {
        isValid: true,
        // Use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON
        data: JSON.parse(await req.text()),
      };
    }

    if (contentType.includes(CONTENT_TYPE_URLENCODED)) {
      return {
        isValid: true,
        data: await getUrlEncodedBody(req),
      };
    }

    // Handle multipart/form-data by parsing as FormData and converting to a plain object.
    // This mirrors how URL-encoded data is structured, allowing tRPC to validate it normally.
    // The content-type header is rewritten to application/json later via the request proxy
    // because createOpenApiNodeHttpHandler aborts on any bodied request that isn't application/json.
    if (contentType.includes(CONTENT_TYPE_MULTIPART)) {
      return {
        isValid: true,
        data: await getMultipartBody(req),
      };
    }

    return {
      isValid: true,
      data: req.body,
    };
  } catch (err) {
    return {
      isValid: false,
      cause: err,
    };
  }
};

Header rewrite and proxy behavior:

const createRequestProxy = async (req: Request, url?: string) => {
  const body = await getRequestBody(req);

  const originalContentType = req.headers.get('content-type') || '';
  const isMultipart = originalContentType.includes(CONTENT_TYPE_MULTIPART);

  return new Proxy(req, {
    get: (target, prop) => {
      switch (prop) {
        case 'url':
          return url ?? target.url;

        case 'body': {
          if (!body.isValid) {
            throw new TRPCError({
              code: 'PARSE_ERROR',
              message: 'Failed to parse request body',
              cause: body.cause,
            });
          }

          return body.data;
        }

        case 'headers': {
          const headers = new Headers(target.headers);

          // Rewrite content-type header for multipart requests to application/json.
          // This is necessary because `createOpenApiNodeHttpHandler` aborts on any bodied
          // request that isn't application/json. Since we've already parsed the multipart
          // data into a plain object above, this is safe to do.
          if (isMultipart) {
            headers.set('content-type', CONTENT_TYPE_JSON);
          }

          return headers;
        }

        default:
          return (target as unknown as Record<string | number | symbol, unknown>)[prop];
      }
    },
  });
};

6) Porting checklist for another project

Use this exact checklist:

  1. Client mutation
    • Build FormData.
    • Append structured data as JSON string (for example payload).
    • Append each file under a repeatable field key (for example files).
  2. Route meta
    • Mark route with contentTypes: ['multipart/form-data'].
  3. Schema
    • Parse JSON field with zfd.json(...).
    • Parse repeated files with zfd.repeatableOfType(zfd.file()).
    • Wrap root with a form-data preprocessor (zodFormData pattern).
  4. Request adapter
    • Ensure your server adapter can parse multipart into plain object + file values before schema validation.
  5. Server safety checks
    • Enforce limits/count/type server-side, not just UI.
  6. File processing
    • Read each file (arrayBuffer), transform as needed, upload, persist returned storage IDs.

7) Common pitfalls

  • Relying only on client-side file restrictions.
  • Forgetting repeatable parsing for multiple files.
  • Missing multipart handling in the HTTP/OpenAPI adapter layer.
  • Using mismatched field names between client FormData and Zod schema keys.