Skip to content

Commit b540da5

Browse files
authored
feat(storage-*): large file uploads on Vercel (#11382)
Currently, usage of Payload on Vercel has a limitation - uploads are limited by 4.5MB file size. This PR allows you to pass `clientUploads: true` to all existing storage adapters * Storage S3 * Vercel Blob * Google Cloud Storage * Uploadthing * Azure Blob And then, Payload will do uploads on the client instead. With the S3 Adapter it uses signed URLs and with Vercel Blob it does this - https://vercel.com/guides/how-to-bypass-vercel-body-size-limit-serverless-functions#step-2:-create-a-client-upload-route. Note that it doesn't mean that anyone can now upload files to your storage, it still does auth checks and you can customize that with `clientUploads.access` https://github.com/user-attachments/assets/5083c76c-8f5a-43dc-a88c-9ddc4527d91c Implements #7569 feature request.
1 parent c6ab312 commit b540da5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1549
-153
lines changed

docs/upload/storage-adapters.mdx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pnpm add @payloadcms/storage-vercel-blob
3030
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
3131
- Ensure you have `BLOB_READ_WRITE_TOKEN` set in your Vercel environment variables. This is usually set by Vercel automatically after adding blob storage to your project.
3232
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
33+
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client.
3334

3435
```ts
3536
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
@@ -64,6 +65,7 @@ export default buildConfig({
6465
| `addRandomSuffix` | Add a random suffix to the uploaded file name in Vercel Blob storage | `false` |
6566
| `cacheControlMaxAge` | Cache-Control max-age in seconds | `365 * 24 * 60 * 60` (1 Year) |
6667
| `token` | Vercel Blob storage read/write token | `''` |
68+
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
6769

6870
## S3 Storage
6971
[`@payloadcms/storage-s3`](https://www.npmjs.com/package/@payloadcms/storage-s3)
@@ -79,6 +81,7 @@ pnpm add @payloadcms/storage-s3
7981
- Configure the `collections` object to specify which collections should use the S3 Storage adapter. The slug _must_ match one of your existing collection slugs.
8082
- The `config` object can be any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object (from [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3)). _This is highly dependent on your AWS setup_. Check the AWS documentation for more information.
8183
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
84+
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method for the bucket to your website.
8285

8386
```ts
8487
import { s3Storage } from '@payloadcms/storage-s3'
@@ -126,6 +129,7 @@ pnpm add @payloadcms/storage-azure
126129

127130
- Configure the `collections` object to specify which collections should use the Azure Blob adapter. The slug _must_ match one of your existing collection slugs.
128131
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
132+
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method to your website.
129133

130134
```ts
131135
import { azureStorage } from '@payloadcms/storage-azure'
@@ -161,6 +165,7 @@ export default buildConfig({
161165
| `baseURL` | Base URL for the Azure Blob storage account | |
162166
| `connectionString` | Azure Blob storage connection string | |
163167
| `containerName` | Azure Blob storage container name | |
168+
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
164169

165170
## Google Cloud Storage
166171
[`@payloadcms/storage-gcs`](https://www.npmjs.com/package/@payloadcms/storage-gcs)
@@ -175,6 +180,7 @@ pnpm add @payloadcms/storage-gcs
175180

176181
- Configure the `collections` object to specify which collections should use the Google Cloud Storage adapter. The slug _must_ match one of your existing collection slugs.
177182
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
183+
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method for the bucket to your website.
178184

179185
```ts
180186
import { gcsStorage } from '@payloadcms/storage-gcs'
@@ -203,13 +209,14 @@ export default buildConfig({
203209

204210
### Configuration Options#gcs-configuration
205211

206-
| Option | Description | Default |
207-
| ------------- | --------------------------------------------------------------------------------------------------- | --------- |
208-
| `enabled` | Whether or not to enable the plugin | `true` |
209-
| `collections` | Collections to apply the storage to | |
210-
| `bucket` | The name of the bucket to use | |
211-
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
212-
| `acl` | Access control list for files that are uploaded | `Private` |
212+
| Option | Description | Default |
213+
| --------------- | --------------------------------------------------------------------------------------------------- | --------- |
214+
| `enabled` | Whether or not to enable the plugin | `true` |
215+
| `collections` | Collections to apply the storage to | |
216+
| `bucket` | The name of the bucket to use | |
217+
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
218+
| `acl` | Access control list for files that are uploaded | `Private` |
219+
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
213220

214221

215222
## Uploadthing Storage
@@ -226,6 +233,7 @@ pnpm add @payloadcms/storage-uploadthing
226233
- Configure the `collections` object to specify which collections should use uploadthing. The slug _must_ match one of your existing collection slugs and be an `upload` type.
227234
- Get a token from Uploadthing and set it as `token` in the `options` object.
228235
- `acl` is optional and defaults to `public-read`.
236+
- When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client.
229237

230238
```ts
231239
export default buildConfig({
@@ -246,13 +254,14 @@ export default buildConfig({
246254

247255
### Configuration Options#uploadthing-configuration
248256

249-
| Option | Description | Default |
250-
| ---------------- | ----------------------------------------------- | ------------- |
251-
| `token` | Token from Uploadthing. Required. | |
252-
| `acl` | Access control list for files that are uploaded | `public-read` |
253-
| `logLevel` | Log level for Uploadthing | `info` |
254-
| `fetch` | Custom fetch function | `fetch` |
255-
| `defaultKeyType` | Default key type for file operations | `fileKey` |
257+
| Option | Description | Default |
258+
| ---------------- | ------------------------------------------------------------- | ------------- |
259+
| `token` | Token from Uploadthing. Required. | |
260+
| `acl` | Access control list for files that are uploaded | `public-read` |
261+
| `logLevel` | Log level for Uploadthing | `info` |
262+
| `fetch` | Custom fetch function | `fetch` |
263+
| `defaultKeyType` | Default key type for file operations | `fileKey` |
264+
| `clientUploads` | Do uploads directly on the client to bypass limits on Vercel. | |
256265

257266

258267
## Custom Storage Adapters

packages/payload/src/uploads/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export type UploadConfig = {
178178
req: PayloadRequest,
179179
args: {
180180
doc: TypeWithID
181-
params: { collection: string; filename: string }
181+
params: { clientUploadContext?: unknown; collection: string; filename: string }
182182
},
183183
) => Promise<Response> | Promise<void> | Response | void)[]
184184
/**

packages/payload/src/utilities/addDataAndFileToRequest.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,54 @@ export const addDataAndFileToRequest: AddDataAndFileToRequest = async (req) => {
4444
if (fields?._payload && typeof fields._payload === 'string') {
4545
req.data = JSON.parse(fields._payload)
4646
}
47+
48+
if (!req.file && fields?.file && typeof fields?.file === 'string') {
49+
const { clientUploadContext, collectionSlug, filename, mimeType, size } = JSON.parse(
50+
fields.file,
51+
)
52+
const uploadConfig = req.payload.collections[collectionSlug].config.upload
53+
54+
if (!uploadConfig.handlers) {
55+
throw new APIError('uploadConfig.handlers is not present for ' + collectionSlug)
56+
}
57+
58+
let response: null | Response = null
59+
let error: unknown
60+
61+
for (const handler of uploadConfig.handlers) {
62+
try {
63+
const result = await handler(req, {
64+
doc: null,
65+
params: {
66+
clientUploadContext, // Pass additional specific to adapters context returned from UploadHandler, then staticHandler can use them.
67+
collection: collectionSlug,
68+
filename,
69+
},
70+
})
71+
if (result) {
72+
response = result
73+
}
74+
// If we couldn't get the file from that handler, save the error and try other.
75+
} catch (err) {
76+
error = err
77+
}
78+
}
79+
80+
if (!response) {
81+
if (error) {
82+
payload.logger.error(error)
83+
}
84+
85+
throw new APIError('Expected response from the upload handler.')
86+
}
87+
88+
req.file = {
89+
name: filename,
90+
data: Buffer.from(await response.arrayBuffer()),
91+
mimetype: response.headers.get('Content-Type') || mimeType,
92+
size,
93+
}
94+
}
4795
}
4896
}
4997
}

packages/plugin-cloud-storage/.swcrc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
"syntax": "typescript",
88
"tsx": true,
99
"dts": true
10+
},
11+
"transform": {
12+
"react": {
13+
"runtime": "automatic",
14+
"pragmaFrag": "React.Fragment",
15+
"throwIfNamespace": true,
16+
"development": false,
17+
"useBuiltins": true
18+
}
1019
}
1120
},
1221
"module": {

packages/plugin-cloud-storage/package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
"import": "./src/exports/utilities.ts",
3434
"types": "./src/exports/utilities.ts",
3535
"default": "./src/exports/utilities.ts"
36+
},
37+
"./client": {
38+
"import": "./src/exports/client.ts",
39+
"types": "./src/exports/client.ts",
40+
"default": "./src/exports/client.ts"
3641
}
3742
},
3843
"main": "./src/index.ts",
@@ -53,15 +58,20 @@
5358
"test": "echo \"No tests available.\""
5459
},
5560
"dependencies": {
61+
"@payloadcms/ui": "workspace:*",
5662
"find-node-modules": "^2.1.3",
5763
"range-parser": "^1.2.1"
5864
},
5965
"devDependencies": {
6066
"@types/find-node-modules": "^2.1.2",
67+
"@types/react": "19.0.1",
68+
"@types/react-dom": "19.0.1",
6169
"payload": "workspace:*"
6270
},
6371
"peerDependencies": {
64-
"payload": "workspace:*"
72+
"payload": "workspace:*",
73+
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
74+
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
6575
},
6676
"publishConfig": {
6777
"exports": {
@@ -79,6 +89,11 @@
7989
"import": "./dist/exports/utilities.js",
8090
"types": "./dist/exports/utilities.d.ts",
8191
"default": "./dist/exports/utilities.js"
92+
},
93+
"./client": {
94+
"import": "./dist/exports/client.js",
95+
"types": "./dist/exports/client.d.ts",
96+
"default": "./dist/exports/client.js"
8297
}
8398
},
8499
"main": "./dist/index.js",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client'
2+
3+
import type { UploadCollectionSlug } from 'payload'
4+
5+
import { useConfig, useEffectEvent, useUploadHandlers } from '@payloadcms/ui'
6+
import { Fragment, type ReactNode, useEffect } from 'react'
7+
8+
type ClientUploadHandlerProps<T extends Record<string, unknown>> = {
9+
children: ReactNode
10+
collectionSlug: UploadCollectionSlug
11+
enabled?: boolean
12+
extra: T
13+
serverHandlerPath: string
14+
}
15+
16+
export const createClientUploadHandler = <T extends Record<string, unknown>>({
17+
handler,
18+
}: {
19+
handler: (args: {
20+
apiRoute: string
21+
collectionSlug: UploadCollectionSlug
22+
extra: T
23+
file: File
24+
serverHandlerPath: string
25+
serverURL: string
26+
updateFilename: (value: string) => void
27+
}) => Promise<unknown>
28+
}) => {
29+
return function ClientUploadHandler({
30+
children,
31+
collectionSlug,
32+
enabled,
33+
extra,
34+
serverHandlerPath,
35+
}: ClientUploadHandlerProps<T>) {
36+
const { setUploadHandler } = useUploadHandlers()
37+
const {
38+
config: {
39+
routes: { api: apiRoute },
40+
serverURL,
41+
},
42+
} = useConfig()
43+
44+
const initializeHandler = useEffectEvent(() => {
45+
if (enabled) {
46+
setUploadHandler({
47+
collectionSlug,
48+
handler: ({ file, updateFilename }) => {
49+
return handler({
50+
apiRoute,
51+
collectionSlug,
52+
extra,
53+
file,
54+
serverHandlerPath,
55+
serverURL,
56+
updateFilename,
57+
})
58+
},
59+
})
60+
}
61+
})
62+
63+
useEffect(() => {
64+
initializeHandler()
65+
}, [])
66+
67+
return <Fragment>{children}</Fragment>
68+
}
69+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { createClientUploadHandler } from '../client/createClientUploadHandler.js'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { getFilePrefix } from '../utilities/getFilePrefix.js'
2+
export { initClientUploads } from '../utilities/initClientUploads.js'

packages/plugin-cloud-storage/src/types.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ export interface File {
1616
tempFilePath?: string
1717
}
1818

19+
export type ClientUploadsAccess = (args: {
20+
collectionSlug: UploadCollectionSlug
21+
req: PayloadRequest
22+
}) => boolean | Promise<boolean>
23+
24+
export type ClientUploadsConfig =
25+
| {
26+
access?: ClientUploadsAccess
27+
}
28+
| boolean
29+
1930
export type HandleUpload = (args: {
2031
collection: CollectionConfig
2132
data: any
@@ -43,7 +54,10 @@ export type GenerateURL = (args: {
4354

4455
export type StaticHandler = (
4556
req: PayloadRequest,
46-
args: { doc?: TypeWithID; params: { collection: string; filename: string } },
57+
args: {
58+
doc?: TypeWithID
59+
params: { clientUploadContext?: unknown; collection: string; filename: string }
60+
},
4761
) => Promise<Response> | Response
4862

4963
export interface GeneratedAdapter {

0 commit comments

Comments
 (0)