@edgestore/react 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/createNextProxy.d.ts +15 -0
- package/dist/createNextProxy.d.ts.map +1 -1
- package/dist/index.js +135 -5
- package/dist/index.mjs +135 -5
- package/package.json +5 -5
- package/src/createNextProxy.ts +211 -9
package/README.md
CHANGED
|
@@ -144,7 +144,7 @@ export default function Page() {
|
|
|
144
144
|
By passing the `replaceTargetUrl` option, you can replace an existing file with a new one.
|
|
145
145
|
It will automatically delete the old file after the upload is complete.
|
|
146
146
|
|
|
147
|
-
You can also just upload the file using the same file name, but in that case, you might still see the old file for a while
|
|
147
|
+
You can also just upload the file using the same file name, but in that case, you might still see the old file for a while because of the CDN cache.
|
|
148
148
|
|
|
149
149
|
```tsx
|
|
150
150
|
const res = await edgestore.publicFiles.upload({
|
|
@@ -26,6 +26,11 @@ export type BucketFunctions<TRouter extends AnyRouter> = {
|
|
|
26
26
|
metadata: InferMetadataObject<TRouter['buckets'][K]>;
|
|
27
27
|
path: InferBucketPathObject<TRouter['buckets'][K]>;
|
|
28
28
|
}>;
|
|
29
|
+
confirmUpload: (params: {
|
|
30
|
+
url: string;
|
|
31
|
+
}) => Promise<{
|
|
32
|
+
success: boolean;
|
|
33
|
+
}>;
|
|
29
34
|
delete: (params: {
|
|
30
35
|
url: string;
|
|
31
36
|
}) => Promise<{
|
|
@@ -52,6 +57,16 @@ type UploadOptions = {
|
|
|
52
57
|
* It will automatically delete the existing file when the upload is complete.
|
|
53
58
|
*/
|
|
54
59
|
replaceTargetUrl?: string;
|
|
60
|
+
/**
|
|
61
|
+
* If true, the file needs to be confirmed by using the `confirmUpload` function.
|
|
62
|
+
* If the file is not confirmed within 24 hours, it will be deleted.
|
|
63
|
+
*
|
|
64
|
+
* This is useful for pages where the file is uploaded as soon as it is selected,
|
|
65
|
+
* but the user can leave the page without submitting the form.
|
|
66
|
+
*
|
|
67
|
+
* This avoids unnecessary zombie files in the bucket.
|
|
68
|
+
*/
|
|
69
|
+
temporary?: boolean;
|
|
55
70
|
};
|
|
56
71
|
export declare function createNextProxy<TRouter extends AnyRouter>({ apiPath, uploadingCountRef, maxConcurrentUploads, }: {
|
|
57
72
|
apiPath: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createNextProxy.d.ts","sourceRoot":"","sources":["../src/createNextProxy.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"createNextProxy.d.ts","sourceRoot":"","sources":["../src/createNextProxy.ts"],"names":[],"mappings":";AACA,OAAO,EACL,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACzB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,CAAC;AAG7B,MAAM,MAAM,eAAe,CAAC,OAAO,SAAS,SAAS,IAAI;KACtD,CAAC,IAAI,MAAM,OAAO,CAAC,SAAS,CAAC,GAAG;QAC/B,MAAM,EAAE,CACN,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,SAAS,KAAK,GACjE;YACE,IAAI,EAAE,IAAI,CAAC;YACX,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;YAC3C,OAAO,CAAC,EAAE,aAAa,CAAC;SACzB,GACD;YACE,IAAI,EAAE,IAAI,CAAC;YACX,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;YACvD,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;YAC3C,OAAO,CAAC,EAAE,aAAa,CAAC;SACzB,KACF,OAAO,CACV,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,SAAS,OAAO,GACjD;YACE,GAAG,EAAE,MAAM,CAAC;YACZ,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;YAC5B,IAAI,EAAE,MAAM,CAAC;YACb,UAAU,EAAE,IAAI,CAAC;YACjB,QAAQ,EAAE,mBAAmB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACrD,IAAI,EAAE,qBAAqB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SACpD,GACD;YACE,GAAG,EAAE,MAAM,CAAC;YACZ,IAAI,EAAE,MAAM,CAAC;YACb,UAAU,EAAE,IAAI,CAAC;YACjB,QAAQ,EAAE,mBAAmB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACrD,IAAI,EAAE,qBAAqB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SACpD,CACN,CAAC;QACF,aAAa,EAAE,CAAC,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC;YAClD,OAAO,EAAE,OAAO,CAAC;SAClB,CAAC,CAAC;QACH,MAAM,EAAE,CAAC,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC;YAC3C,OAAO,EAAE,OAAO,CAAC;SAClB,CAAC,CAAC;KACJ;CACF,CAAC;AAEF,KAAK,uBAAuB,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;AAE1D,KAAK,aAAa,GAAG;IACnB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,wBAAgB,eAAe,CAAC,OAAO,SAAS,SAAS,EAAE,EACzD,OAAO,EACP,iBAAiB,EACjB,oBAAwB,GACzB,EAAE;IACD,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,EAAE,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;IAClD,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,4BAuCA"}
|
package/dist/index.js
CHANGED
|
@@ -51,6 +51,12 @@ function createNextProxy({ apiPath, uploadingCountRef, maxConcurrentUploads = 5
|
|
|
51
51
|
uploadingCountRef.current--;
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
|
+
confirmUpload: async (params)=>{
|
|
55
|
+
return await confirmUpload(params, {
|
|
56
|
+
bucketName: bucketName,
|
|
57
|
+
apiPath
|
|
58
|
+
});
|
|
59
|
+
},
|
|
54
60
|
delete: async (params)=>{
|
|
55
61
|
return await deleteFile(params, {
|
|
56
62
|
bucketName: bucketName,
|
|
@@ -75,7 +81,8 @@ async function uploadFile({ file, input, onProgressChange, options }, { apiPath,
|
|
|
75
81
|
type: file.type,
|
|
76
82
|
size: file.size,
|
|
77
83
|
fileName: options?.manualFileName,
|
|
78
|
-
replaceTargetUrl: options?.replaceTargetUrl
|
|
84
|
+
replaceTargetUrl: options?.replaceTargetUrl,
|
|
85
|
+
temporary: options?.temporary
|
|
79
86
|
}
|
|
80
87
|
}),
|
|
81
88
|
headers: {
|
|
@@ -83,17 +90,29 @@ async function uploadFile({ file, input, onProgressChange, options }, { apiPath,
|
|
|
83
90
|
}
|
|
84
91
|
});
|
|
85
92
|
const json = await res.json();
|
|
86
|
-
if (
|
|
93
|
+
if ('multipart' in json) {
|
|
94
|
+
await multipartUpload({
|
|
95
|
+
bucketName,
|
|
96
|
+
multipartInfo: json.multipart,
|
|
97
|
+
onProgressChange,
|
|
98
|
+
file,
|
|
99
|
+
apiPath
|
|
100
|
+
});
|
|
101
|
+
} else if ('uploadUrl' in json) {
|
|
102
|
+
// Single part upload
|
|
103
|
+
// Upload the file to the signed URL and get the progress
|
|
104
|
+
await uploadFileInner(file, json.uploadUrl, onProgressChange);
|
|
105
|
+
} else {
|
|
87
106
|
throw new EdgeStoreError('An error occurred');
|
|
88
107
|
}
|
|
89
|
-
// Upload the file to the signed URL and get the progress
|
|
90
|
-
await uploadFileInner(file, json.uploadUrl, onProgressChange);
|
|
91
108
|
return {
|
|
92
109
|
url: getUrl(json.accessUrl, apiPath),
|
|
93
110
|
thumbnailUrl: json.thumbnailUrl ? getUrl(json.thumbnailUrl, apiPath) : null,
|
|
94
111
|
size: json.size,
|
|
95
112
|
uploadedAt: new Date(json.uploadedAt),
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
96
114
|
path: json.path,
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
97
116
|
metadata: json.metadata
|
|
98
117
|
};
|
|
99
118
|
} catch (e) {
|
|
@@ -137,12 +156,86 @@ const uploadFileInner = async (file, uploadUrl, onProgressChange)=>{
|
|
|
137
156
|
reject(new Error('File upload aborted'));
|
|
138
157
|
});
|
|
139
158
|
request.addEventListener('loadend', ()=>{
|
|
140
|
-
|
|
159
|
+
// Return the ETag header (needed to complete multipart upload)
|
|
160
|
+
resolve(request.getResponseHeader('ETag'));
|
|
141
161
|
});
|
|
142
162
|
request.send(file);
|
|
143
163
|
});
|
|
144
164
|
return promise;
|
|
145
165
|
};
|
|
166
|
+
async function multipartUpload(params) {
|
|
167
|
+
const { bucketName, multipartInfo, onProgressChange, file, apiPath } = params;
|
|
168
|
+
const { partSize, parts, totalParts, uploadId, key } = multipartInfo;
|
|
169
|
+
const uploadingParts = [];
|
|
170
|
+
const uploadPart = async (params)=>{
|
|
171
|
+
const { part, chunk } = params;
|
|
172
|
+
const { uploadUrl } = part;
|
|
173
|
+
const eTag = await uploadFileInner(chunk, uploadUrl, (progress)=>{
|
|
174
|
+
const uploadingPart = uploadingParts.find((p)=>p.partNumber === part.partNumber);
|
|
175
|
+
if (uploadingPart) {
|
|
176
|
+
uploadingPart.progress = progress;
|
|
177
|
+
} else {
|
|
178
|
+
uploadingParts.push({
|
|
179
|
+
partNumber: part.partNumber,
|
|
180
|
+
progress
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const totalProgress = Math.round(uploadingParts.reduce((acc, p)=>acc + p.progress * 100, 0) / totalParts) / 100;
|
|
184
|
+
onProgressChange?.(totalProgress);
|
|
185
|
+
});
|
|
186
|
+
if (!eTag) {
|
|
187
|
+
throw new EdgeStoreError('Could not get ETag from multipart response');
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
partNumber: part.partNumber,
|
|
191
|
+
eTag
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
// Upload the parts in parallel
|
|
195
|
+
const completedParts = await queuedPromises({
|
|
196
|
+
items: parts.map((part)=>({
|
|
197
|
+
part,
|
|
198
|
+
chunk: file.slice((part.partNumber - 1) * partSize, part.partNumber * partSize)
|
|
199
|
+
})),
|
|
200
|
+
fn: uploadPart,
|
|
201
|
+
maxParallel: 5,
|
|
202
|
+
maxRetries: 10
|
|
203
|
+
});
|
|
204
|
+
// Complete multipart upload
|
|
205
|
+
const res = await fetch(`${apiPath}/complete-multipart-upload`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
body: JSON.stringify({
|
|
208
|
+
bucketName,
|
|
209
|
+
uploadId,
|
|
210
|
+
key,
|
|
211
|
+
parts: completedParts
|
|
212
|
+
}),
|
|
213
|
+
headers: {
|
|
214
|
+
'Content-Type': 'application/json'
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
throw new EdgeStoreError('Multi-part upload failed');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function confirmUpload({ url }, { apiPath, bucketName }) {
|
|
222
|
+
const res = await fetch(`${apiPath}/confirm-upload`, {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
url,
|
|
226
|
+
bucketName
|
|
227
|
+
}),
|
|
228
|
+
headers: {
|
|
229
|
+
'Content-Type': 'application/json'
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok) {
|
|
233
|
+
throw new EdgeStoreError('An error occurred');
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
success: true
|
|
237
|
+
};
|
|
238
|
+
}
|
|
146
239
|
async function deleteFile({ url }, { apiPath, bucketName }) {
|
|
147
240
|
const res = await fetch(`${apiPath}/delete-file`, {
|
|
148
241
|
method: 'POST',
|
|
@@ -161,6 +254,43 @@ async function deleteFile({ url }, { apiPath, bucketName }) {
|
|
|
161
254
|
success: true
|
|
162
255
|
};
|
|
163
256
|
}
|
|
257
|
+
async function queuedPromises({ items, fn, maxParallel, maxRetries = 0 }) {
|
|
258
|
+
const results = new Array(items.length);
|
|
259
|
+
const executeWithRetry = async (func, retries)=>{
|
|
260
|
+
try {
|
|
261
|
+
return await func();
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (retries > 0) {
|
|
264
|
+
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
265
|
+
return executeWithRetry(func, retries - 1);
|
|
266
|
+
} else {
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const semaphore = {
|
|
272
|
+
count: maxParallel,
|
|
273
|
+
async wait () {
|
|
274
|
+
// If we've reached our maximum concurrency or it's the last item, wait
|
|
275
|
+
while(this.count <= 0)await new Promise((resolve)=>setTimeout(resolve, 500));
|
|
276
|
+
this.count--;
|
|
277
|
+
},
|
|
278
|
+
signal () {
|
|
279
|
+
this.count++;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const tasks = items.map((item, i)=>(async ()=>{
|
|
283
|
+
await semaphore.wait();
|
|
284
|
+
try {
|
|
285
|
+
const result = await executeWithRetry(()=>fn(item), maxRetries);
|
|
286
|
+
results[i] = result;
|
|
287
|
+
} finally{
|
|
288
|
+
semaphore.signal();
|
|
289
|
+
}
|
|
290
|
+
})());
|
|
291
|
+
await Promise.all(tasks);
|
|
292
|
+
return results;
|
|
293
|
+
}
|
|
164
294
|
|
|
165
295
|
const DEFAULT_BASE_URL = process.env.NEXT_PUBLIC_EDGE_STORE_BASE_URL ?? 'https://files.edgestore.dev';
|
|
166
296
|
function createEdgeStoreProvider(opts) {
|
package/dist/index.mjs
CHANGED
|
@@ -27,6 +27,12 @@ function createNextProxy({ apiPath, uploadingCountRef, maxConcurrentUploads = 5
|
|
|
27
27
|
uploadingCountRef.current--;
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
|
+
confirmUpload: async (params)=>{
|
|
31
|
+
return await confirmUpload(params, {
|
|
32
|
+
bucketName: bucketName,
|
|
33
|
+
apiPath
|
|
34
|
+
});
|
|
35
|
+
},
|
|
30
36
|
delete: async (params)=>{
|
|
31
37
|
return await deleteFile(params, {
|
|
32
38
|
bucketName: bucketName,
|
|
@@ -51,7 +57,8 @@ async function uploadFile({ file, input, onProgressChange, options }, { apiPath,
|
|
|
51
57
|
type: file.type,
|
|
52
58
|
size: file.size,
|
|
53
59
|
fileName: options?.manualFileName,
|
|
54
|
-
replaceTargetUrl: options?.replaceTargetUrl
|
|
60
|
+
replaceTargetUrl: options?.replaceTargetUrl,
|
|
61
|
+
temporary: options?.temporary
|
|
55
62
|
}
|
|
56
63
|
}),
|
|
57
64
|
headers: {
|
|
@@ -59,17 +66,29 @@ async function uploadFile({ file, input, onProgressChange, options }, { apiPath,
|
|
|
59
66
|
}
|
|
60
67
|
});
|
|
61
68
|
const json = await res.json();
|
|
62
|
-
if (
|
|
69
|
+
if ('multipart' in json) {
|
|
70
|
+
await multipartUpload({
|
|
71
|
+
bucketName,
|
|
72
|
+
multipartInfo: json.multipart,
|
|
73
|
+
onProgressChange,
|
|
74
|
+
file,
|
|
75
|
+
apiPath
|
|
76
|
+
});
|
|
77
|
+
} else if ('uploadUrl' in json) {
|
|
78
|
+
// Single part upload
|
|
79
|
+
// Upload the file to the signed URL and get the progress
|
|
80
|
+
await uploadFileInner(file, json.uploadUrl, onProgressChange);
|
|
81
|
+
} else {
|
|
63
82
|
throw new EdgeStoreError('An error occurred');
|
|
64
83
|
}
|
|
65
|
-
// Upload the file to the signed URL and get the progress
|
|
66
|
-
await uploadFileInner(file, json.uploadUrl, onProgressChange);
|
|
67
84
|
return {
|
|
68
85
|
url: getUrl(json.accessUrl, apiPath),
|
|
69
86
|
thumbnailUrl: json.thumbnailUrl ? getUrl(json.thumbnailUrl, apiPath) : null,
|
|
70
87
|
size: json.size,
|
|
71
88
|
uploadedAt: new Date(json.uploadedAt),
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
72
90
|
path: json.path,
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
73
92
|
metadata: json.metadata
|
|
74
93
|
};
|
|
75
94
|
} catch (e) {
|
|
@@ -113,12 +132,86 @@ const uploadFileInner = async (file, uploadUrl, onProgressChange)=>{
|
|
|
113
132
|
reject(new Error('File upload aborted'));
|
|
114
133
|
});
|
|
115
134
|
request.addEventListener('loadend', ()=>{
|
|
116
|
-
|
|
135
|
+
// Return the ETag header (needed to complete multipart upload)
|
|
136
|
+
resolve(request.getResponseHeader('ETag'));
|
|
117
137
|
});
|
|
118
138
|
request.send(file);
|
|
119
139
|
});
|
|
120
140
|
return promise;
|
|
121
141
|
};
|
|
142
|
+
async function multipartUpload(params) {
|
|
143
|
+
const { bucketName, multipartInfo, onProgressChange, file, apiPath } = params;
|
|
144
|
+
const { partSize, parts, totalParts, uploadId, key } = multipartInfo;
|
|
145
|
+
const uploadingParts = [];
|
|
146
|
+
const uploadPart = async (params)=>{
|
|
147
|
+
const { part, chunk } = params;
|
|
148
|
+
const { uploadUrl } = part;
|
|
149
|
+
const eTag = await uploadFileInner(chunk, uploadUrl, (progress)=>{
|
|
150
|
+
const uploadingPart = uploadingParts.find((p)=>p.partNumber === part.partNumber);
|
|
151
|
+
if (uploadingPart) {
|
|
152
|
+
uploadingPart.progress = progress;
|
|
153
|
+
} else {
|
|
154
|
+
uploadingParts.push({
|
|
155
|
+
partNumber: part.partNumber,
|
|
156
|
+
progress
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const totalProgress = Math.round(uploadingParts.reduce((acc, p)=>acc + p.progress * 100, 0) / totalParts) / 100;
|
|
160
|
+
onProgressChange?.(totalProgress);
|
|
161
|
+
});
|
|
162
|
+
if (!eTag) {
|
|
163
|
+
throw new EdgeStoreError('Could not get ETag from multipart response');
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
partNumber: part.partNumber,
|
|
167
|
+
eTag
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
// Upload the parts in parallel
|
|
171
|
+
const completedParts = await queuedPromises({
|
|
172
|
+
items: parts.map((part)=>({
|
|
173
|
+
part,
|
|
174
|
+
chunk: file.slice((part.partNumber - 1) * partSize, part.partNumber * partSize)
|
|
175
|
+
})),
|
|
176
|
+
fn: uploadPart,
|
|
177
|
+
maxParallel: 5,
|
|
178
|
+
maxRetries: 10
|
|
179
|
+
});
|
|
180
|
+
// Complete multipart upload
|
|
181
|
+
const res = await fetch(`${apiPath}/complete-multipart-upload`, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
bucketName,
|
|
185
|
+
uploadId,
|
|
186
|
+
key,
|
|
187
|
+
parts: completedParts
|
|
188
|
+
}),
|
|
189
|
+
headers: {
|
|
190
|
+
'Content-Type': 'application/json'
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
throw new EdgeStoreError('Multi-part upload failed');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function confirmUpload({ url }, { apiPath, bucketName }) {
|
|
198
|
+
const res = await fetch(`${apiPath}/confirm-upload`, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
url,
|
|
202
|
+
bucketName
|
|
203
|
+
}),
|
|
204
|
+
headers: {
|
|
205
|
+
'Content-Type': 'application/json'
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
throw new EdgeStoreError('An error occurred');
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
success: true
|
|
213
|
+
};
|
|
214
|
+
}
|
|
122
215
|
async function deleteFile({ url }, { apiPath, bucketName }) {
|
|
123
216
|
const res = await fetch(`${apiPath}/delete-file`, {
|
|
124
217
|
method: 'POST',
|
|
@@ -137,6 +230,43 @@ async function deleteFile({ url }, { apiPath, bucketName }) {
|
|
|
137
230
|
success: true
|
|
138
231
|
};
|
|
139
232
|
}
|
|
233
|
+
async function queuedPromises({ items, fn, maxParallel, maxRetries = 0 }) {
|
|
234
|
+
const results = new Array(items.length);
|
|
235
|
+
const executeWithRetry = async (func, retries)=>{
|
|
236
|
+
try {
|
|
237
|
+
return await func();
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (retries > 0) {
|
|
240
|
+
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
241
|
+
return executeWithRetry(func, retries - 1);
|
|
242
|
+
} else {
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const semaphore = {
|
|
248
|
+
count: maxParallel,
|
|
249
|
+
async wait () {
|
|
250
|
+
// If we've reached our maximum concurrency or it's the last item, wait
|
|
251
|
+
while(this.count <= 0)await new Promise((resolve)=>setTimeout(resolve, 500));
|
|
252
|
+
this.count--;
|
|
253
|
+
},
|
|
254
|
+
signal () {
|
|
255
|
+
this.count++;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const tasks = items.map((item, i)=>(async ()=>{
|
|
259
|
+
await semaphore.wait();
|
|
260
|
+
try {
|
|
261
|
+
const result = await executeWithRetry(()=>fn(item), maxRetries);
|
|
262
|
+
results[i] = result;
|
|
263
|
+
} finally{
|
|
264
|
+
semaphore.signal();
|
|
265
|
+
}
|
|
266
|
+
})());
|
|
267
|
+
await Promise.all(tasks);
|
|
268
|
+
return results;
|
|
269
|
+
}
|
|
140
270
|
|
|
141
271
|
const DEFAULT_BASE_URL = process.env.NEXT_PUBLIC_EDGE_STORE_BASE_URL ?? 'https://files.edgestore.dev';
|
|
142
272
|
function createEdgeStoreProvider(opts) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgestore/react",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Upload files with ease from React/Next.js",
|
|
5
5
|
"homepage": "https://edgestore.dev",
|
|
6
6
|
"repository": "https://github.com/edgestorejs/edgestore.git",
|
|
7
7
|
"author": "Ravi <me@ravi.com>",
|
|
@@ -54,14 +54,14 @@
|
|
|
54
54
|
"uuid": "^9.0.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@edgestore/server": "0.1.
|
|
57
|
+
"@edgestore/server": "0.1.2",
|
|
58
58
|
"next": "*",
|
|
59
59
|
"react": ">=16.8.0",
|
|
60
60
|
"react-dom": ">=16.8.0",
|
|
61
61
|
"zod": ">=3.0.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@edgestore/server": "0.1.
|
|
64
|
+
"@edgestore/server": "0.1.2",
|
|
65
65
|
"@types/cookie": "^0.5.1",
|
|
66
66
|
"@types/node": "^18.11.18",
|
|
67
67
|
"@types/uuid": "^9.0.1",
|
|
@@ -71,5 +71,5 @@
|
|
|
71
71
|
"typescript": "^5.1.6",
|
|
72
72
|
"zod": "^3.21.4"
|
|
73
73
|
},
|
|
74
|
-
"gitHead": "
|
|
74
|
+
"gitHead": "99519b57aa7a5cbbe5cab16b85a70d437cb87435"
|
|
75
75
|
}
|
package/src/createNextProxy.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type RequestUploadRes } from '@edgestore/server/adapters';
|
|
1
2
|
import {
|
|
2
3
|
type AnyRouter,
|
|
3
4
|
type InferBucketPathObject,
|
|
@@ -39,6 +40,9 @@ export type BucketFunctions<TRouter extends AnyRouter> = {
|
|
|
39
40
|
path: InferBucketPathObject<TRouter['buckets'][K]>;
|
|
40
41
|
}
|
|
41
42
|
>;
|
|
43
|
+
confirmUpload: (params: { url: string }) => Promise<{
|
|
44
|
+
success: boolean;
|
|
45
|
+
}>;
|
|
42
46
|
delete: (params: { url: string }) => Promise<{
|
|
43
47
|
success: boolean;
|
|
44
48
|
}>;
|
|
@@ -65,6 +69,16 @@ type UploadOptions = {
|
|
|
65
69
|
* It will automatically delete the existing file when the upload is complete.
|
|
66
70
|
*/
|
|
67
71
|
replaceTargetUrl?: string;
|
|
72
|
+
/**
|
|
73
|
+
* If true, the file needs to be confirmed by using the `confirmUpload` function.
|
|
74
|
+
* If the file is not confirmed within 24 hours, it will be deleted.
|
|
75
|
+
*
|
|
76
|
+
* This is useful for pages where the file is uploaded as soon as it is selected,
|
|
77
|
+
* but the user can leave the page without submitting the form.
|
|
78
|
+
*
|
|
79
|
+
* This avoids unnecessary zombie files in the bucket.
|
|
80
|
+
*/
|
|
81
|
+
temporary?: boolean;
|
|
68
82
|
};
|
|
69
83
|
|
|
70
84
|
export function createNextProxy<TRouter extends AnyRouter>({
|
|
@@ -98,6 +112,12 @@ export function createNextProxy<TRouter extends AnyRouter>({
|
|
|
98
112
|
uploadingCountRef.current--;
|
|
99
113
|
}
|
|
100
114
|
},
|
|
115
|
+
confirmUpload: async (params: { url: string }) => {
|
|
116
|
+
return await confirmUpload(params, {
|
|
117
|
+
bucketName: bucketName as string,
|
|
118
|
+
apiPath,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
101
121
|
delete: async (params: { url: string }) => {
|
|
102
122
|
return await deleteFile(params, {
|
|
103
123
|
bucketName: bucketName as string,
|
|
@@ -143,18 +163,29 @@ async function uploadFile(
|
|
|
143
163
|
size: file.size,
|
|
144
164
|
fileName: options?.manualFileName,
|
|
145
165
|
replaceTargetUrl: options?.replaceTargetUrl,
|
|
166
|
+
temporary: options?.temporary,
|
|
146
167
|
},
|
|
147
168
|
}),
|
|
148
169
|
headers: {
|
|
149
170
|
'Content-Type': 'application/json',
|
|
150
171
|
},
|
|
151
172
|
});
|
|
152
|
-
const json = await res.json();
|
|
153
|
-
if (
|
|
173
|
+
const json = (await res.json()) as RequestUploadRes;
|
|
174
|
+
if ('multipart' in json) {
|
|
175
|
+
await multipartUpload({
|
|
176
|
+
bucketName,
|
|
177
|
+
multipartInfo: json.multipart,
|
|
178
|
+
onProgressChange,
|
|
179
|
+
file,
|
|
180
|
+
apiPath,
|
|
181
|
+
});
|
|
182
|
+
} else if ('uploadUrl' in json) {
|
|
183
|
+
// Single part upload
|
|
184
|
+
// Upload the file to the signed URL and get the progress
|
|
185
|
+
await uploadFileInner(file, json.uploadUrl, onProgressChange);
|
|
186
|
+
} else {
|
|
154
187
|
throw new EdgeStoreError('An error occurred');
|
|
155
188
|
}
|
|
156
|
-
// Upload the file to the signed URL and get the progress
|
|
157
|
-
await uploadFileInner(file, json.uploadUrl, onProgressChange);
|
|
158
189
|
return {
|
|
159
190
|
url: getUrl(json.accessUrl, apiPath),
|
|
160
191
|
thumbnailUrl: json.thumbnailUrl
|
|
@@ -162,8 +193,10 @@ async function uploadFile(
|
|
|
162
193
|
: null,
|
|
163
194
|
size: json.size,
|
|
164
195
|
uploadedAt: new Date(json.uploadedAt),
|
|
165
|
-
|
|
166
|
-
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
197
|
+
path: json.path as any,
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
199
|
+
metadata: json.metadata as any,
|
|
167
200
|
};
|
|
168
201
|
} catch (e) {
|
|
169
202
|
onProgressChange?.(0);
|
|
@@ -189,11 +222,11 @@ function getUrl(url: string, apiPath: string) {
|
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
const uploadFileInner = async (
|
|
192
|
-
file: File,
|
|
225
|
+
file: File | Blob,
|
|
193
226
|
uploadUrl: string,
|
|
194
227
|
onProgressChange?: OnProgressChangeHandler,
|
|
195
228
|
) => {
|
|
196
|
-
const promise = new Promise<
|
|
229
|
+
const promise = new Promise<string | null>((resolve, reject) => {
|
|
197
230
|
const request = new XMLHttpRequest();
|
|
198
231
|
request.open('PUT', uploadUrl);
|
|
199
232
|
request.addEventListener('loadstart', () => {
|
|
@@ -213,7 +246,8 @@ const uploadFileInner = async (
|
|
|
213
246
|
reject(new Error('File upload aborted'));
|
|
214
247
|
});
|
|
215
248
|
request.addEventListener('loadend', () => {
|
|
216
|
-
|
|
249
|
+
// Return the ETag header (needed to complete multipart upload)
|
|
250
|
+
resolve(request.getResponseHeader('ETag'));
|
|
217
251
|
});
|
|
218
252
|
|
|
219
253
|
request.send(file);
|
|
@@ -221,6 +255,115 @@ const uploadFileInner = async (
|
|
|
221
255
|
return promise;
|
|
222
256
|
};
|
|
223
257
|
|
|
258
|
+
async function multipartUpload(params: {
|
|
259
|
+
bucketName: string;
|
|
260
|
+
multipartInfo: Extract<RequestUploadRes, { multipart: any }>['multipart'];
|
|
261
|
+
onProgressChange: OnProgressChangeHandler | undefined;
|
|
262
|
+
file: File;
|
|
263
|
+
apiPath: string;
|
|
264
|
+
}) {
|
|
265
|
+
const { bucketName, multipartInfo, onProgressChange, file, apiPath } = params;
|
|
266
|
+
const { partSize, parts, totalParts, uploadId, key } = multipartInfo;
|
|
267
|
+
const uploadingParts: {
|
|
268
|
+
partNumber: number;
|
|
269
|
+
progress: number;
|
|
270
|
+
}[] = [];
|
|
271
|
+
const uploadPart = async (params: {
|
|
272
|
+
part: typeof parts[number];
|
|
273
|
+
chunk: Blob;
|
|
274
|
+
}) => {
|
|
275
|
+
const { part, chunk } = params;
|
|
276
|
+
const { uploadUrl } = part;
|
|
277
|
+
const eTag = await uploadFileInner(chunk, uploadUrl, (progress) => {
|
|
278
|
+
const uploadingPart = uploadingParts.find(
|
|
279
|
+
(p) => p.partNumber === part.partNumber,
|
|
280
|
+
);
|
|
281
|
+
if (uploadingPart) {
|
|
282
|
+
uploadingPart.progress = progress;
|
|
283
|
+
} else {
|
|
284
|
+
uploadingParts.push({
|
|
285
|
+
partNumber: part.partNumber,
|
|
286
|
+
progress,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const totalProgress =
|
|
290
|
+
Math.round(
|
|
291
|
+
uploadingParts.reduce((acc, p) => acc + p.progress * 100, 0) /
|
|
292
|
+
totalParts,
|
|
293
|
+
) / 100;
|
|
294
|
+
onProgressChange?.(totalProgress);
|
|
295
|
+
});
|
|
296
|
+
if (!eTag) {
|
|
297
|
+
throw new EdgeStoreError('Could not get ETag from multipart response');
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
partNumber: part.partNumber,
|
|
301
|
+
eTag,
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Upload the parts in parallel
|
|
306
|
+
const completedParts = await queuedPromises({
|
|
307
|
+
items: parts.map((part) => ({
|
|
308
|
+
part,
|
|
309
|
+
chunk: file.slice(
|
|
310
|
+
(part.partNumber - 1) * partSize,
|
|
311
|
+
part.partNumber * partSize,
|
|
312
|
+
),
|
|
313
|
+
})),
|
|
314
|
+
fn: uploadPart,
|
|
315
|
+
maxParallel: 5,
|
|
316
|
+
maxRetries: 10, // retry 10 times per part
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Complete multipart upload
|
|
320
|
+
const res = await fetch(`${apiPath}/complete-multipart-upload`, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
bucketName,
|
|
324
|
+
uploadId,
|
|
325
|
+
key,
|
|
326
|
+
parts: completedParts,
|
|
327
|
+
}),
|
|
328
|
+
headers: {
|
|
329
|
+
'Content-Type': 'application/json',
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
if (!res.ok) {
|
|
333
|
+
throw new EdgeStoreError('Multi-part upload failed');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function confirmUpload(
|
|
338
|
+
{
|
|
339
|
+
url,
|
|
340
|
+
}: {
|
|
341
|
+
url: string;
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
apiPath,
|
|
345
|
+
bucketName,
|
|
346
|
+
}: {
|
|
347
|
+
apiPath: string;
|
|
348
|
+
bucketName: string;
|
|
349
|
+
},
|
|
350
|
+
) {
|
|
351
|
+
const res = await fetch(`${apiPath}/confirm-upload`, {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
body: JSON.stringify({
|
|
354
|
+
url,
|
|
355
|
+
bucketName,
|
|
356
|
+
}),
|
|
357
|
+
headers: {
|
|
358
|
+
'Content-Type': 'application/json',
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
if (!res.ok) {
|
|
362
|
+
throw new EdgeStoreError('An error occurred');
|
|
363
|
+
}
|
|
364
|
+
return { success: true };
|
|
365
|
+
}
|
|
366
|
+
|
|
224
367
|
async function deleteFile(
|
|
225
368
|
{
|
|
226
369
|
url,
|
|
@@ -250,3 +393,62 @@ async function deleteFile(
|
|
|
250
393
|
}
|
|
251
394
|
return { success: true };
|
|
252
395
|
}
|
|
396
|
+
|
|
397
|
+
async function queuedPromises<TType, TRes>({
|
|
398
|
+
items,
|
|
399
|
+
fn,
|
|
400
|
+
maxParallel,
|
|
401
|
+
maxRetries = 0,
|
|
402
|
+
}: {
|
|
403
|
+
items: TType[];
|
|
404
|
+
fn: (item: TType) => Promise<TRes>;
|
|
405
|
+
maxParallel: number;
|
|
406
|
+
maxRetries?: number;
|
|
407
|
+
}): Promise<TRes[]> {
|
|
408
|
+
const results: TRes[] = new Array(items.length);
|
|
409
|
+
|
|
410
|
+
const executeWithRetry = async (
|
|
411
|
+
func: () => Promise<TRes>,
|
|
412
|
+
retries: number,
|
|
413
|
+
): Promise<TRes> => {
|
|
414
|
+
try {
|
|
415
|
+
return await func();
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (retries > 0) {
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
419
|
+
return executeWithRetry(func, retries - 1);
|
|
420
|
+
} else {
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const semaphore = {
|
|
427
|
+
count: maxParallel,
|
|
428
|
+
async wait() {
|
|
429
|
+
// If we've reached our maximum concurrency or it's the last item, wait
|
|
430
|
+
while (this.count <= 0)
|
|
431
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
432
|
+
this.count--;
|
|
433
|
+
},
|
|
434
|
+
signal() {
|
|
435
|
+
this.count++;
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const tasks: Promise<void>[] = items.map((item, i) =>
|
|
440
|
+
(async () => {
|
|
441
|
+
await semaphore.wait();
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const result = await executeWithRetry(() => fn(item), maxRetries);
|
|
445
|
+
results[i] = result;
|
|
446
|
+
} finally {
|
|
447
|
+
semaphore.signal();
|
|
448
|
+
}
|
|
449
|
+
})(),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
await Promise.all(tasks);
|
|
453
|
+
return results;
|
|
454
|
+
}
|