@better-s3/react 3.1045.2 → 3.1047.0
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/dist/api.d.ts +30 -0
- package/dist/helpers/build-object-key.d.ts +20 -0
- package/dist/helpers/format-eta.d.ts +12 -0
- package/dist/helpers/format-file-size.d.ts +11 -0
- package/dist/helpers/format-speed.d.ts +8 -0
- package/dist/helpers/format-upload-progress.d.ts +17 -0
- package/dist/helpers/get-file-extension.d.ts +13 -0
- package/dist/helpers/index.d.ts +10 -0
- package/dist/helpers/parse-content-disposition.d.ts +18 -0
- package/dist/helpers/speed-tracker.d.ts +17 -0
- package/dist/helpers/truncate-filename.d.ts +9 -0
- package/dist/helpers/validate-file.d.ts +22 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/{use-delete.d.ts → hooks/use-delete.d.ts} +4 -3
- package/dist/{use-download.d.ts → hooks/use-download.d.ts} +5 -10
- package/dist/hooks/use-fetch-download.d.ts +23 -0
- package/dist/hooks/use-multi-upload-controls.d.ts +33 -0
- package/dist/{use-multi-upload.d.ts → hooks/use-multi-upload.d.ts} +4 -3
- package/dist/hooks/use-upload-controls.d.ts +44 -0
- package/dist/hooks/use-upload.d.ts +75 -0
- package/dist/index.d.ts +13 -8
- package/dist/index.js +581 -186
- package/dist/index.js.map +1 -1
- package/dist/internal-helpers.d.ts +9 -0
- package/dist/s3-provider.d.ts +47 -0
- package/dist/store/index.d.ts +3 -0
- package/dist/store/local-storage-store.d.ts +9 -0
- package/dist/store/memory-store.d.ts +14 -0
- package/dist/types/download.d.ts +19 -2
- package/dist/types/error.d.ts +14 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/s3-api.d.ts +158 -0
- package/dist/types/upload-store.d.ts +54 -0
- package/dist/types/upload.d.ts +56 -0
- package/dist/upload/constants.d.ts +1 -1
- package/dist/upload/multipart.d.ts +4 -3
- package/dist/upload/retry.d.ts +2 -1
- package/dist/upload/upload-file.d.ts +5 -1
- package/dist/upload/upload-files.d.ts +1 -1
- package/package.json +3 -6
- package/dist/helpers.d.ts +0 -7
- package/dist/use-fetch-download.d.ts +0 -33
- package/dist/use-upload-controls.d.ts +0 -63
- package/dist/use-upload.d.ts +0 -19
package/dist/index.js
CHANGED
|
@@ -1,48 +1,110 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useCallback } from 'react';
|
|
1
|
+
import { createContext, useContext, useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { jsx } from 'react/jsx-runtime';
|
|
4
3
|
|
|
5
|
-
// src/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
4
|
+
// src/types/error.ts
|
|
5
|
+
var S3UploadError = class extends Error {
|
|
6
|
+
constructor(message, code, statusCode, phase) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "S3UploadError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.statusCode = statusCode;
|
|
11
|
+
this.phase = phase;
|
|
14
12
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/api.ts
|
|
16
|
+
function createS3Client(input) {
|
|
17
|
+
return input;
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
var createS3Api = createS3Client;
|
|
20
|
+
var S3Context = createContext(null);
|
|
21
|
+
function S3Provider({
|
|
22
|
+
client,
|
|
23
|
+
children
|
|
24
|
+
}) {
|
|
25
|
+
return /* @__PURE__ */ jsx(S3Context.Provider, { value: client, children });
|
|
26
|
+
}
|
|
27
|
+
function useS3Client() {
|
|
28
|
+
const ctx = useContext(S3Context);
|
|
29
|
+
if (!ctx) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"[better-s3] No S3Api client found. Either wrap your app with <S3Provider client={...}> or pass `api` directly to the hook."
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return ctx;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/store/local-storage-store.ts
|
|
38
|
+
var STORAGE_PREFIX = "better-s3:upload:";
|
|
39
|
+
function createLocalStorageStore() {
|
|
40
|
+
return {
|
|
41
|
+
get(key, fileSize) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = localStorage.getItem(STORAGE_PREFIX + key);
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
const stored = JSON.parse(raw);
|
|
46
|
+
return stored.fileSize === fileSize ? stored : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
set(upload) {
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(
|
|
54
|
+
STORAGE_PREFIX + upload.key,
|
|
55
|
+
JSON.stringify(upload)
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
delete(key) {
|
|
61
|
+
try {
|
|
62
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/store/memory-store.ts
|
|
70
|
+
function createMemoryStore() {
|
|
71
|
+
const map = /* @__PURE__ */ new Map();
|
|
72
|
+
return {
|
|
73
|
+
get(key, fileSize) {
|
|
74
|
+
const stored = map.get(key);
|
|
75
|
+
if (!stored) return null;
|
|
76
|
+
return stored.fileSize === fileSize ? stored : null;
|
|
77
|
+
},
|
|
78
|
+
set(upload) {
|
|
79
|
+
map.set(upload.key, upload);
|
|
80
|
+
},
|
|
81
|
+
delete(key) {
|
|
82
|
+
map.delete(key);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
25
85
|
}
|
|
26
86
|
|
|
27
87
|
// src/upload/constants.ts
|
|
28
|
-
var DEFAULT_MULTIPART_THRESHOLD =
|
|
29
|
-
var DEFAULT_PART_SIZE =
|
|
88
|
+
var DEFAULT_MULTIPART_THRESHOLD = 50 * 1024 * 1024;
|
|
89
|
+
var DEFAULT_PART_SIZE = 5 * 1024 * 1024;
|
|
30
90
|
var MAX_RETRIES = 3;
|
|
31
91
|
var RETRY_BASE_DELAY = 1e3;
|
|
32
|
-
var DEFAULT_CONCURRENT_PARTS =
|
|
92
|
+
var DEFAULT_CONCURRENT_PARTS = 2;
|
|
33
93
|
var DEFAULT_CONCURRENT_FILES = 2;
|
|
34
94
|
|
|
35
95
|
// src/upload/retry.ts
|
|
36
|
-
async function withRetry(fn,
|
|
96
|
+
async function withRetry(fn, retryConfig, signal) {
|
|
97
|
+
const maxRetries = retryConfig?.maxRetries ?? MAX_RETRIES;
|
|
98
|
+
const baseDelay = retryConfig?.baseDelay ?? RETRY_BASE_DELAY;
|
|
37
99
|
let lastError;
|
|
38
|
-
for (let attempt = 0; attempt <=
|
|
100
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
39
101
|
try {
|
|
40
102
|
return await fn();
|
|
41
103
|
} catch (err) {
|
|
42
104
|
if (err.name === "AbortError") throw err;
|
|
43
105
|
lastError = err;
|
|
44
|
-
if (attempt <
|
|
45
|
-
const delay =
|
|
106
|
+
if (attempt < maxRetries) {
|
|
107
|
+
const delay = baseDelay * 2 ** attempt;
|
|
46
108
|
await new Promise((r) => setTimeout(r, delay));
|
|
47
109
|
if (signal?.aborted)
|
|
48
110
|
throw new DOMException("Upload aborted", "AbortError");
|
|
@@ -177,22 +239,69 @@ function uploadPart(blob, presignedUrl, partLoaded, totalSize, reportProgress, s
|
|
|
177
239
|
}
|
|
178
240
|
|
|
179
241
|
// src/upload/multipart.ts
|
|
180
|
-
|
|
242
|
+
function resolvePartSize(partIndex, totalParts, partSize, fileSize) {
|
|
243
|
+
return partIndex === totalParts - 1 ? fileSize - partIndex * partSize : partSize;
|
|
244
|
+
}
|
|
245
|
+
async function uploadMultipart(api, file, objectKey, partSize, concurrentParts, onProgress, signal, requestOptions, retryConfig, uploadStore, onPartUpload, onMultipartInit) {
|
|
246
|
+
const bucket = requestOptions?.bucket;
|
|
181
247
|
const contentType = requestOptions?.contentType ?? file.type;
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
248
|
+
const store = uploadStore != null && uploadStore !== false ? uploadStore : null;
|
|
249
|
+
let uploadId;
|
|
250
|
+
let key;
|
|
251
|
+
const completedPartNumbers = /* @__PURE__ */ new Set();
|
|
252
|
+
const existing = store ? await store.get(objectKey, file.size) : null;
|
|
253
|
+
if (existing) {
|
|
254
|
+
try {
|
|
255
|
+
const { parts: parts2 } = await api.multipart.listParts({
|
|
256
|
+
key: existing.key,
|
|
257
|
+
uploadId: existing.uploadId,
|
|
258
|
+
bucket: bucket ?? existing.bucket
|
|
259
|
+
});
|
|
260
|
+
uploadId = existing.uploadId;
|
|
261
|
+
key = existing.key;
|
|
262
|
+
for (const p of parts2) completedPartNumbers.add(p.partNumber);
|
|
263
|
+
onMultipartInit?.(uploadId, key);
|
|
264
|
+
} catch {
|
|
265
|
+
await store?.delete(objectKey);
|
|
266
|
+
const result = await api.multipart.init({
|
|
267
|
+
key: objectKey,
|
|
268
|
+
contentType,
|
|
269
|
+
fileSize: file.size,
|
|
270
|
+
fileName: requestOptions?.fileName !== null ? requestOptions?.fileName ?? file.name : void 0,
|
|
271
|
+
metadata: requestOptions?.metadata,
|
|
272
|
+
bucket,
|
|
273
|
+
acl: requestOptions?.acl
|
|
274
|
+
});
|
|
275
|
+
uploadId = result.uploadId;
|
|
276
|
+
key = result.key;
|
|
277
|
+
await store?.set({ uploadId, key, fileSize: file.size, bucket });
|
|
278
|
+
onMultipartInit?.(uploadId, key);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
const result = await api.multipart.init({
|
|
282
|
+
key: objectKey,
|
|
283
|
+
contentType,
|
|
284
|
+
fileSize: file.size,
|
|
285
|
+
fileName: requestOptions?.fileName !== null ? requestOptions?.fileName ?? file.name : void 0,
|
|
286
|
+
metadata: requestOptions?.metadata,
|
|
287
|
+
bucket,
|
|
288
|
+
acl: requestOptions?.acl
|
|
289
|
+
});
|
|
290
|
+
uploadId = result.uploadId;
|
|
291
|
+
key = result.key;
|
|
292
|
+
await store?.set({ uploadId, key, fileSize: file.size, bucket });
|
|
293
|
+
onMultipartInit?.(uploadId, key);
|
|
294
|
+
}
|
|
191
295
|
const totalParts = Math.ceil(file.size / partSize);
|
|
192
|
-
const parts = [];
|
|
193
296
|
const partProgress = Array.from(
|
|
194
297
|
{ length: totalParts },
|
|
195
|
-
() => ({
|
|
298
|
+
(_, i) => ({
|
|
299
|
+
bytes: completedPartNumbers.has(i + 1) ? resolvePartSize(i, totalParts, partSize, file.size) : 0
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
const parts = Array.from(
|
|
303
|
+
completedPartNumbers,
|
|
304
|
+
(n) => ({ partNumber: n })
|
|
196
305
|
);
|
|
197
306
|
const reportProgress = () => {
|
|
198
307
|
const loaded = partProgress.reduce((sum, p) => sum + p.bytes, 0);
|
|
@@ -202,6 +311,9 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
|
|
|
202
311
|
percent: Math.round(loaded / file.size * 100)
|
|
203
312
|
});
|
|
204
313
|
};
|
|
314
|
+
if (completedPartNumbers.size > 0) {
|
|
315
|
+
reportProgress();
|
|
316
|
+
}
|
|
205
317
|
try {
|
|
206
318
|
for (let batchStart = 0; batchStart < totalParts; batchStart += concurrentParts) {
|
|
207
319
|
if (signal?.aborted) {
|
|
@@ -210,10 +322,11 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
|
|
|
210
322
|
const batchEnd = Math.min(batchStart + concurrentParts, totalParts);
|
|
211
323
|
const batch = [];
|
|
212
324
|
for (let i = batchStart; i < batchEnd; i++) {
|
|
325
|
+
const partNumber = i + 1;
|
|
326
|
+
if (completedPartNumbers.has(partNumber)) continue;
|
|
213
327
|
const start = i * partSize;
|
|
214
328
|
const end = Math.min(start + partSize, file.size);
|
|
215
329
|
const blob = file.slice(start, end);
|
|
216
|
-
const partNumber = i + 1;
|
|
217
330
|
batch.push(
|
|
218
331
|
withRetry(
|
|
219
332
|
async () => {
|
|
@@ -221,11 +334,8 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
|
|
|
221
334
|
key,
|
|
222
335
|
uploadId,
|
|
223
336
|
partNumber,
|
|
224
|
-
// Pass the exact byte count so the server binds Content-Length
|
|
225
|
-
// into the HMAC signature. S3 will then reject any PUT whose
|
|
226
|
-
// body size differs from blob.size with SignatureDoesNotMatch.
|
|
227
337
|
partSize: blob.size,
|
|
228
|
-
bucket
|
|
338
|
+
bucket
|
|
229
339
|
});
|
|
230
340
|
partProgress[i].bytes = 0;
|
|
231
341
|
await uploadPart(
|
|
@@ -236,9 +346,10 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
|
|
|
236
346
|
reportProgress,
|
|
237
347
|
signal
|
|
238
348
|
);
|
|
349
|
+
onPartUpload?.(partNumber, totalParts);
|
|
239
350
|
return { partNumber };
|
|
240
351
|
},
|
|
241
|
-
|
|
352
|
+
retryConfig,
|
|
242
353
|
signal
|
|
243
354
|
)
|
|
244
355
|
);
|
|
@@ -251,13 +362,16 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
|
|
|
251
362
|
key,
|
|
252
363
|
uploadId,
|
|
253
364
|
parts,
|
|
254
|
-
bucket
|
|
365
|
+
bucket
|
|
255
366
|
});
|
|
367
|
+
await store?.delete(objectKey);
|
|
256
368
|
onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
|
|
257
369
|
return result.eTag;
|
|
258
370
|
} catch (err) {
|
|
259
|
-
|
|
260
|
-
|
|
371
|
+
if (store === null) {
|
|
372
|
+
api.multipart.abort({ key, uploadId, bucket }).catch(() => {
|
|
373
|
+
});
|
|
374
|
+
}
|
|
261
375
|
throw err;
|
|
262
376
|
}
|
|
263
377
|
}
|
|
@@ -267,6 +381,7 @@ async function uploadFile(api, file, objectKey, config = {}, callbacks = {}, sig
|
|
|
267
381
|
const threshold = config.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
|
|
268
382
|
const useMultipart = config.multipart === true && file.size >= threshold;
|
|
269
383
|
const concurrentParts = config.concurrentParts ?? DEFAULT_CONCURRENT_PARTS;
|
|
384
|
+
const partSize = requestOptions?.partSize ?? config.partSize ?? DEFAULT_PART_SIZE;
|
|
270
385
|
const contentType = requestOptions?.contentType ?? file.type;
|
|
271
386
|
let eTag;
|
|
272
387
|
if (useMultipart) {
|
|
@@ -275,11 +390,15 @@ async function uploadFile(api, file, objectKey, config = {}, callbacks = {}, sig
|
|
|
275
390
|
api,
|
|
276
391
|
file,
|
|
277
392
|
objectKey,
|
|
278
|
-
|
|
393
|
+
partSize,
|
|
279
394
|
concurrentParts,
|
|
280
395
|
callbacks.onProgress,
|
|
281
396
|
signal,
|
|
282
|
-
requestOptions
|
|
397
|
+
requestOptions,
|
|
398
|
+
config.retry,
|
|
399
|
+
config.uploadStore,
|
|
400
|
+
callbacks.onPartUpload,
|
|
401
|
+
callbacks.onMultipartInit
|
|
283
402
|
);
|
|
284
403
|
} else {
|
|
285
404
|
await withRetry(
|
|
@@ -313,7 +432,7 @@ async function uploadFile(api, file, objectKey, config = {}, callbacks = {}, sig
|
|
|
313
432
|
);
|
|
314
433
|
}
|
|
315
434
|
},
|
|
316
|
-
|
|
435
|
+
config.retry,
|
|
317
436
|
signal
|
|
318
437
|
);
|
|
319
438
|
callbacks.onPhaseChange?.("finalizing");
|
|
@@ -398,6 +517,143 @@ async function uploadFiles(api, items, config = {}, callbacks = {}, signal, getR
|
|
|
398
517
|
await Promise.all(workers);
|
|
399
518
|
return results;
|
|
400
519
|
}
|
|
520
|
+
|
|
521
|
+
// src/helpers/format-file-size.ts
|
|
522
|
+
function formatFileSize(bytes) {
|
|
523
|
+
if (bytes === 0) return "0 B";
|
|
524
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
525
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
526
|
+
const size = bytes / Math.pow(1024, i);
|
|
527
|
+
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/helpers/validate-file.ts
|
|
531
|
+
function validateFile(file, options) {
|
|
532
|
+
if (options.accept?.length) {
|
|
533
|
+
const allowed = options.accept.some((type) => {
|
|
534
|
+
if (type.startsWith(".")) {
|
|
535
|
+
return file.name.toLowerCase().endsWith(type.toLowerCase());
|
|
536
|
+
}
|
|
537
|
+
if (type.endsWith("/*")) {
|
|
538
|
+
return file.type.startsWith(type.replace("/*", "/"));
|
|
539
|
+
}
|
|
540
|
+
return file.type === type;
|
|
541
|
+
});
|
|
542
|
+
if (!allowed) {
|
|
543
|
+
const ext = file.name.includes(".") ? file.name.split(".").pop() : null;
|
|
544
|
+
return `File type "${ext ? `.${ext}` : file.type || "unknown"}" is not allowed`;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (file.size === 0) {
|
|
548
|
+
return "File is empty";
|
|
549
|
+
}
|
|
550
|
+
if (options.maxFileSize && file.size > options.maxFileSize) {
|
|
551
|
+
const maxMB = (options.maxFileSize / (1024 * 1024)).toFixed(1);
|
|
552
|
+
return `File size exceeds ${maxMB} MB limit`;
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/helpers/parse-content-disposition.ts
|
|
558
|
+
function parseContentDispositionFilename(header, fallback) {
|
|
559
|
+
if (!header) return fallback;
|
|
560
|
+
const starMatch = header.match(/filename\*=UTF-8''([^;,\s]+)/i);
|
|
561
|
+
if (starMatch) {
|
|
562
|
+
try {
|
|
563
|
+
return decodeURIComponent(starMatch[1]);
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const match = header.match(/filename="([^"]+)"/i);
|
|
568
|
+
if (match) return match[1];
|
|
569
|
+
return fallback;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/helpers/format-upload-progress.ts
|
|
573
|
+
function formatUploadProgress(loaded, total, percent) {
|
|
574
|
+
const loadedStr = formatFileSize(loaded);
|
|
575
|
+
if (!total) return loadedStr;
|
|
576
|
+
return `${loadedStr} / ${formatFileSize(total)} (${Math.round(percent)}%)`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/helpers/format-speed.ts
|
|
580
|
+
function formatSpeed(bytesPerSecond) {
|
|
581
|
+
return `${formatFileSize(bytesPerSecond)}/s`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// src/helpers/format-eta.ts
|
|
585
|
+
function formatEta(remainingBytes, bytesPerSecond) {
|
|
586
|
+
if (bytesPerSecond <= 0 || remainingBytes <= 0) return null;
|
|
587
|
+
const totalSeconds = remainingBytes / bytesPerSecond;
|
|
588
|
+
if (totalSeconds < 60) return `${Math.ceil(totalSeconds)}s`;
|
|
589
|
+
const totalMinutes = totalSeconds / 60;
|
|
590
|
+
if (totalMinutes < 60) return `${Math.ceil(totalMinutes)}m`;
|
|
591
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
592
|
+
const mins = Math.ceil(totalMinutes % 60);
|
|
593
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/helpers/build-object-key.ts
|
|
597
|
+
function buildObjectKey(...parts) {
|
|
598
|
+
return parts.map((p) => p.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/helpers/get-file-extension.ts
|
|
602
|
+
function getFileExtension(filename) {
|
|
603
|
+
const dotIndex = filename.lastIndexOf(".");
|
|
604
|
+
if (dotIndex < 1) return "";
|
|
605
|
+
return filename.slice(dotIndex + 1).toLowerCase();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/helpers/truncate-filename.ts
|
|
609
|
+
function truncateFilename(name, maxChars = 26) {
|
|
610
|
+
if (name.length <= maxChars) return name;
|
|
611
|
+
const dotIndex = name.lastIndexOf(".");
|
|
612
|
+
if (dotIndex <= 0) {
|
|
613
|
+
return name.slice(0, maxChars - 1) + "\u2026";
|
|
614
|
+
}
|
|
615
|
+
const ext = name.slice(dotIndex);
|
|
616
|
+
const available = maxChars - ext.length - 1;
|
|
617
|
+
if (available <= 0) {
|
|
618
|
+
return name.slice(0, maxChars - 1) + "\u2026";
|
|
619
|
+
}
|
|
620
|
+
return name.slice(0, available) + "\u2026 " + ext;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/helpers/speed-tracker.ts
|
|
624
|
+
function createSpeedTracker(windowMs = 3e3) {
|
|
625
|
+
const samples = [];
|
|
626
|
+
return {
|
|
627
|
+
/**
|
|
628
|
+
* Record the latest cumulative `loaded` byte count.
|
|
629
|
+
* Returns the current speed in bytes/second (0 until at least 2 samples exist).
|
|
630
|
+
*/
|
|
631
|
+
update(loaded) {
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
samples.push({ t: now, loaded });
|
|
634
|
+
const cutoff = now - windowMs;
|
|
635
|
+
while (samples.length > 1 && samples[0].t < cutoff) {
|
|
636
|
+
samples.shift();
|
|
637
|
+
}
|
|
638
|
+
if (samples.length < 2) return 0;
|
|
639
|
+
const oldest = samples[0];
|
|
640
|
+
const newest = samples[samples.length - 1];
|
|
641
|
+
const deltaMs = newest.t - oldest.t;
|
|
642
|
+
const deltaBytes = newest.loaded - oldest.loaded;
|
|
643
|
+
return deltaMs > 0 ? Math.round(deltaBytes / deltaMs * 1e3) : 0;
|
|
644
|
+
},
|
|
645
|
+
reset() {
|
|
646
|
+
samples.length = 0;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function useLiveRef(value) {
|
|
651
|
+
const ref = useRef(value);
|
|
652
|
+
ref.current = value;
|
|
653
|
+
return ref;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/hooks/use-upload.ts
|
|
401
657
|
var INITIAL_PROGRESS = { loaded: 0, total: 0, percent: 0 };
|
|
402
658
|
var INITIAL_STATE = {
|
|
403
659
|
phase: "idle",
|
|
@@ -409,9 +665,15 @@ var INITIAL_STATE = {
|
|
|
409
665
|
};
|
|
410
666
|
function useUpload(options) {
|
|
411
667
|
const [state, setState] = useState(INITIAL_STATE);
|
|
412
|
-
const
|
|
413
|
-
|
|
668
|
+
const contextApi = useContext(S3Context);
|
|
669
|
+
const optsRef = useLiveRef(options);
|
|
670
|
+
const apiRef = useLiveRef(contextApi);
|
|
414
671
|
const abortRef = useRef(null);
|
|
672
|
+
const activeUploadRef = useRef(null);
|
|
673
|
+
const detachingRef = useRef(false);
|
|
674
|
+
const speedTrackerRef = useRef(createSpeedTracker());
|
|
675
|
+
const lastSpeedRef = useRef(void 0);
|
|
676
|
+
const lastSpeedUpdateRef = useRef(0);
|
|
415
677
|
const upload = useCallback(
|
|
416
678
|
async (file, objectKey, requestOptions) => {
|
|
417
679
|
setState({
|
|
@@ -420,7 +682,12 @@ function useUpload(options) {
|
|
|
420
682
|
fileName: file.name,
|
|
421
683
|
fileSize: file.size
|
|
422
684
|
});
|
|
423
|
-
const opts =
|
|
685
|
+
const opts = optsRef.current;
|
|
686
|
+
const api = opts.api ?? apiRef.current;
|
|
687
|
+
if (!api)
|
|
688
|
+
throw new Error(
|
|
689
|
+
"[better-s3] No S3Api client found. Pass `api` to useUpload or wrap with <S3Provider>."
|
|
690
|
+
);
|
|
424
691
|
const validationError = validateFile(file, {
|
|
425
692
|
accept: opts.accept,
|
|
426
693
|
maxFileSize: opts.maxFileSize
|
|
@@ -442,26 +709,55 @@ function useUpload(options) {
|
|
|
442
709
|
return;
|
|
443
710
|
}
|
|
444
711
|
}
|
|
712
|
+
speedTrackerRef.current.reset();
|
|
713
|
+
lastSpeedRef.current = void 0;
|
|
714
|
+
lastSpeedUpdateRef.current = 0;
|
|
445
715
|
setState((s) => ({ ...s, phase: "presigning" }));
|
|
446
716
|
opts.onUploadStart?.(file, objectKey);
|
|
447
717
|
const controller = new AbortController();
|
|
448
718
|
abortRef.current = controller;
|
|
719
|
+
activeUploadRef.current = {
|
|
720
|
+
file,
|
|
721
|
+
objectKey,
|
|
722
|
+
serverKey: objectKey,
|
|
723
|
+
bucket: requestOptions?.bucket,
|
|
724
|
+
requestOptions
|
|
725
|
+
};
|
|
449
726
|
try {
|
|
450
727
|
const result = await uploadFile(
|
|
451
|
-
|
|
728
|
+
api,
|
|
452
729
|
file,
|
|
453
730
|
objectKey,
|
|
454
731
|
{
|
|
455
732
|
multipart: opts.multipart,
|
|
456
733
|
multipartThreshold: opts.multipartThreshold,
|
|
457
|
-
concurrentParts: opts.concurrentParts
|
|
734
|
+
concurrentParts: opts.concurrentParts,
|
|
735
|
+
partSize: opts.partSize,
|
|
736
|
+
retry: opts.retry,
|
|
737
|
+
uploadStore: opts.uploadStore
|
|
458
738
|
},
|
|
459
739
|
{
|
|
460
740
|
onProgress: (progress) => {
|
|
461
|
-
|
|
462
|
-
|
|
741
|
+
const rawSpeed = speedTrackerRef.current.update(progress.loaded);
|
|
742
|
+
const now = Date.now();
|
|
743
|
+
if (rawSpeed > 0 && now - lastSpeedUpdateRef.current >= 500) {
|
|
744
|
+
lastSpeedRef.current = rawSpeed;
|
|
745
|
+
lastSpeedUpdateRef.current = now;
|
|
746
|
+
}
|
|
747
|
+
const speed = lastSpeedRef.current;
|
|
748
|
+
const p = speed ? { ...progress, speed } : progress;
|
|
749
|
+
setState((s) => ({ ...s, progress: p }));
|
|
750
|
+
opts.onProgress?.(file, p);
|
|
463
751
|
},
|
|
464
|
-
onPhaseChange: (phase) => setState((s) => ({ ...s, phase }))
|
|
752
|
+
onPhaseChange: (phase) => setState((s) => ({ ...s, phase })),
|
|
753
|
+
onPartUpload: (partNumber, totalParts) => opts.onPartUpload?.(file, partNumber, totalParts),
|
|
754
|
+
onMultipartInit: (uploadId, serverKey) => {
|
|
755
|
+
if (activeUploadRef.current) {
|
|
756
|
+
activeUploadRef.current.uploadId = uploadId;
|
|
757
|
+
activeUploadRef.current.serverKey = serverKey;
|
|
758
|
+
}
|
|
759
|
+
opts.onMultipartInit?.(file, uploadId);
|
|
760
|
+
}
|
|
465
761
|
},
|
|
466
762
|
controller.signal,
|
|
467
763
|
requestOptions
|
|
@@ -475,6 +771,10 @@ function useUpload(options) {
|
|
|
475
771
|
await opts.onSuccess?.(file, result);
|
|
476
772
|
} catch (err) {
|
|
477
773
|
if (err.name === "AbortError") {
|
|
774
|
+
if (detachingRef.current) {
|
|
775
|
+
detachingRef.current = false;
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
478
778
|
opts.onCancel?.(file);
|
|
479
779
|
setState(INITIAL_STATE);
|
|
480
780
|
return;
|
|
@@ -484,11 +784,34 @@ function useUpload(options) {
|
|
|
484
784
|
opts.onError?.(file, err, "uploading");
|
|
485
785
|
} finally {
|
|
486
786
|
abortRef.current = null;
|
|
787
|
+
activeUploadRef.current = null;
|
|
487
788
|
}
|
|
488
789
|
},
|
|
489
790
|
[]
|
|
490
791
|
);
|
|
491
792
|
const cancel = useCallback(() => {
|
|
793
|
+
const opts = optsRef.current;
|
|
794
|
+
const api = opts.api ?? apiRef.current;
|
|
795
|
+
const active = activeUploadRef.current;
|
|
796
|
+
abortRef.current?.abort();
|
|
797
|
+
if (active && api) {
|
|
798
|
+
const { objectKey, serverKey, uploadId, bucket } = active;
|
|
799
|
+
const store = opts.uploadStore;
|
|
800
|
+
if (store != null && store !== false) {
|
|
801
|
+
void Promise.resolve(store.delete(objectKey)).catch(() => {
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
if (uploadId) {
|
|
805
|
+
api.multipart.abort({ key: serverKey, uploadId, bucket }).catch(() => {
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
setState(INITIAL_STATE);
|
|
810
|
+
}, []);
|
|
811
|
+
const detach = useCallback(() => {
|
|
812
|
+
const active = activeUploadRef.current;
|
|
813
|
+
if (!active) return;
|
|
814
|
+
detachingRef.current = true;
|
|
492
815
|
abortRef.current?.abort();
|
|
493
816
|
setState(INITIAL_STATE);
|
|
494
817
|
}, []);
|
|
@@ -496,7 +819,7 @@ function useUpload(options) {
|
|
|
496
819
|
abortRef.current?.abort();
|
|
497
820
|
setState(INITIAL_STATE);
|
|
498
821
|
}, []);
|
|
499
|
-
return { ...state, upload, cancel, reset };
|
|
822
|
+
return { ...state, upload, cancel, detach, reset };
|
|
500
823
|
}
|
|
501
824
|
var INITIAL_PROGRESS2 = { loaded: 0, total: 0, percent: 0 };
|
|
502
825
|
var INITIAL_STATE2 = {
|
|
@@ -505,19 +828,31 @@ var INITIAL_STATE2 = {
|
|
|
505
828
|
totalProgress: INITIAL_PROGRESS2,
|
|
506
829
|
error: null
|
|
507
830
|
};
|
|
508
|
-
var nextId = 0;
|
|
509
831
|
function generateId() {
|
|
510
|
-
return
|
|
832
|
+
return crypto.randomUUID();
|
|
511
833
|
}
|
|
512
834
|
function useMultiUpload(options) {
|
|
513
835
|
const [state, setState] = useState(INITIAL_STATE2);
|
|
514
|
-
const
|
|
515
|
-
|
|
836
|
+
const contextApi = useContext(S3Context);
|
|
837
|
+
const optsRef = useLiveRef(options);
|
|
838
|
+
const apiRef = useLiveRef(contextApi);
|
|
516
839
|
const abortRef = useRef(null);
|
|
840
|
+
const resettingRef = useRef(false);
|
|
517
841
|
const fileMapRef = useRef(/* @__PURE__ */ new Map());
|
|
842
|
+
const fileSpeedTrackersRef = useRef(/* @__PURE__ */ new Map());
|
|
843
|
+
const totalSpeedTrackerRef = useRef(createSpeedTracker());
|
|
844
|
+
const fileLastSpeedRef = useRef(/* @__PURE__ */ new Map());
|
|
845
|
+
const fileLastSpeedUpdateRef = useRef(/* @__PURE__ */ new Map());
|
|
846
|
+
const lastTotalSpeedRef = useRef(void 0);
|
|
847
|
+
const lastTotalSpeedUpdateRef = useRef(0);
|
|
518
848
|
const upload = useCallback(
|
|
519
849
|
async (files, resolveKey) => {
|
|
520
|
-
const opts =
|
|
850
|
+
const opts = optsRef.current;
|
|
851
|
+
const api = opts.api ?? apiRef.current;
|
|
852
|
+
if (!api)
|
|
853
|
+
throw new Error(
|
|
854
|
+
"[better-s3] No S3Api client found. Pass `api` to useMultiUpload or wrap with <S3Provider>."
|
|
855
|
+
);
|
|
521
856
|
const items = [];
|
|
522
857
|
const fileStates = [];
|
|
523
858
|
const fileMap = /* @__PURE__ */ new Map();
|
|
@@ -578,28 +913,49 @@ function useMultiUpload(options) {
|
|
|
578
913
|
error: null
|
|
579
914
|
});
|
|
580
915
|
opts.onUploadStart?.(files);
|
|
916
|
+
fileSpeedTrackersRef.current.clear();
|
|
917
|
+
for (const item of items) {
|
|
918
|
+
fileSpeedTrackersRef.current.set(item.id, createSpeedTracker());
|
|
919
|
+
}
|
|
920
|
+
totalSpeedTrackerRef.current.reset();
|
|
921
|
+
fileLastSpeedRef.current.clear();
|
|
922
|
+
fileLastSpeedUpdateRef.current.clear();
|
|
923
|
+
lastTotalSpeedRef.current = void 0;
|
|
924
|
+
lastTotalSpeedUpdateRef.current = 0;
|
|
581
925
|
const controller = new AbortController();
|
|
582
926
|
abortRef.current = controller;
|
|
583
927
|
try {
|
|
584
928
|
const results = await uploadFiles(
|
|
585
|
-
|
|
929
|
+
api,
|
|
586
930
|
items,
|
|
587
931
|
{
|
|
588
932
|
multipart: opts.multipart,
|
|
589
933
|
multipartThreshold: opts.multipartThreshold,
|
|
590
934
|
concurrentParts: opts.concurrentParts,
|
|
591
|
-
concurrentFiles: opts.concurrentFiles
|
|
935
|
+
concurrentFiles: opts.concurrentFiles,
|
|
936
|
+
partSize: opts.partSize,
|
|
937
|
+
retry: opts.retry,
|
|
938
|
+
uploadStore: opts.uploadStore
|
|
592
939
|
},
|
|
593
940
|
{
|
|
594
941
|
onFileProgress: (id, progress) => {
|
|
942
|
+
const tracker = fileSpeedTrackersRef.current.get(id);
|
|
943
|
+
const rawSpeed = tracker ? tracker.update(progress.loaded) : 0;
|
|
944
|
+
const now = Date.now();
|
|
945
|
+
if (rawSpeed > 0 && now - (fileLastSpeedUpdateRef.current.get(id) ?? 0) >= 500) {
|
|
946
|
+
fileLastSpeedRef.current.set(id, rawSpeed);
|
|
947
|
+
fileLastSpeedUpdateRef.current.set(id, now);
|
|
948
|
+
}
|
|
949
|
+
const speed = fileLastSpeedRef.current.get(id);
|
|
950
|
+
const p = speed ? { ...progress, speed } : progress;
|
|
595
951
|
setState((s) => ({
|
|
596
952
|
...s,
|
|
597
953
|
files: s.files.map(
|
|
598
|
-
(f) => f.id === id ? { ...f, status: "uploading", progress } : f
|
|
954
|
+
(f) => f.id === id ? { ...f, status: "uploading", progress: p } : f
|
|
599
955
|
)
|
|
600
956
|
}));
|
|
601
957
|
const file = fileMap.get(id);
|
|
602
|
-
if (file) opts.onFileProgress?.(file,
|
|
958
|
+
if (file) opts.onFileProgress?.(file, p);
|
|
603
959
|
},
|
|
604
960
|
onFileSuccess: (id, result) => {
|
|
605
961
|
setState((s) => ({
|
|
@@ -630,8 +986,18 @@ function useMultiUpload(options) {
|
|
|
630
986
|
if (file) opts.onFileError?.(file, error);
|
|
631
987
|
},
|
|
632
988
|
onTotalProgress: (progress) => {
|
|
633
|
-
|
|
634
|
-
|
|
989
|
+
const rawSpeed = totalSpeedTrackerRef.current.update(
|
|
990
|
+
progress.loaded
|
|
991
|
+
);
|
|
992
|
+
const now = Date.now();
|
|
993
|
+
if (rawSpeed > 0 && now - lastTotalSpeedUpdateRef.current >= 1e3) {
|
|
994
|
+
lastTotalSpeedRef.current = rawSpeed;
|
|
995
|
+
lastTotalSpeedUpdateRef.current = now;
|
|
996
|
+
}
|
|
997
|
+
const speed = lastTotalSpeedRef.current;
|
|
998
|
+
const p = speed ? { ...progress, speed } : progress;
|
|
999
|
+
setState((s) => ({ ...s, totalProgress: p }));
|
|
1000
|
+
opts.onProgress?.(p);
|
|
635
1001
|
}
|
|
636
1002
|
},
|
|
637
1003
|
controller.signal,
|
|
@@ -658,7 +1024,8 @@ function useMultiUpload(options) {
|
|
|
658
1024
|
}
|
|
659
1025
|
} catch (err) {
|
|
660
1026
|
if (err.name === "AbortError") {
|
|
661
|
-
opts.onCancel?.();
|
|
1027
|
+
if (!resettingRef.current) opts.onCancel?.();
|
|
1028
|
+
resettingRef.current = false;
|
|
662
1029
|
setState(INITIAL_STATE2);
|
|
663
1030
|
return;
|
|
664
1031
|
}
|
|
@@ -676,90 +1043,43 @@ function useMultiUpload(options) {
|
|
|
676
1043
|
setState(INITIAL_STATE2);
|
|
677
1044
|
}, []);
|
|
678
1045
|
const reset = useCallback(() => {
|
|
1046
|
+
resettingRef.current = true;
|
|
679
1047
|
abortRef.current?.abort();
|
|
680
1048
|
setState(INITIAL_STATE2);
|
|
681
1049
|
}, []);
|
|
682
1050
|
return { ...state, upload, cancel, reset };
|
|
683
1051
|
}
|
|
684
|
-
var INITIAL_PROGRESS3 = { loaded: 0, total: 0, percent: 0 };
|
|
685
1052
|
function useUploadControls(options) {
|
|
686
|
-
const
|
|
687
|
-
const singleOpts = {
|
|
688
|
-
api: options.api,
|
|
689
|
-
accept: options.accept,
|
|
690
|
-
maxFileSize: options.maxFileSize,
|
|
691
|
-
multipart: options.multipart,
|
|
692
|
-
multipartThreshold: options.multipartThreshold,
|
|
693
|
-
concurrentParts: options.concurrentParts,
|
|
694
|
-
beforeUpload: options.beforeUpload,
|
|
695
|
-
onUploadStart: options.onUploadStart,
|
|
696
|
-
onProgress: options.onProgress,
|
|
697
|
-
onSuccess: options.onSuccess,
|
|
698
|
-
onError: options.onError,
|
|
699
|
-
onCancel: options.onCancel
|
|
700
|
-
};
|
|
701
|
-
const multiOpts = {
|
|
702
|
-
api: options.api,
|
|
703
|
-
accept: options.accept,
|
|
704
|
-
maxFileSize: options.maxFileSize,
|
|
705
|
-
maxFiles: options.maxFiles,
|
|
706
|
-
multipart: options.multipart,
|
|
707
|
-
multipartThreshold: options.multipartThreshold,
|
|
708
|
-
concurrentParts: options.concurrentParts,
|
|
709
|
-
concurrentFiles: options.concurrentFiles,
|
|
710
|
-
uploadOptions: options.uploadOptions,
|
|
711
|
-
getUploadOptions: options.getUploadOptions,
|
|
712
|
-
beforeUpload: options.beforeUpload,
|
|
713
|
-
onUploadStart: options.onUploadStart,
|
|
714
|
-
onProgress: options.onProgress,
|
|
715
|
-
onSuccess: options.onSuccess,
|
|
716
|
-
onError: options.onError,
|
|
717
|
-
onCancel: options.onCancel,
|
|
718
|
-
onFileProgress: options.onFileProgress,
|
|
719
|
-
onFileSuccess: options.onFileSuccess,
|
|
720
|
-
onFileError: options.onFileError
|
|
721
|
-
};
|
|
722
|
-
const single = useUpload(singleOpts);
|
|
723
|
-
const multi = useMultiUpload(multiOpts);
|
|
1053
|
+
const single = useUpload(options);
|
|
724
1054
|
const inputRef = useRef(null);
|
|
725
1055
|
const [fileInfo, setFileInfo] = useState(null);
|
|
726
1056
|
const resolveKey = (file) => typeof options.objectKey === "function" ? options.objectKey(file) : options.objectKey;
|
|
727
1057
|
const handleFiles = async (files) => {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
await single.upload(file, resolveKey(file), {
|
|
736
|
-
...options.uploadOptions,
|
|
737
|
-
...options.getUploadOptions?.(file)
|
|
738
|
-
});
|
|
739
|
-
}
|
|
1058
|
+
const file = files?.[0];
|
|
1059
|
+
if (!file) return;
|
|
1060
|
+
setFileInfo({ name: file.name, size: file.size });
|
|
1061
|
+
await single.upload(file, resolveKey(file), {
|
|
1062
|
+
...options.uploadOptions,
|
|
1063
|
+
...options.getUploadOptions?.(file)
|
|
1064
|
+
});
|
|
740
1065
|
};
|
|
741
|
-
const openFilePicker = () => inputRef.current?.click();
|
|
742
|
-
const isUploading = isMulti ? multi.phase === "uploading" : single.phase === "uploading";
|
|
743
1066
|
return {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
totalProgress: isMulti ? multi.totalProgress : INITIAL_PROGRESS3,
|
|
750
|
-
error: isMulti ? multi.error : single.error,
|
|
751
|
-
isUploading,
|
|
1067
|
+
phase: single.phase,
|
|
1068
|
+
fileInfo,
|
|
1069
|
+
progress: single.progress,
|
|
1070
|
+
error: single.error,
|
|
1071
|
+
isUploading: single.phase === "uploading",
|
|
752
1072
|
handleFiles,
|
|
753
|
-
openFilePicker,
|
|
754
|
-
cancel:
|
|
755
|
-
|
|
1073
|
+
openFilePicker: () => inputRef.current?.click(),
|
|
1074
|
+
cancel: single.cancel,
|
|
1075
|
+
detach: single.detach,
|
|
1076
|
+
reset: () => {
|
|
756
1077
|
single.reset();
|
|
757
1078
|
setFileInfo(null);
|
|
758
1079
|
},
|
|
759
1080
|
inputProps: {
|
|
760
1081
|
ref: inputRef,
|
|
761
1082
|
type: "file",
|
|
762
|
-
...isMulti && { multiple: true },
|
|
763
1083
|
accept: options.accept?.join(","),
|
|
764
1084
|
hidden: true,
|
|
765
1085
|
onChange: (e) => {
|
|
@@ -775,7 +1095,49 @@ function useUploadControls(options) {
|
|
|
775
1095
|
onDrop: (e) => {
|
|
776
1096
|
e.preventDefault();
|
|
777
1097
|
e.stopPropagation();
|
|
778
|
-
if (
|
|
1098
|
+
if (single.phase !== "uploading") handleFiles(e.dataTransfer.files);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
function useMultiUploadControls(options) {
|
|
1104
|
+
const multi = useMultiUpload(options);
|
|
1105
|
+
const inputRef = useRef(null);
|
|
1106
|
+
const resolveKey = (file) => typeof options.objectKey === "function" ? options.objectKey(file) : options.objectKey;
|
|
1107
|
+
const handleFiles = async (files) => {
|
|
1108
|
+
if (!files?.length) return;
|
|
1109
|
+
await multi.upload(Array.from(files), resolveKey);
|
|
1110
|
+
};
|
|
1111
|
+
return {
|
|
1112
|
+
phase: multi.phase,
|
|
1113
|
+
files: multi.files,
|
|
1114
|
+
totalProgress: multi.totalProgress,
|
|
1115
|
+
error: multi.error,
|
|
1116
|
+
isUploading: multi.phase === "uploading",
|
|
1117
|
+
handleFiles,
|
|
1118
|
+
openFilePicker: () => inputRef.current?.click(),
|
|
1119
|
+
cancel: multi.cancel,
|
|
1120
|
+
reset: multi.reset,
|
|
1121
|
+
inputProps: {
|
|
1122
|
+
ref: inputRef,
|
|
1123
|
+
type: "file",
|
|
1124
|
+
multiple: true,
|
|
1125
|
+
accept: options.accept?.join(","),
|
|
1126
|
+
hidden: true,
|
|
1127
|
+
onChange: (e) => {
|
|
1128
|
+
handleFiles(e.target.files);
|
|
1129
|
+
e.target.value = "";
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
dropHandlers: {
|
|
1133
|
+
onDragOver: (e) => {
|
|
1134
|
+
e.preventDefault();
|
|
1135
|
+
e.stopPropagation();
|
|
1136
|
+
},
|
|
1137
|
+
onDrop: (e) => {
|
|
1138
|
+
e.preventDefault();
|
|
1139
|
+
e.stopPropagation();
|
|
1140
|
+
if (multi.phase !== "uploading") handleFiles(e.dataTransfer.files);
|
|
779
1141
|
}
|
|
780
1142
|
}
|
|
781
1143
|
};
|
|
@@ -788,13 +1150,19 @@ var INITIAL_STATE3 = {
|
|
|
788
1150
|
};
|
|
789
1151
|
function useDownload(options) {
|
|
790
1152
|
const [state, setState] = useState(INITIAL_STATE3);
|
|
791
|
-
const
|
|
792
|
-
|
|
1153
|
+
const contextApi = useContext(S3Context);
|
|
1154
|
+
const optsRef = useLiveRef(options);
|
|
1155
|
+
const apiRef = useLiveRef(contextApi);
|
|
793
1156
|
const presign = useCallback(async (key, downloadName) => {
|
|
794
|
-
const opts =
|
|
1157
|
+
const opts = optsRef.current;
|
|
1158
|
+
const api = opts.api ?? apiRef.current;
|
|
1159
|
+
if (!api)
|
|
1160
|
+
throw new Error(
|
|
1161
|
+
"[better-s3] No S3Api client found. Pass `api` to useDownload or wrap with <S3Provider>."
|
|
1162
|
+
);
|
|
795
1163
|
setState({ phase: "presigning", error: null, url: null, expiresIn: null });
|
|
796
1164
|
try {
|
|
797
|
-
const result = await
|
|
1165
|
+
const result = await api.download(key, {
|
|
798
1166
|
fileName: downloadName,
|
|
799
1167
|
bucket: opts.bucket
|
|
800
1168
|
});
|
|
@@ -814,7 +1182,7 @@ function useDownload(options) {
|
|
|
814
1182
|
}, []);
|
|
815
1183
|
const download = useCallback(
|
|
816
1184
|
async (key, downloadName) => {
|
|
817
|
-
const opts =
|
|
1185
|
+
const opts = optsRef.current;
|
|
818
1186
|
if (opts.beforeDownload) {
|
|
819
1187
|
const allowed = await opts.beforeDownload(key);
|
|
820
1188
|
if (!allowed) {
|
|
@@ -835,31 +1203,35 @@ function useDownload(options) {
|
|
|
835
1203
|
},
|
|
836
1204
|
[presign]
|
|
837
1205
|
);
|
|
838
|
-
const reset = useCallback(() =>
|
|
839
|
-
setState(INITIAL_STATE3);
|
|
840
|
-
}, []);
|
|
1206
|
+
const reset = useCallback(() => setState(INITIAL_STATE3), []);
|
|
841
1207
|
return { ...state, download, presign, reset };
|
|
842
1208
|
}
|
|
843
|
-
var
|
|
844
|
-
loaded: 0,
|
|
845
|
-
total: 0,
|
|
846
|
-
percent: 0
|
|
847
|
-
};
|
|
1209
|
+
var INITIAL_PROGRESS3 = { loaded: 0, total: 0, percent: 0 };
|
|
848
1210
|
var INITIAL_STATE4 = {
|
|
849
1211
|
phase: "idle",
|
|
850
|
-
progress:
|
|
1212
|
+
progress: INITIAL_PROGRESS3,
|
|
851
1213
|
error: null,
|
|
852
1214
|
fileName: null,
|
|
853
1215
|
fileSize: null
|
|
854
1216
|
};
|
|
855
1217
|
function useFetchDownload(options) {
|
|
856
1218
|
const [state, setState] = useState(INITIAL_STATE4);
|
|
857
|
-
const
|
|
858
|
-
|
|
1219
|
+
const contextApi = useContext(S3Context);
|
|
1220
|
+
const optsRef = useLiveRef(options);
|
|
1221
|
+
const apiRef = useLiveRef(contextApi);
|
|
859
1222
|
const abortRef = useRef(null);
|
|
1223
|
+
const resettingRef = useRef(false);
|
|
1224
|
+
const speedTrackerRef = useRef(createSpeedTracker());
|
|
1225
|
+
const lastSpeedRef = useRef(void 0);
|
|
1226
|
+
const lastSpeedUpdateRef = useRef(0);
|
|
860
1227
|
const download = useCallback(async (key, downloadName) => {
|
|
861
1228
|
const fallback = key.split("/").pop() ?? key;
|
|
862
|
-
const opts =
|
|
1229
|
+
const opts = optsRef.current;
|
|
1230
|
+
const api = opts.api ?? apiRef.current;
|
|
1231
|
+
if (!api)
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
"[better-s3] No S3Api client found. Pass `api` to useFetchDownload or wrap with <S3Provider>."
|
|
1234
|
+
);
|
|
863
1235
|
if (opts.beforeDownload) {
|
|
864
1236
|
const allowed = await opts.beforeDownload(key);
|
|
865
1237
|
if (!allowed) {
|
|
@@ -874,18 +1246,21 @@ function useFetchDownload(options) {
|
|
|
874
1246
|
}
|
|
875
1247
|
setState({
|
|
876
1248
|
phase: "presigning",
|
|
877
|
-
progress:
|
|
1249
|
+
progress: INITIAL_PROGRESS3,
|
|
878
1250
|
error: null,
|
|
879
1251
|
fileName: downloadName ?? null,
|
|
880
1252
|
fileSize: null
|
|
881
1253
|
});
|
|
882
1254
|
try {
|
|
883
|
-
const { url } = await
|
|
1255
|
+
const { url } = await api.download(key, {
|
|
884
1256
|
fileName: downloadName,
|
|
885
1257
|
bucket: opts.bucket
|
|
886
1258
|
});
|
|
887
1259
|
setState((s) => ({ ...s, phase: "downloading" }));
|
|
888
1260
|
opts.onDownloadStart?.(key);
|
|
1261
|
+
speedTrackerRef.current.reset();
|
|
1262
|
+
lastSpeedRef.current = void 0;
|
|
1263
|
+
lastSpeedUpdateRef.current = 0;
|
|
889
1264
|
const controller = new AbortController();
|
|
890
1265
|
abortRef.current = controller;
|
|
891
1266
|
const res = await fetch(url, { signal: controller.signal });
|
|
@@ -914,10 +1289,18 @@ function useFetchDownload(options) {
|
|
|
914
1289
|
chunks.push(value);
|
|
915
1290
|
loaded += value.byteLength;
|
|
916
1291
|
const percent = contentLength > 0 ? Math.round(loaded / contentLength * 100) : 0;
|
|
1292
|
+
const rawSpeed = speedTrackerRef.current.update(loaded);
|
|
1293
|
+
const now = Date.now();
|
|
1294
|
+
if (rawSpeed > 0 && now - lastSpeedUpdateRef.current >= 500) {
|
|
1295
|
+
lastSpeedRef.current = rawSpeed;
|
|
1296
|
+
lastSpeedUpdateRef.current = now;
|
|
1297
|
+
}
|
|
1298
|
+
const speed = lastSpeedRef.current;
|
|
917
1299
|
const progress = {
|
|
918
1300
|
loaded,
|
|
919
1301
|
total: contentLength,
|
|
920
|
-
percent
|
|
1302
|
+
percent,
|
|
1303
|
+
...speed && { speed }
|
|
921
1304
|
};
|
|
922
1305
|
setState((s) => ({ ...s, progress }));
|
|
923
1306
|
opts.onProgress?.(key, progress);
|
|
@@ -938,7 +1321,8 @@ function useFetchDownload(options) {
|
|
|
938
1321
|
await opts.onSuccess?.(key, name ?? fallback);
|
|
939
1322
|
} catch (err) {
|
|
940
1323
|
if (err.name === "AbortError") {
|
|
941
|
-
opts.onCancel?.(key);
|
|
1324
|
+
if (!resettingRef.current) opts.onCancel?.(key);
|
|
1325
|
+
resettingRef.current = false;
|
|
942
1326
|
setState(INITIAL_STATE4);
|
|
943
1327
|
return;
|
|
944
1328
|
}
|
|
@@ -954,6 +1338,7 @@ function useFetchDownload(options) {
|
|
|
954
1338
|
setState(INITIAL_STATE4);
|
|
955
1339
|
}, []);
|
|
956
1340
|
const reset = useCallback(() => {
|
|
1341
|
+
resettingRef.current = true;
|
|
957
1342
|
abortRef.current?.abort();
|
|
958
1343
|
setState(INITIAL_STATE4);
|
|
959
1344
|
}, []);
|
|
@@ -961,56 +1346,66 @@ function useFetchDownload(options) {
|
|
|
961
1346
|
}
|
|
962
1347
|
var INITIAL_STATE5 = {
|
|
963
1348
|
phase: "idle",
|
|
964
|
-
error: null
|
|
1349
|
+
error: null,
|
|
1350
|
+
pendingKey: null
|
|
965
1351
|
};
|
|
966
1352
|
function useDelete(options) {
|
|
967
1353
|
const [state, setState] = useState(INITIAL_STATE5);
|
|
968
|
-
const
|
|
969
|
-
const
|
|
970
|
-
|
|
1354
|
+
const contextApi = useContext(S3Context);
|
|
1355
|
+
const optsRef = useLiveRef(options);
|
|
1356
|
+
const apiRef = useLiveRef(contextApi);
|
|
1357
|
+
const pendingKeyRef = useRef(null);
|
|
971
1358
|
const requestDelete = useCallback((key) => {
|
|
972
|
-
|
|
973
|
-
setState({ phase: "confirming", error: null });
|
|
1359
|
+
pendingKeyRef.current = key;
|
|
1360
|
+
setState({ phase: "confirming", error: null, pendingKey: key });
|
|
974
1361
|
}, []);
|
|
975
1362
|
const confirmDelete = useCallback(async () => {
|
|
976
|
-
|
|
977
|
-
|
|
1363
|
+
const key = pendingKeyRef.current;
|
|
1364
|
+
if (!key) return;
|
|
1365
|
+
const opts = optsRef.current;
|
|
1366
|
+
const api = opts.api ?? apiRef.current;
|
|
1367
|
+
if (!api)
|
|
1368
|
+
throw new Error(
|
|
1369
|
+
"[better-s3] No S3Api client found. Pass `api` to useDelete or wrap with <S3Provider>."
|
|
1370
|
+
);
|
|
978
1371
|
if (opts.beforeDelete) {
|
|
979
|
-
const allowed = await opts.beforeDelete(
|
|
1372
|
+
const allowed = await opts.beforeDelete(key);
|
|
980
1373
|
if (!allowed) {
|
|
981
1374
|
setState({
|
|
982
1375
|
phase: "error",
|
|
983
|
-
error: "Delete blocked by beforeDelete hook"
|
|
1376
|
+
error: "Delete blocked by beforeDelete hook",
|
|
1377
|
+
pendingKey: null
|
|
984
1378
|
});
|
|
985
|
-
opts.onError?.(
|
|
986
|
-
|
|
1379
|
+
opts.onError?.(key, new Error("blocked"), "confirming");
|
|
1380
|
+
pendingKeyRef.current = null;
|
|
987
1381
|
return;
|
|
988
1382
|
}
|
|
989
1383
|
}
|
|
990
|
-
setState({ phase: "deleting", error: null });
|
|
991
|
-
opts.onDeleteStart?.(
|
|
1384
|
+
setState((s) => ({ ...s, phase: "deleting", error: null }));
|
|
1385
|
+
opts.onDeleteStart?.(key);
|
|
992
1386
|
try {
|
|
993
|
-
await
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1387
|
+
await api.delete(key, { bucket: opts.bucket });
|
|
1388
|
+
pendingKeyRef.current = null;
|
|
1389
|
+
setState({ phase: "success", error: null, pendingKey: null });
|
|
1390
|
+
await opts.onSuccess?.(key);
|
|
997
1391
|
} catch (err) {
|
|
998
1392
|
const message = err instanceof Error ? err.message : "Delete failed";
|
|
999
|
-
setState({ phase: "error", error: message });
|
|
1000
|
-
opts.onError?.(
|
|
1393
|
+
setState((s) => ({ ...s, phase: "error", error: message }));
|
|
1394
|
+
opts.onError?.(key, err, "deleting");
|
|
1001
1395
|
}
|
|
1002
|
-
}, [
|
|
1396
|
+
}, []);
|
|
1003
1397
|
const cancelDelete = useCallback(() => {
|
|
1004
|
-
|
|
1398
|
+
pendingKeyRef.current = null;
|
|
1005
1399
|
setState(INITIAL_STATE5);
|
|
1006
1400
|
}, []);
|
|
1007
1401
|
const reset = useCallback(() => {
|
|
1008
|
-
|
|
1402
|
+
pendingKeyRef.current = null;
|
|
1009
1403
|
setState(INITIAL_STATE5);
|
|
1010
1404
|
}, []);
|
|
1011
1405
|
return {
|
|
1012
|
-
|
|
1013
|
-
|
|
1406
|
+
phase: state.phase,
|
|
1407
|
+
error: state.error,
|
|
1408
|
+
pendingKey: state.pendingKey,
|
|
1014
1409
|
requestDelete,
|
|
1015
1410
|
confirmDelete,
|
|
1016
1411
|
cancelDelete,
|
|
@@ -1018,6 +1413,6 @@ function useDelete(options) {
|
|
|
1018
1413
|
};
|
|
1019
1414
|
}
|
|
1020
1415
|
|
|
1021
|
-
export { formatFileSize, uploadFile, uploadFiles, useDelete, useDownload, useFetchDownload, useMultiUpload, useUpload, useUploadControls };
|
|
1416
|
+
export { S3Context, S3Provider, S3UploadError, buildObjectKey, createLocalStorageStore, createMemoryStore, createS3Api as createPresignApi, createS3Api, createS3Client, createSpeedTracker, formatEta, formatFileSize, formatSpeed, formatUploadProgress, getFileExtension, parseContentDispositionFilename, truncateFilename, uploadFile, uploadFiles, useDelete, useDownload, useFetchDownload, useMultiUpload, useMultiUploadControls, useS3Client, useUpload, useUploadControls, validateFile };
|
|
1022
1417
|
//# sourceMappingURL=index.js.map
|
|
1023
1418
|
//# sourceMappingURL=index.js.map
|