From 0d22b43c9836477876f302695d623119353b0cf8 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 23 Feb 2026 15:51:59 +1100 Subject: [PATCH] chore: remove random file --- trpc-upload.md | 300 ------------------------------------------------- 1 file changed, 300 deletions(-) delete mode 100644 trpc-upload.md diff --git a/trpc-upload.md b/trpc-upload.md deleted file mode 100644 index 22d3fc725..000000000 --- a/trpc-upload.md +++ /dev/null @@ -1,300 +0,0 @@ -# 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: - -```ts -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: - -```ts -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: - -```ts -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 = (schema: T) => { - return z.preprocess((data) => { - if (data instanceof FormData) { - const formData: Record = {}; - - 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: - -```ts -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: - -```ts -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 = {}; - - 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: - -```ts -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)[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.