mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 02:15:05 +10:00
fix: wip
This commit is contained in:
+300
@@ -0,0 +1,300 @@
|
||||
# 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 = <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:
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<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.
|
||||
Reference in New Issue
Block a user