@edgestore/react 0.1.0 → 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 CHANGED
@@ -64,8 +64,8 @@ Now let's initiate our context provider.
64
64
  ```tsx title="src/lib/edgestore.ts"
65
65
  'use client';
66
66
 
67
- import { EdgeStoreRouter } from '../app/api/edgestore/[...edgestore]/route';
68
67
  import { createEdgeStoreProvider } from '@edgestore/react';
68
+ import { type EdgeStoreRouter } from '../app/api/edgestore/[...edgestore]/route';
69
69
 
70
70
  const { EdgeStoreProvider, useEdgeStore } =
71
71
  createEdgeStoreProvider<EdgeStoreRouter>();
@@ -101,11 +101,11 @@ export default function RootLayout({
101
101
  You can use the `useEdgeStore` hook to access typesafe frontend client and use it to upload files.
102
102
 
103
103
  ```tsx {1, 6, 19-28}
104
- import { useEdgeStore } from '../lib/edgestore';
105
104
  import * as React from 'react';
105
+ import { useEdgeStore } from '../lib/edgestore';
106
106
 
107
107
  export default function Page() {
108
- const [file, setFile] = React.useState<File | null>(null);
108
+ const [file, setFile] = React.useState<File>();
109
109
  const { edgestore } = useEdgeStore();
110
110
 
111
111
  return (
@@ -113,7 +113,7 @@ export default function Page() {
113
113
  <input
114
114
  type="file"
115
115
  onChange={(e) => {
116
- setFile(e.target.files?.[0] ?? null);
116
+ setFile(e.target.files?.[0]);
117
117
  }}
118
118
  />
119
119
  <button
@@ -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 becasue of the CDN cache.
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({
@@ -161,5 +161,5 @@ const res = await edgestore.publicFiles.upload({
161
161
  ```tsx
162
162
  await edgestore.publicFiles.delete({
163
163
  url: urlToDelete,
164
- })
164
+ });
165
165
  ```
@@ -1,6 +1,6 @@
1
- import { AnyRouter } from '@edgestore/server/core';
1
+ import { type AnyRouter } from '@edgestore/server/core';
2
2
  import * as React from 'react';
3
- import { BucketFunctions } from './createNextProxy';
3
+ import { type BucketFunctions } from './createNextProxy';
4
4
  type EdgeStoreContextValue<TRouter extends AnyRouter> = {
5
5
  edgestore: BucketFunctions<TRouter>;
6
6
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"contextProvider.d.ts","sourceRoot":"","sources":["../src/contextProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,eAAe,EAAmB,MAAM,mBAAmB,CAAC;AAKrE,KAAK,qBAAqB,CAAC,OAAO,SAAS,SAAS,IAAI;IACtD,SAAS,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC;IACpC;;;OAGG;IACH,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CACjC,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,OAAO,SAAS,SAAS,EAAE,IAAI,CAAC,EAAE;IACxE;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;;kBASa,MAAM,SAAS;QACzB;;;;;;WAMG;;;;EAgCN"}
1
+ {"version":3,"file":"contextProvider.d.ts","sourceRoot":"","sources":["../src/contextProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAK1E,KAAK,qBAAqB,CAAC,OAAO,SAAS,SAAS,IAAI;IACtD,SAAS,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC;IACpC;;;OAGG;IACH,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CACjC,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,OAAO,SAAS,SAAS,EAAE,IAAI,CAAC,EAAE;IACxE;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;;kBASa,MAAM,SAAS;QACzB;;;;;;WAMG;;;;EAgCN"}
@@ -1,6 +1,6 @@
1
1
  /// <reference types="react" />
2
- import { AnyRouter, InferBucketPathObject, InferMetadataObject } from '@edgestore/server/core';
3
- import { z } from 'zod';
2
+ import { type AnyRouter, type InferBucketPathObject, type InferMetadataObject } from '@edgestore/server/core';
3
+ import { type z } from 'zod';
4
4
  export type BucketFunctions<TRouter extends AnyRouter> = {
5
5
  [K in keyof TRouter['buckets']]: {
6
6
  upload: (params: z.infer<TRouter['buckets'][K]['_def']['input']> extends never ? {
@@ -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":";AAAA,OAAO,EACL,SAAS,EACT,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,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,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;CAC3B,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,4BAiCA"}
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 (!json.uploadUrl) {
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
- resolve();
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 (!json.uploadUrl) {
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
- resolve();
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.0",
4
- "description": "The best DX for uploading files from your Next.js app",
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.0",
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.0",
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": "02d8fe7865f94c75d0b09f6725f3ff3471abb549"
74
+ "gitHead": "99519b57aa7a5cbbe5cab16b85a70d437cb87435"
75
75
  }
@@ -1,6 +1,6 @@
1
- import { AnyRouter } from '@edgestore/server/core';
1
+ import { type AnyRouter } from '@edgestore/server/core';
2
2
  import * as React from 'react';
3
- import { BucketFunctions, createNextProxy } from './createNextProxy';
3
+ import { createNextProxy, type BucketFunctions } from './createNextProxy';
4
4
 
5
5
  const DEFAULT_BASE_URL =
6
6
  process.env.NEXT_PUBLIC_EDGE_STORE_BASE_URL ?? 'https://files.edgestore.dev';
@@ -1,9 +1,10 @@
1
+ import { type RequestUploadRes } from '@edgestore/server/adapters';
1
2
  import {
2
- AnyRouter,
3
- InferBucketPathObject,
4
- InferMetadataObject,
3
+ type AnyRouter,
4
+ type InferBucketPathObject,
5
+ type InferMetadataObject,
5
6
  } from '@edgestore/server/core';
6
- import { z } from 'zod';
7
+ import { type z } from 'zod';
7
8
  import EdgeStoreError from './libs/errors/EdgeStoreError';
8
9
 
9
10
  export type BucketFunctions<TRouter extends AnyRouter> = {
@@ -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 (!json.uploadUrl) {
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
- path: json.path,
166
- metadata: json.metadata,
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<void>((resolve, reject) => {
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
- resolve();
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
+ }