Skip to content

Commit 5c38902

Browse files
rjgtavRicardo Tavarespaulpopus
authored
feat(storage-r2): client uploads using R2 multipart api (#14733)
### What? Implements client-side uploads by splitting files into multiple parts and uploading each one separately. ### Why? To support uploading large files, without reaching the Worker's memory limit. ### How? By splitting the file into multiple small parts and uploading each one separately. This leverages R2's support for multipart uploads: https://developers.cloudflare.com/r2/objects/multipart-objects/ This feature was requested on Discord: https://discord.com/channels/967097582721572934/1428654256562503740/1428654256562503740 --------- Co-authored-by: Ricardo Tavares <rtavares@cloudflare.com> Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent 5875cd0 commit 5c38902

11 files changed

Lines changed: 422 additions & 15 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use client'
2+
3+
import{createClientUploadHandler}from'@payloadcms/plugin-cloud-storage/client'
4+
import{formatAdminURL}from'payload/shared'
5+
6+
importtype{
7+
R2MultipartUpload,
8+
R2StorageClientUploadContext,
9+
R2StorageClientUploadHandlerParams,
10+
R2StorageMultipartUploadHandlerParams,
11+
R2UploadedPart,
12+
}from'../types.js'
13+
14+
exportconstR2ClientUploadHandler=createClientUploadHandler<R2StorageClientUploadHandlerParams>({
15+
handler: async({
16+
apiRoute,
17+
collectionSlug,
18+
extra: { chunkSize =5*1024*1024, prefix =''},
19+
file,
20+
serverHandlerPath,
21+
serverURL,
22+
}): Promise<R2StorageClientUploadContext|undefined>=>{
23+
constparams: R2StorageMultipartUploadHandlerParams={
24+
collection: collectionSlug,
25+
fileName: file.name,
26+
fileType: file.type,
27+
}
28+
constbaseURL=formatAdminURL({
29+
apiRoute,
30+
path: serverHandlerPath,
31+
serverURL,
32+
})
33+
34+
constendpoint=`${baseURL}?${String(newURLSearchParams(params))}`
35+
36+
constmultipart=awaitfetch(endpoint,{method: 'POST'})
37+
if(!multipart.ok){
38+
thrownewError('Failed to initialize multipart upload')
39+
}
40+
41+
constmultipartUpload=(awaitmultipart.json())asPick<R2MultipartUpload,'key'|'uploadId'>
42+
constmultipartUploadedParts: R2UploadedPart[]=[]
43+
44+
params.multipartId=multipartUpload.uploadId
45+
params.multipartKey=multipartUpload.key
46+
47+
constpartTotal=Math.ceil(file.size/chunkSize)
48+
49+
for(letpart=1;part<=partTotal;part++){
50+
constbytesEnd=Math.min(part*chunkSize,file.size)
51+
constbytesStart=(part-1)*chunkSize
52+
53+
params.multipartNumber=String(part)
54+
55+
constbody=file.slice(bytesStart,bytesEnd)
56+
constheaders={
57+
'Content-Length': String(body.size),
58+
'Content-Type': 'application/octet-stream',
59+
}
60+
constuploaded=awaitfetch(endpoint,{ body, headers,method: 'POST'})
61+
if(!uploaded.ok){
62+
thrownewError(`Failed to upload part ${part} / ${partTotal}`)
63+
}
64+
65+
multipartUploadedParts.push((awaituploaded.json())asR2UploadedPart)
66+
67+
if(part===partTotal){
68+
deleteparams.multipartNumber
69+
70+
constbody=JSON.stringify(multipartUploadedParts)
71+
constheaders={'Content-Type': 'application/json'}
72+
constcomplete=awaitfetch(endpoint,{ body, headers,method: 'POST'})
73+
if(!complete.ok){
74+
thrownewError(`Failed to complete multipart upload`)
75+
}
76+
77+
constkey=awaitcomplete.text()
78+
return{ key }
79+
}
80+
}
81+
},
82+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export{R2ClientUploadHandler}from'../client/R2ClientUploadHandler.js'
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
importtype{ClientUploadsAccess}from'@payloadcms/plugin-cloud-storage/types'
2+
importtype{PayloadHandler}from'payload'
3+
4+
importpathfrom'path'
5+
import{APIError,Forbidden}from'payload'
6+
7+
importtype{R2StorageOptions}from'./index.js'
8+
importtype{R2Bucket,R2StorageMultipartUploadHandlerParams}from'./types.js'
9+
10+
typeArgs={
11+
access?: ClientUploadsAccess
12+
bucket: R2Bucket
13+
collections: R2StorageOptions['collections']
14+
}
15+
16+
// Adapted from https://developers.cloudflare.com/r2/api/workers/workers-multipart-usage/
17+
exportconstgetHandleMultiPartUpload=
18+
({ access, bucket, collections }: Args): PayloadHandler=>
19+
async(req)=>{
20+
constparams=Object.fromEntries(req.searchParams)asR2StorageMultipartUploadHandlerParams
21+
constcollectionSlug=params.collection
22+
constfiletype=params.fileType
23+
24+
constcollectionConfig=collections[collectionSlug]
25+
if(!collectionConfig){
26+
thrownewAPIError(`Collection ${collectionSlug} was not found in R2 Storage options`)
27+
}
28+
29+
// Check custom access if provided, otherwise check collection's create access
30+
if(access){
31+
if(!(awaitaccess({ collectionSlug, req }))){
32+
thrownewForbidden(req.t)
33+
}
34+
}else{
35+
// Use the collection's create access control
36+
constcollection=req.payload.collections[collectionSlug]
37+
if(!collection){
38+
thrownewAPIError(`Collection ${collectionSlug} not found`)
39+
}
40+
41+
constcreateAccess=collection.config.access?.create
42+
if(createAccess){
43+
consthasAccess=awaitcreateAccess({ req })
44+
if(!hasAccess){
45+
thrownewForbidden(req.t)
46+
}
47+
}elseif(!req.user){
48+
// No custom access and no user - deny by default
49+
thrownewForbidden(req.t)
50+
}
51+
}
52+
53+
constprefix=(typeofcollectionConfig==='object'&&collectionConfig.prefix)||''
54+
constfileKey=path.posix.join(prefix,params.fileName)
55+
56+
constmultipartId=params.multipartId
57+
constmultipartKey=params.multipartKey
58+
constmultipartNumber=parseInt(params.multipartNumber||'')
59+
60+
if(multipartId&&multipartKey){
61+
constmultipartUpload=bucket.resumeMultipartUpload(multipartKey,multipartId)
62+
constrequest=reqasRequest
63+
64+
if(isNaN(multipartNumber)){
65+
// Upload complete
66+
constobject=awaitmultipartUpload.complete((awaitrequest.json())asany)
67+
returnnewResponse(object.key,{status: 200})
68+
}else{
69+
// Upload part
70+
constuploadedPart=awaitmultipartUpload.uploadPart(
71+
multipartNumber,
72+
awaitrequest.arrayBuffer(),
73+
)
74+
returnResponse.json(uploadedPart)
75+
}
76+
}else{
77+
// Create multipart upload
78+
constmultipartUpload=awaitbucket.createMultipartUpload(fileKey,{
79+
httpMetadata: {
80+
contentType: filetype,
81+
},
82+
})
83+
84+
returnResponse.json({
85+
key: multipartUpload.key,
86+
uploadId: multipartUpload.uploadId,
87+
})
88+
}
89+
}

‎packages/storage-r2/src/handleUpload.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,5 @@ export const getHandleUpload = ({ bucket, prefix = '' }: Args): HandleUpload =>
1818
awaitbucket.put(path.posix.join(data.prefix||prefix,file.filename),buffer,{
1919
httpMetadata: {contentType: file.mimeType},
2020
})
21-
22-
returndata
2321
}
2422
}

‎packages/storage-r2/src/index.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
importtype{
22
Adapter,
3+
ClientUploadsConfig,
34
PluginOptionsasCloudStoragePluginOptions,
45
CollectionOptions,
56
GeneratedAdapter,
67
}from'@payloadcms/plugin-cloud-storage/types'
78
importtype{Config,Plugin,UploadCollectionSlug}from'payload'
89

910
import{cloudStoragePlugin}from'@payloadcms/plugin-cloud-storage'
11+
import{initClientUploads}from'@payloadcms/plugin-cloud-storage/utilities'
1012

11-
importtype{R2Bucket}from'./types.js'
13+
importtype{R2Bucket,R2StorageClientUploadHandlerParams}from'./types.js'
1214

1315
import{getHandleDelete}from'./handleDelete.js'
16+
import{getHandleMultiPartUpload}from'./handleMultiPartUpload.js'
1417
import{getHandleUpload}from'./handleUpload.js'
1518
import{getHandler}from'./staticHandler.js'
1619

@@ -27,6 +30,10 @@ export interface R2StorageOptions {
2730
alwaysInsertFields?: boolean
2831

2932
bucket: R2Bucket
33+
/**
34+
* Do uploads directly on the client, to bypass limits on Cloudflare/Vercel.
35+
*/
36+
clientUploads?: ClientUploadsConfig
3037
/**
3138
* Collection options to apply the R2 adapter to.
3239
*/
@@ -43,6 +50,29 @@ export const r2Storage: R2StoragePlugin =
4350

4451
constisPluginDisabled=r2StorageOptions.enabled===false
4552

53+
initClientUploads<
54+
R2StorageClientUploadHandlerParams,
55+
R2StorageOptions['collections'][keyofR2StorageOptions['collections']]
56+
>({
57+
clientHandler: '@payloadcms/storage-r2/client#R2ClientUploadHandler',
58+
collections: r2StorageOptions.collections,
59+
config: incomingConfig,
60+
enabled: !isPluginDisabled&&Boolean(r2StorageOptions.clientUploads),
61+
extraClientHandlerProps: (collection)=>({
62+
prefix:
63+
(typeofcollection==='object'&&collection.prefix&&`${collection.prefix}/`)||'',
64+
}),
65+
serverHandler: getHandleMultiPartUpload({
66+
access:
67+
typeofr2StorageOptions.clientUploads==='object'
68+
? r2StorageOptions.clientUploads.access
69+
: undefined,
70+
bucket: r2StorageOptions.bucket,
71+
collections: r2StorageOptions.collections,
72+
}),
73+
serverHandlerPath: '/storage-r2-multi-part-upload',
74+
})
75+
4676
if(isPluginDisabled){
4777
returnincomingConfig
4878
}
@@ -85,10 +115,11 @@ export const r2Storage: R2StoragePlugin =
85115
})(config)
86116
}
87117

88-
functionr2StorageInternal({ bucket }: R2StorageOptions): Adapter{
118+
functionr2StorageInternal({ bucket, clientUploads}: R2StorageOptions): Adapter{
89119
return({ collection, prefix }): GeneratedAdapter=>{
90120
return{
91121
name: 'r2',
122+
clientUploads,
92123
handleDelete: getHandleDelete({ bucket }),
93124
handleUpload: getHandleUpload({
94125
bucket,

‎packages/storage-r2/src/staticHandler.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { CollectionConfig } from 'payload'
44
importpathfrom'path'
55
import{getRangeRequestInfo}from'payload/internal'
66

7-
importtype{R2Bucket}from'./types.js'
7+
importtype{R2Bucket,R2ObjectBody}from'./types.js'
88

99
interfaceArgs{
1010
bucket: R2Bucket
@@ -15,7 +15,7 @@ interface Args {
1515
constisMiniflare=process.env.NODE_ENV==='development'
1616

1717
exportconstgetHandler=({ bucket, collection, prefix =''}: Args): StaticHandler=>{
18-
returnasync(req,{headers: incomingHeaders,params: { filename }})=>{
18+
returnasync(req,{headers: incomingHeaders,params: {clientUploadContext,filename }})=>{
1919
try{
2020
constkey=path.posix.join(prefix,filename)
2121

@@ -27,6 +27,11 @@ export const getHandler = ({ bucket, collection, prefix = '' }: Args): StaticHan
2727

2828
constfileSize=headObj.size
2929

30+
// Don't return large file uploads back to the client, or the Worker will run out of memory
31+
if(fileSize>50*1024*1024&&clientUploadContext){
32+
returnnewResponse(null,{status: 200})
33+
}
34+
3035
// Handle range request
3136
constrangeHeader=req.headers.get('range')
3237
constrangeResult=getRangeRequestInfo({ fileSize, rangeHeader })
@@ -41,7 +46,7 @@ export const getHandler = ({ bucket, collection, prefix = '' }: Args): StaticHan
4146
// Get object with range if needed
4247
// Due to https://github.com/cloudflare/workers-sdk/issues/6047
4348
// We cannot send a Headers instance to Miniflare
44-
constobj=
49+
constobj: R2ObjectBody=
4550
rangeResult.type==='partial'&&!isMiniflare
4651
? awaitbucket?.get(key,{
4752
range: {
@@ -63,7 +68,25 @@ export const getHandler = ({ bucket, collection, prefix = '' }: Args): StaticHan
6368
}
6469

6570
// Add R2-specific headers
66-
if(!isMiniflare){
71+
if(isMiniflare){
72+
// In development with Miniflare, manually set headers from httpMetadata
73+
constmetadata=obj.httpMetadata
74+
if(metadata?.cacheControl){
75+
headers.set('Cache-Control',metadata.cacheControl)
76+
}
77+
if(metadata?.contentDisposition){
78+
headers.set('Content-Disposition',metadata.contentDisposition)
79+
}
80+
if(metadata?.contentEncoding){
81+
headers.set('Content-Encoding',metadata.contentEncoding)
82+
}
83+
if(metadata?.contentLanguage){
84+
headers.set('Content-Language',metadata.contentLanguage)
85+
}
86+
if(metadata?.contentType){
87+
headers.set('Content-Type',metadata.contentType)
88+
}
89+
}else{
6790
obj.writeHttpMetadata(headers)
6891
}
6992

‎packages/storage-r2/src/types.ts‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export interface R2GetOptions {
1414
}
1515

1616
exportinterfaceR2Bucket{
17-
createMultipartUpload(key: string,options?: any): Promise<any>
17+
createMultipartUpload(key: string,options?: any): Promise<R2MultipartUpload>
1818
delete(keys: string|string[]): Promise<void>
1919
get(key: string,options?: R2GetOptions): Promise<any|null>
2020
head(key: string): Promise<any>
@@ -32,5 +32,60 @@ export interface R2Bucket {
3232
value: ArrayBuffer|ArrayBufferView|Blob|null|ReadableStream|string,
3333
options?: any,
3434
): Promise<any>
35-
resumeMultipartUpload(key: string,uploadId: string): any
35+
resumeMultipartUpload(key: string,uploadId: string): R2MultipartUpload
36+
}
37+
38+
interfaceR2HTTPMetadata{
39+
cacheControl?: string
40+
cacheExpiry?: Date
41+
contentDisposition?: string
42+
contentEncoding?: string
43+
contentLanguage?: string
44+
contentType?: string
45+
}
46+
47+
exportinterfaceR2Object{
48+
readonlyetag: string
49+
readonlyhttpMetadata?: R2HTTPMetadata
50+
readonlykey: string
51+
readonlysize: number
52+
53+
writeHttpMetadata(headers: Headers): void
54+
}
55+
exportinterfaceR2ObjectBodyextendsR2Object{
56+
getbody(): ReadableStream
57+
}
58+
59+
exportinterfaceR2MultipartUpload{
60+
abort(): Promise<void>
61+
complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>
62+
readonlykey: string
63+
readonlyuploadId: string
64+
uploadPart(
65+
partNumber: number,
66+
value: (ArrayBuffer|ArrayBufferView)|Blob|ReadableStream|string,
67+
options?: any,
68+
): Promise<R2UploadedPart>
69+
}
70+
71+
exportinterfaceR2StorageClientUploadContext{
72+
key: string
73+
}
74+
exporttypeR2StorageClientUploadHandlerParams={
75+
chunkSize?: number
76+
prefix: string
77+
}
78+
79+
exporttypeR2StorageMultipartUploadHandlerParams={
80+
collection: string
81+
fileName: string
82+
fileType: string
83+
multipartId?: string
84+
multipartKey?: string
85+
multipartNumber?: string
86+
}
87+
88+
exportinterfaceR2UploadedPart{
89+
etag: string
90+
partNumber: number
3691
}

0 commit comments

Comments
 (0)