@better-s3/react 3.1045.0 → 3.1045.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/dist/index.js +1022 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,2 +1,1023 @@
|
|
|
1
|
-
import {validateFile}from'@better-s3/server';export{createS3Api as createPresignApi,createS3Api,validateFile}from'@better-s3/server';import {useState,useRef,useCallback}from'react';function K(e,a){if(!e)return a;let o=e.match(/filename\*=UTF-8''([^;,\s]+)/i);if(o)try{return decodeURIComponent(o[1])}catch{}let d=e.match(/filename="([^"]+)"/i);return d?d[1]:a}function ee(e){if(e===0)return "0 B";let a=["B","KB","MB","GB","TB"],o=Math.floor(Math.log(e)/Math.log(1024));return `${(e/Math.pow(1024,o)).toFixed(o===0?0:1)} ${a[o]}`}async function R(e,a,o){let d;for(let s=0;s<=a;s++)try{return await e()}catch(g){if(g.name==="AbortError")throw g;if(d=g,s<a){let c=1e3*2**s;if(await new Promise(t=>setTimeout(t,c)),o?.aborted)throw new DOMException("Upload aborted","AbortError")}}throw d}function B(e,a,o,d,s){return new Promise((g,c)=>{let t=new XMLHttpRequest,n=()=>{t.abort(),c(new DOMException("Upload aborted","AbortError"));};s?.addEventListener("abort",n,{once:true}),t.upload.addEventListener("progress",i=>{i.lengthComputable&&d?.({loaded:i.loaded,total:i.total,percent:Math.round(i.loaded/i.total*100)});}),t.addEventListener("load",()=>{s?.removeEventListener("abort",n),t.status>=200&&t.status<300?(d?.({loaded:e.size,total:e.size,percent:100}),g()):c(new Error(`Upload failed: ${t.status} ${t.statusText}`));}),t.addEventListener("error",()=>{s?.removeEventListener("abort",n),c(new Error("Upload failed: network error"));}),t.addEventListener("abort",()=>{s?.removeEventListener("abort",n),c(new DOMException("Upload aborted","AbortError"));});let p=new FormData;for(let[i,r]of Object.entries(o))p.append(i,r);p.append("file",e),t.open("POST",a),t.send(p);})}function q(e,a,o,d,s){return new Promise((g,c)=>{let t=new XMLHttpRequest,n=()=>{t.abort(),c(new DOMException("Upload aborted","AbortError"));};s?.addEventListener("abort",n,{once:true}),t.upload.addEventListener("progress",p=>{p.lengthComputable&&d?.({loaded:p.loaded,total:p.total,percent:Math.round(p.loaded/p.total*100)});}),t.addEventListener("load",()=>{s?.removeEventListener("abort",n),t.status>=200&&t.status<300?(d?.({loaded:e.size,total:e.size,percent:100}),g()):c(new Error(`Upload failed: ${t.status} ${t.statusText}`));}),t.addEventListener("error",()=>{s?.removeEventListener("abort",n),c(new Error("Upload failed: network error"));}),t.addEventListener("abort",()=>{s?.removeEventListener("abort",n),c(new DOMException("Upload aborted","AbortError"));}),t.open("PUT",a);for(let[p,i]of Object.entries(o))t.setRequestHeader(p,i);t.send(e);})}function G(e,a,o,d,s,g){return new Promise((c,t)=>{let n=new XMLHttpRequest,p=()=>{n.abort(),t(new DOMException("Upload aborted","AbortError"));};g?.addEventListener("abort",p,{once:true}),n.upload.addEventListener("progress",i=>{i.lengthComputable&&(o.bytes=i.loaded,s());}),n.addEventListener("load",()=>{g?.removeEventListener("abort",p),n.status>=200&&n.status<300?(o.bytes=e.size,s(),c()):t(new Error(`Part upload failed: ${n.status}`));}),n.addEventListener("error",()=>{g?.removeEventListener("abort",p),t(new Error("Part upload failed: network error"));}),n.addEventListener("abort",()=>{g?.removeEventListener("abort",p),t(new DOMException("Upload aborted","AbortError"));}),n.open("PUT",a),n.send(e);})}async function X(e,a,o,d,s,g,c,t){let n=t?.contentType??a.type,{uploadId:p,key:i}=await e.multipart.init({key:o,contentType:n,fileSize:a.size,fileName:t?.fileName!==null?t?.fileName??a.name:void 0,metadata:t?.metadata,bucket:t?.bucket,acl:t?.acl}),r=Math.ceil(a.size/d),U=[],l=Array.from({length:r},()=>({bytes:0})),u=()=>{let f=l.reduce((m,h)=>m+h.bytes,0);g?.({loaded:f,total:a.size,percent:Math.round(f/a.size*100)});};try{for(let m=0;m<r;m+=s){if(c?.aborted)throw new DOMException("Upload aborted","AbortError");let h=Math.min(m+s,r),P=[];for(let w=m;w<h;w++){let S=w*d,F=Math.min(S+d,a.size),y=a.slice(S,F),v=w+1;P.push(R(async()=>{let{presignedUrl:O}=await e.multipart.signPart({key:i,uploadId:p,partNumber:v,partSize:y.size,bucket:t?.bucket});return l[w].bytes=0,await G(y,O,l[w],a.size,u,c),{partNumber:v}},3,c));}let b=await Promise.all(P);U.push(...b);}U.sort((m,h)=>m.partNumber-h.partNumber);let f=await e.multipart.complete({key:i,uploadId:p,parts:U,bucket:t?.bucket});return g?.({loaded:a.size,total:a.size,percent:100}),f.eTag}catch(f){throw e.multipart.abort({key:i,uploadId:p,bucket:t?.bucket}).catch(()=>{}),f}}async function E(e,a,o,d={},s={},g,c){let t=d.multipartThreshold??31457280,n=d.multipart===true&&a.size>=t,p=d.concurrentParts??3,i=c?.contentType??a.type,r;return n?(s.onPhaseChange?.("uploading"),r=await X(e,a,o,10485760,p,s.onProgress,g,c)):(await R(async()=>{s.onPhaseChange?.("presigning");let l=await e.upload({key:o,contentType:i,fileSize:a.size,fileName:c?.fileName!==null?c?.fileName??a.name:void 0,metadata:c?.metadata,bucket:c?.bucket,acl:c?.acl});s.onPhaseChange?.("uploading"),l.method==="put"?await q(a,l.url,l.headers??{},s.onProgress,g):await B(a,l.url,l.fields??{},s.onProgress,g);},3,g),s.onPhaseChange?.("finalizing"),r=(await e.confirm({key:o,bucket:c?.bucket})).eTag),{key:o,eTag:r}}async function x(e,a,o={},d={},s,g){let c=a.map(U=>({...U,status:"pending",progress:{loaded:0,total:U.file.size,percent:0},result:null,error:null})),t=()=>{let U=c.reduce((u,f)=>u+f.progress.loaded,0),l=c.reduce((u,f)=>u+f.progress.total,0);d.onTotalProgress?.({loaded:U,total:l,percent:l>0?Math.round(U/l*100):0});},n=0,p=async()=>{for(;n<c.length;){if(s?.aborted)return;let U=n++,l=c[U];l.status="uploading";try{let u=await E(e,l.file,l.objectKey,o,{onProgress:f=>{l.progress=f,d.onFileProgress?.(l.id,f),t();}},s,g?.(l.file));l.status="success",l.result=u,l.progress={loaded:l.file.size,total:l.file.size,percent:100},d.onFileSuccess?.(l.id,u),t();}catch(u){if(u.name==="AbortError"){l.status="error",l.error="Upload cancelled";return}let f=u instanceof Error?u.message:"Upload failed";l.status="error",l.error=f,d.onFileError?.(l.id,f),t();}}},i=o.concurrentFiles??2,r=Array.from({length:Math.min(i,a.length)},()=>p());return await Promise.all(r),c}var se={loaded:0,total:0,percent:0},D={phase:"idle",progress:se,error:null,result:null,fileName:null,fileSize:null};function C(e){let[a,o]=useState(D),d=useRef(e);d.current=e;let s=useRef(null),g=useCallback(async(n,p,i)=>{o({...D,phase:"validating",fileName:n.name,fileSize:n.size});let r=d.current,U=validateFile(n,{accept:r.accept,maxFileSize:r.maxFileSize});if(U){o(u=>({...u,phase:"error",error:U})),r.onError?.(n,new Error(U),"validating");return}if(r.beforeUpload&&!await r.beforeUpload(n)){o(f=>({...f,phase:"error",error:"Upload blocked by beforeUpload hook"})),r.onError?.(n,new Error("blocked"),"validating");return}o(u=>({...u,phase:"presigning"})),r.onUploadStart?.(n,p);let l=new AbortController;s.current=l;try{let u=await E(r.api,n,p,{multipart:r.multipart,multipartThreshold:r.multipartThreshold,concurrentParts:r.concurrentParts},{onProgress:f=>{o(m=>({...m,progress:f})),r.onProgress?.(n,f);},onPhaseChange:f=>o(m=>({...m,phase:f}))},l.signal,i);o(f=>({...f,phase:"success",result:u,progress:{loaded:n.size,total:n.size,percent:100}})),await r.onSuccess?.(n,u);}catch(u){if(u.name==="AbortError"){r.onCancel?.(n),o(D);return}let f=u instanceof Error?u.message:"Upload failed";o(m=>({...m,phase:"error",error:f})),r.onError?.(n,u,"uploading");}finally{s.current=null;}},[]),c=useCallback(()=>{s.current?.abort(),o(D);},[]),t=useCallback(()=>{s.current?.abort(),o(D);},[]);return {...a,upload:g,cancel:c,reset:t}}var ae={loaded:0,total:0,percent:0},A={phase:"idle",files:[],totalProgress:ae,error:null},ie=0;function pe(){return `file-${++ie}`}function L(e){let[a,o]=useState(A),d=useRef(e);d.current=e;let s=useRef(null),g=useRef(new Map),c=useCallback(async(p,i)=>{let r=d.current,U=[],l=[],u=new Map;if(o(m=>({...m,phase:"validating",error:null})),r.maxFiles&&p.length>r.maxFiles){let m=`Too many files. Maximum is ${r.maxFiles}.`;o(h=>({...h,phase:"error",error:m})),r.onError?.(new Error(m));return}for(let m of p){let h=validateFile(m,{accept:r.accept,maxFileSize:r.maxFileSize});if(h){let P=`${m.name}: ${h}`;o(b=>({...b,phase:"error",error:P})),r.onError?.(new Error(P));return}}if(r.beforeUpload&&!await r.beforeUpload(p)){o(h=>({...h,phase:"error",error:"Upload blocked by beforeUpload hook"})),r.onError?.(new Error("blocked"));return}for(let m of p){let h=pe(),P=i(m);U.push({id:h,file:m,objectKey:P}),u.set(h,m),l.push({id:h,fileName:m.name,fileSize:m.size,status:"pending",progress:{loaded:0,total:m.size,percent:0},error:null});}g.current=u,o({phase:"uploading",files:l,totalProgress:{loaded:0,total:p.reduce((m,h)=>m+h.size,0),percent:0},error:null}),r.onUploadStart?.(p);let f=new AbortController;s.current=f;try{let m=await x(r.api,U,{multipart:r.multipart,multipartThreshold:r.multipartThreshold,concurrentParts:r.concurrentParts,concurrentFiles:r.concurrentFiles},{onFileProgress:(b,w)=>{o(F=>({...F,files:F.files.map(y=>y.id===b?{...y,status:"uploading",progress:w}:y)}));let S=u.get(b);S&&r.onFileProgress?.(S,w);},onFileSuccess:(b,w)=>{o(F=>({...F,files:F.files.map(y=>y.id===b?{...y,status:"success",progress:{loaded:y.fileSize,total:y.fileSize,percent:100}}:y)}));let S=u.get(b);S&&r.onFileSuccess?.(S,w);},onFileError:(b,w)=>{o(F=>({...F,files:F.files.map(y=>y.id===b?{...y,status:"error",error:w}:y)}));let S=u.get(b);S&&r.onFileError?.(S,w);},onTotalProgress:b=>{o(w=>({...w,totalProgress:b})),r.onProgress?.(b);}},f.signal,b=>{let w=r.getUploadOptions?.(b);return r.uploadOptions?{...r.uploadOptions,...w}:w??{}}),h=m.some(b=>b.status==="error"),P=m.filter(b=>b.result!==null).map(b=>b.result);o(b=>({...b,phase:h?"error":"success",error:h?`${m.filter(w=>w.status==="error").length} file(s) failed`:null,totalProgress:h?b.totalProgress:{loaded:b.totalProgress.total,total:b.totalProgress.total,percent:100}})),h||await r.onSuccess?.(P);}catch(m){if(m.name==="AbortError"){r.onCancel?.(),o(A);return}let h=m instanceof Error?m.message:"Upload failed";o(P=>({...P,phase:"error",error:h})),r.onError?.(m);}finally{s.current=null;}},[]),t=useCallback(()=>{s.current?.abort(),o(A);},[]),n=useCallback(()=>{s.current?.abort(),o(A);},[]);return {...a,upload:c,cancel:t,reset:n}}var Y={loaded:0,total:0,percent:0};function ce(e){let a=(e.maxFiles??1)>1,o={api:e.api,accept:e.accept,maxFileSize:e.maxFileSize,multipart:e.multipart,multipartThreshold:e.multipartThreshold,concurrentParts:e.concurrentParts,beforeUpload:e.beforeUpload,onUploadStart:e.onUploadStart,onProgress:e.onProgress,onSuccess:e.onSuccess,onError:e.onError,onCancel:e.onCancel},d={api:e.api,accept:e.accept,maxFileSize:e.maxFileSize,maxFiles:e.maxFiles,multipart:e.multipart,multipartThreshold:e.multipartThreshold,concurrentParts:e.concurrentParts,concurrentFiles:e.concurrentFiles,uploadOptions:e.uploadOptions,getUploadOptions:e.getUploadOptions,beforeUpload:e.beforeUpload,onUploadStart:e.onUploadStart,onProgress:e.onProgress,onSuccess:e.onSuccess,onError:e.onError,onCancel:e.onCancel,onFileProgress:e.onFileProgress,onFileSuccess:e.onFileSuccess,onFileError:e.onFileError},s=C(o),g=L(d),c=useRef(null),[t,n]=useState(null),p=l=>typeof e.objectKey=="function"?e.objectKey(l):e.objectKey,i=async l=>{if(a){if(!l?.length)return;await g.upload(Array.from(l),p);}else {let u=l?.[0];if(!u)return;n({name:u.name,size:u.size}),await s.upload(u,p(u),{...e.uploadOptions,...e.getUploadOptions?.(u)});}},r=()=>c.current?.click(),U=a?g.phase==="uploading":s.phase==="uploading";return {mode:a?"multi":"single",phase:a?g.phase:s.phase,fileInfo:a?null:t,progress:a?Y:s.progress,files:a?g.files:[],totalProgress:a?g.totalProgress:Y,error:a?g.error:s.error,isUploading:U,handleFiles:i,openFilePicker:r,cancel:a?g.cancel:s.cancel,reset:a?g.reset:()=>{s.reset(),n(null);},inputProps:{ref:c,type:"file",...a&&{multiple:true},accept:e.accept?.join(","),hidden:true,onChange:l=>{i(l.target.files),l.target.value="";}},dropHandlers:{onDragOver:l=>{l.preventDefault(),l.stopPropagation();},onDrop:l=>{l.preventDefault(),l.stopPropagation(),U||i(l.dataTransfer.files);}}}}var Z={phase:"idle",error:null,url:null,expiresIn:null};function fe(e){let[a,o]=useState(Z),d=useRef(e);d.current=e;let s=useCallback(async(t,n)=>{let p=d.current;o({phase:"presigning",error:null,url:null,expiresIn:null});try{let i=await p.api.download(t,{fileName:n,bucket:p.bucket});return o({phase:"idle",error:null,url:i.url,expiresIn:i.expiresIn}),{url:i.url,expiresIn:i.expiresIn}}catch(i){let r=i instanceof Error?i.message:"Download failed";return o({phase:"error",error:r,url:null,expiresIn:null}),p.onError?.(t,i),null}},[]),g=useCallback(async(t,n)=>{let p=d.current;if(p.beforeDownload&&!await p.beforeDownload(t)){o({phase:"error",error:"Download blocked by beforeDownload hook",url:null,expiresIn:null}),p.onError?.(t,new Error("blocked"));return}let i=await s(t,n);i&&(window.location.href=i.url,p.onInitiated?.(t));},[s]),c=useCallback(()=>{o(Z);},[]);return {...a,download:g,presign:s,reset:c}}var Q={loaded:0,total:0,percent:0},M={phase:"idle",progress:Q,error:null,fileName:null,fileSize:null};function be(e){let[a,o]=useState(M),d=useRef(e);d.current=e;let s=useRef(null),g=useCallback(async(n,p)=>{let i=n.split("/").pop()??n,r=d.current;if(r.beforeDownload&&!await r.beforeDownload(n)){o(l=>({...l,phase:"error",error:"Download blocked by beforeDownload hook"})),r.onError?.(n,new Error("blocked"),"presigning");return}o({phase:"presigning",progress:Q,error:null,fileName:p??null,fileSize:null});try{let{url:U}=await r.api.download(n,{fileName:p,bucket:r.bucket});o(y=>({...y,phase:"downloading"})),r.onDownloadStart?.(n);let l=new AbortController;s.current=l;let u=await fetch(U,{signal:l.signal});if(!u.ok)throw new Error(u.status===404?"File not found":`Download failed (${u.status})`);let f=Number(u.headers.get("content-length")||0),m=p??K(u.headers.get("content-disposition"),i);o(y=>({...y,fileName:m,fileSize:f||null}));let h=u.body?.getReader();if(!h)throw new Error("ReadableStream not supported");let P=[],b=0;for(;;){let{done:y,value:v}=await h.read();if(y)break;P.push(v),b+=v.byteLength;let O=f>0?Math.round(b/f*100):0,$={loaded:b,total:f,percent:O};o(W=>({...W,progress:$})),r.onProgress?.(n,$);}let w=new Blob(P),S=URL.createObjectURL(w),F=document.createElement("a");F.href=S,F.download=m??i,F.click(),URL.revokeObjectURL(S),o(y=>({...y,phase:"success",fileSize:w.size,progress:{loaded:w.size,total:w.size,percent:100}})),await r.onSuccess?.(n,m??i);}catch(U){if(U.name==="AbortError"){r.onCancel?.(n),o(M);return}let l=U instanceof Error?U.message:"Download failed";o(u=>({...u,phase:"error",error:l})),r.onError?.(n,U,"downloading");}finally{s.current=null;}},[]),c=useCallback(()=>{s.current?.abort(),o(M);},[]),t=useCallback(()=>{s.current?.abort(),o(M);},[]);return {...a,download:g,cancel:c,reset:t}}var _={phase:"idle",error:null};function we(e){let[a,o]=useState(_),[d,s]=useState(null),g=useRef(e);g.current=e;let c=useCallback(i=>{s(i),o({phase:"confirming",error:null});},[]),t=useCallback(async()=>{if(!d)return;let i=g.current;if(i.beforeDelete&&!await i.beforeDelete(d)){o({phase:"error",error:"Delete blocked by beforeDelete hook"}),i.onError?.(d,new Error("blocked"),"confirming"),s(null);return}o({phase:"deleting",error:null}),i.onDeleteStart?.(d);try{await i.api.delete(d,{bucket:i.bucket}),o({phase:"success",error:null}),await i.onSuccess?.(d),s(null);}catch(r){let U=r instanceof Error?r.message:"Delete failed";o({phase:"error",error:U}),i.onError?.(d,r,"deleting");}},[d]),n=useCallback(()=>{s(null),o(_);},[]),p=useCallback(()=>{s(null),o(_);},[]);return {...a,pendingKey:d,requestDelete:c,confirmDelete:t,cancelDelete:n,reset:p}}export{ee as formatFileSize,E as uploadFile,x as uploadFiles,we as useDelete,fe as useDownload,be as useFetchDownload,L as useMultiUpload,C as useUpload,ce as useUploadControls};//# sourceMappingURL=index.js.map
|
|
1
|
+
import { validateFile } from '@better-s3/server';
|
|
2
|
+
export { createS3Api as createPresignApi, createS3Api, validateFile } from '@better-s3/server';
|
|
3
|
+
import { useState, useRef, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
// src/helpers.ts
|
|
6
|
+
function parseContentDispositionFilename(header, fallback) {
|
|
7
|
+
if (!header) return fallback;
|
|
8
|
+
const starMatch = header.match(/filename\*=UTF-8''([^;,\s]+)/i);
|
|
9
|
+
if (starMatch) {
|
|
10
|
+
try {
|
|
11
|
+
return decodeURIComponent(starMatch[1]);
|
|
12
|
+
} catch {
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const match = header.match(/filename="([^"]+)"/i);
|
|
16
|
+
if (match) return match[1];
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
function formatFileSize(bytes) {
|
|
20
|
+
if (bytes === 0) return "0 B";
|
|
21
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
22
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
23
|
+
const size = bytes / Math.pow(1024, i);
|
|
24
|
+
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/upload/constants.ts
|
|
28
|
+
var DEFAULT_MULTIPART_THRESHOLD = 30 * 1024 * 1024;
|
|
29
|
+
var DEFAULT_PART_SIZE = 10 * 1024 * 1024;
|
|
30
|
+
var MAX_RETRIES = 3;
|
|
31
|
+
var RETRY_BASE_DELAY = 1e3;
|
|
32
|
+
var DEFAULT_CONCURRENT_PARTS = 3;
|
|
33
|
+
var DEFAULT_CONCURRENT_FILES = 2;
|
|
34
|
+
|
|
35
|
+
// src/upload/retry.ts
|
|
36
|
+
async function withRetry(fn, retries, signal) {
|
|
37
|
+
let lastError;
|
|
38
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
return await fn();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err.name === "AbortError") throw err;
|
|
43
|
+
lastError = err;
|
|
44
|
+
if (attempt < retries) {
|
|
45
|
+
const delay = RETRY_BASE_DELAY * 2 ** attempt;
|
|
46
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
throw new DOMException("Upload aborted", "AbortError");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw lastError;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/upload/simple.ts
|
|
56
|
+
function uploadSimple(file, url, fields, onProgress, signal) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const xhr = new XMLHttpRequest();
|
|
59
|
+
const onAbort = () => {
|
|
60
|
+
xhr.abort();
|
|
61
|
+
reject(new DOMException("Upload aborted", "AbortError"));
|
|
62
|
+
};
|
|
63
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
65
|
+
if (e.lengthComputable) {
|
|
66
|
+
onProgress?.({
|
|
67
|
+
loaded: e.loaded,
|
|
68
|
+
total: e.total,
|
|
69
|
+
percent: Math.round(e.loaded / e.total * 100)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
xhr.addEventListener("load", () => {
|
|
74
|
+
signal?.removeEventListener("abort", onAbort);
|
|
75
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
76
|
+
onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
|
|
77
|
+
resolve();
|
|
78
|
+
} else {
|
|
79
|
+
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
xhr.addEventListener("error", () => {
|
|
83
|
+
signal?.removeEventListener("abort", onAbort);
|
|
84
|
+
reject(new Error("Upload failed: network error"));
|
|
85
|
+
});
|
|
86
|
+
xhr.addEventListener("abort", () => {
|
|
87
|
+
signal?.removeEventListener("abort", onAbort);
|
|
88
|
+
reject(new DOMException("Upload aborted", "AbortError"));
|
|
89
|
+
});
|
|
90
|
+
const formData = new FormData();
|
|
91
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
92
|
+
formData.append(k, v);
|
|
93
|
+
}
|
|
94
|
+
formData.append("file", file);
|
|
95
|
+
xhr.open("POST", url);
|
|
96
|
+
xhr.send(formData);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function uploadPut(file, url, headers, onProgress, signal) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const xhr = new XMLHttpRequest();
|
|
102
|
+
const onAbort = () => {
|
|
103
|
+
xhr.abort();
|
|
104
|
+
reject(new DOMException("Upload aborted", "AbortError"));
|
|
105
|
+
};
|
|
106
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
107
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
108
|
+
if (e.lengthComputable) {
|
|
109
|
+
onProgress?.({
|
|
110
|
+
loaded: e.loaded,
|
|
111
|
+
total: e.total,
|
|
112
|
+
percent: Math.round(e.loaded / e.total * 100)
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
xhr.addEventListener("load", () => {
|
|
117
|
+
signal?.removeEventListener("abort", onAbort);
|
|
118
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
119
|
+
onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
|
|
120
|
+
resolve();
|
|
121
|
+
} else {
|
|
122
|
+
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
xhr.addEventListener("error", () => {
|
|
126
|
+
signal?.removeEventListener("abort", onAbort);
|
|
127
|
+
reject(new Error("Upload failed: network error"));
|
|
128
|
+
});
|
|
129
|
+
xhr.addEventListener("abort", () => {
|
|
130
|
+
signal?.removeEventListener("abort", onAbort);
|
|
131
|
+
reject(new DOMException("Upload aborted", "AbortError"));
|
|
132
|
+
});
|
|
133
|
+
xhr.open("PUT", url);
|
|
134
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
135
|
+
xhr.setRequestHeader(k, v);
|
|
136
|
+
}
|
|
137
|
+
xhr.send(file);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/upload/part.ts
|
|
142
|
+
function uploadPart(blob, presignedUrl, partLoaded, totalSize, reportProgress, signal) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const xhr = new XMLHttpRequest();
|
|
145
|
+
const onAbort = () => {
|
|
146
|
+
xhr.abort();
|
|
147
|
+
reject(new DOMException("Upload aborted", "AbortError"));
|
|
148
|
+
};
|
|
149
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
150
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
151
|
+
if (e.lengthComputable) {
|
|
152
|
+
partLoaded.bytes = e.loaded;
|
|
153
|
+
reportProgress();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
xhr.addEventListener("load", () => {
|
|
157
|
+
signal?.removeEventListener("abort", onAbort);
|
|
158
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
159
|
+
partLoaded.bytes = blob.size;
|
|
160
|
+
reportProgress();
|
|
161
|
+
resolve();
|
|
162
|
+
} else {
|
|
163
|
+
reject(new Error(`Part upload failed: ${xhr.status}`));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
xhr.addEventListener("error", () => {
|
|
167
|
+
signal?.removeEventListener("abort", onAbort);
|
|
168
|
+
reject(new Error("Part upload failed: network error"));
|
|
169
|
+
});
|
|
170
|
+
xhr.addEventListener("abort", () => {
|
|
171
|
+
signal?.removeEventListener("abort", onAbort);
|
|
172
|
+
reject(new DOMException("Upload aborted", "AbortError"));
|
|
173
|
+
});
|
|
174
|
+
xhr.open("PUT", presignedUrl);
|
|
175
|
+
xhr.send(blob);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/upload/multipart.ts
|
|
180
|
+
async function uploadMultipart(api, file, objectKey, partSize, concurrentParts, onProgress, signal, requestOptions) {
|
|
181
|
+
const contentType = requestOptions?.contentType ?? file.type;
|
|
182
|
+
const { uploadId, key } = await api.multipart.init({
|
|
183
|
+
key: objectKey,
|
|
184
|
+
contentType,
|
|
185
|
+
fileSize: file.size,
|
|
186
|
+
fileName: requestOptions?.fileName !== null ? requestOptions?.fileName ?? file.name : void 0,
|
|
187
|
+
metadata: requestOptions?.metadata,
|
|
188
|
+
bucket: requestOptions?.bucket,
|
|
189
|
+
acl: requestOptions?.acl
|
|
190
|
+
});
|
|
191
|
+
const totalParts = Math.ceil(file.size / partSize);
|
|
192
|
+
const parts = [];
|
|
193
|
+
const partProgress = Array.from(
|
|
194
|
+
{ length: totalParts },
|
|
195
|
+
() => ({ bytes: 0 })
|
|
196
|
+
);
|
|
197
|
+
const reportProgress = () => {
|
|
198
|
+
const loaded = partProgress.reduce((sum, p) => sum + p.bytes, 0);
|
|
199
|
+
onProgress?.({
|
|
200
|
+
loaded,
|
|
201
|
+
total: file.size,
|
|
202
|
+
percent: Math.round(loaded / file.size * 100)
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
for (let batchStart = 0; batchStart < totalParts; batchStart += concurrentParts) {
|
|
207
|
+
if (signal?.aborted) {
|
|
208
|
+
throw new DOMException("Upload aborted", "AbortError");
|
|
209
|
+
}
|
|
210
|
+
const batchEnd = Math.min(batchStart + concurrentParts, totalParts);
|
|
211
|
+
const batch = [];
|
|
212
|
+
for (let i = batchStart; i < batchEnd; i++) {
|
|
213
|
+
const start = i * partSize;
|
|
214
|
+
const end = Math.min(start + partSize, file.size);
|
|
215
|
+
const blob = file.slice(start, end);
|
|
216
|
+
const partNumber = i + 1;
|
|
217
|
+
batch.push(
|
|
218
|
+
withRetry(
|
|
219
|
+
async () => {
|
|
220
|
+
const { presignedUrl } = await api.multipart.signPart({
|
|
221
|
+
key,
|
|
222
|
+
uploadId,
|
|
223
|
+
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
|
+
partSize: blob.size,
|
|
228
|
+
bucket: requestOptions?.bucket
|
|
229
|
+
});
|
|
230
|
+
partProgress[i].bytes = 0;
|
|
231
|
+
await uploadPart(
|
|
232
|
+
blob,
|
|
233
|
+
presignedUrl,
|
|
234
|
+
partProgress[i],
|
|
235
|
+
file.size,
|
|
236
|
+
reportProgress,
|
|
237
|
+
signal
|
|
238
|
+
);
|
|
239
|
+
return { partNumber };
|
|
240
|
+
},
|
|
241
|
+
MAX_RETRIES,
|
|
242
|
+
signal
|
|
243
|
+
)
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const batchResults = await Promise.all(batch);
|
|
247
|
+
parts.push(...batchResults);
|
|
248
|
+
}
|
|
249
|
+
parts.sort((a, b) => a.partNumber - b.partNumber);
|
|
250
|
+
const result = await api.multipart.complete({
|
|
251
|
+
key,
|
|
252
|
+
uploadId,
|
|
253
|
+
parts,
|
|
254
|
+
bucket: requestOptions?.bucket
|
|
255
|
+
});
|
|
256
|
+
onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
|
|
257
|
+
return result.eTag;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
api.multipart.abort({ key, uploadId, bucket: requestOptions?.bucket }).catch(() => {
|
|
260
|
+
});
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/upload/upload-file.ts
|
|
266
|
+
async function uploadFile(api, file, objectKey, config = {}, callbacks = {}, signal, requestOptions) {
|
|
267
|
+
const threshold = config.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
|
|
268
|
+
const useMultipart = config.multipart === true && file.size >= threshold;
|
|
269
|
+
const concurrentParts = config.concurrentParts ?? DEFAULT_CONCURRENT_PARTS;
|
|
270
|
+
const contentType = requestOptions?.contentType ?? file.type;
|
|
271
|
+
let eTag;
|
|
272
|
+
if (useMultipart) {
|
|
273
|
+
callbacks.onPhaseChange?.("uploading");
|
|
274
|
+
eTag = await uploadMultipart(
|
|
275
|
+
api,
|
|
276
|
+
file,
|
|
277
|
+
objectKey,
|
|
278
|
+
DEFAULT_PART_SIZE,
|
|
279
|
+
concurrentParts,
|
|
280
|
+
callbacks.onProgress,
|
|
281
|
+
signal,
|
|
282
|
+
requestOptions
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
await withRetry(
|
|
286
|
+
async () => {
|
|
287
|
+
callbacks.onPhaseChange?.("presigning");
|
|
288
|
+
const presign = await api.upload({
|
|
289
|
+
key: objectKey,
|
|
290
|
+
contentType,
|
|
291
|
+
fileSize: file.size,
|
|
292
|
+
fileName: requestOptions?.fileName !== null ? requestOptions?.fileName ?? file.name : void 0,
|
|
293
|
+
metadata: requestOptions?.metadata,
|
|
294
|
+
bucket: requestOptions?.bucket,
|
|
295
|
+
acl: requestOptions?.acl
|
|
296
|
+
});
|
|
297
|
+
callbacks.onPhaseChange?.("uploading");
|
|
298
|
+
if (presign.method === "put") {
|
|
299
|
+
await uploadPut(
|
|
300
|
+
file,
|
|
301
|
+
presign.url,
|
|
302
|
+
presign.headers ?? {},
|
|
303
|
+
callbacks.onProgress,
|
|
304
|
+
signal
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
await uploadSimple(
|
|
308
|
+
file,
|
|
309
|
+
presign.url,
|
|
310
|
+
presign.fields ?? {},
|
|
311
|
+
callbacks.onProgress,
|
|
312
|
+
signal
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
MAX_RETRIES,
|
|
317
|
+
signal
|
|
318
|
+
);
|
|
319
|
+
callbacks.onPhaseChange?.("finalizing");
|
|
320
|
+
const confirmed = await api.confirm({
|
|
321
|
+
key: objectKey,
|
|
322
|
+
bucket: requestOptions?.bucket
|
|
323
|
+
});
|
|
324
|
+
eTag = confirmed.eTag;
|
|
325
|
+
}
|
|
326
|
+
return { key: objectKey, eTag };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/upload/upload-files.ts
|
|
330
|
+
async function uploadFiles(api, items, config = {}, callbacks = {}, signal, getRequestOptions) {
|
|
331
|
+
const results = items.map((item) => ({
|
|
332
|
+
...item,
|
|
333
|
+
status: "pending",
|
|
334
|
+
progress: { loaded: 0, total: item.file.size, percent: 0 },
|
|
335
|
+
result: null,
|
|
336
|
+
error: null
|
|
337
|
+
}));
|
|
338
|
+
const reportTotalProgress = () => {
|
|
339
|
+
const loaded = results.reduce((sum, r) => sum + r.progress.loaded, 0);
|
|
340
|
+
const total = results.reduce((sum, r) => sum + r.progress.total, 0);
|
|
341
|
+
callbacks.onTotalProgress?.({
|
|
342
|
+
loaded,
|
|
343
|
+
total,
|
|
344
|
+
percent: total > 0 ? Math.round(loaded / total * 100) : 0
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
let nextIndex = 0;
|
|
348
|
+
const processNext = async () => {
|
|
349
|
+
while (nextIndex < results.length) {
|
|
350
|
+
if (signal?.aborted) return;
|
|
351
|
+
const idx = nextIndex++;
|
|
352
|
+
const item = results[idx];
|
|
353
|
+
item.status = "uploading";
|
|
354
|
+
try {
|
|
355
|
+
const result = await uploadFile(
|
|
356
|
+
api,
|
|
357
|
+
item.file,
|
|
358
|
+
item.objectKey,
|
|
359
|
+
config,
|
|
360
|
+
{
|
|
361
|
+
onProgress: (progress) => {
|
|
362
|
+
item.progress = progress;
|
|
363
|
+
callbacks.onFileProgress?.(item.id, progress);
|
|
364
|
+
reportTotalProgress();
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
signal,
|
|
368
|
+
getRequestOptions?.(item.file)
|
|
369
|
+
);
|
|
370
|
+
item.status = "success";
|
|
371
|
+
item.result = result;
|
|
372
|
+
item.progress = {
|
|
373
|
+
loaded: item.file.size,
|
|
374
|
+
total: item.file.size,
|
|
375
|
+
percent: 100
|
|
376
|
+
};
|
|
377
|
+
callbacks.onFileSuccess?.(item.id, result);
|
|
378
|
+
reportTotalProgress();
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (err.name === "AbortError") {
|
|
381
|
+
item.status = "error";
|
|
382
|
+
item.error = "Upload cancelled";
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const message = err instanceof Error ? err.message : "Upload failed";
|
|
386
|
+
item.status = "error";
|
|
387
|
+
item.error = message;
|
|
388
|
+
callbacks.onFileError?.(item.id, message);
|
|
389
|
+
reportTotalProgress();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const concurrentFiles = config.concurrentFiles ?? DEFAULT_CONCURRENT_FILES;
|
|
394
|
+
const workers = Array.from(
|
|
395
|
+
{ length: Math.min(concurrentFiles, items.length) },
|
|
396
|
+
() => processNext()
|
|
397
|
+
);
|
|
398
|
+
await Promise.all(workers);
|
|
399
|
+
return results;
|
|
400
|
+
}
|
|
401
|
+
var INITIAL_PROGRESS = { loaded: 0, total: 0, percent: 0 };
|
|
402
|
+
var INITIAL_STATE = {
|
|
403
|
+
phase: "idle",
|
|
404
|
+
progress: INITIAL_PROGRESS,
|
|
405
|
+
error: null,
|
|
406
|
+
result: null,
|
|
407
|
+
fileName: null,
|
|
408
|
+
fileSize: null
|
|
409
|
+
};
|
|
410
|
+
function useUpload(options) {
|
|
411
|
+
const [state, setState] = useState(INITIAL_STATE);
|
|
412
|
+
const optionsRef = useRef(options);
|
|
413
|
+
optionsRef.current = options;
|
|
414
|
+
const abortRef = useRef(null);
|
|
415
|
+
const upload = useCallback(
|
|
416
|
+
async (file, objectKey, requestOptions) => {
|
|
417
|
+
setState({
|
|
418
|
+
...INITIAL_STATE,
|
|
419
|
+
phase: "validating",
|
|
420
|
+
fileName: file.name,
|
|
421
|
+
fileSize: file.size
|
|
422
|
+
});
|
|
423
|
+
const opts = optionsRef.current;
|
|
424
|
+
const validationError = validateFile(file, {
|
|
425
|
+
accept: opts.accept,
|
|
426
|
+
maxFileSize: opts.maxFileSize
|
|
427
|
+
});
|
|
428
|
+
if (validationError) {
|
|
429
|
+
setState((s) => ({ ...s, phase: "error", error: validationError }));
|
|
430
|
+
opts.onError?.(file, new Error(validationError), "validating");
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (opts.beforeUpload) {
|
|
434
|
+
const allowed = await opts.beforeUpload(file);
|
|
435
|
+
if (!allowed) {
|
|
436
|
+
setState((s) => ({
|
|
437
|
+
...s,
|
|
438
|
+
phase: "error",
|
|
439
|
+
error: "Upload blocked by beforeUpload hook"
|
|
440
|
+
}));
|
|
441
|
+
opts.onError?.(file, new Error("blocked"), "validating");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
setState((s) => ({ ...s, phase: "presigning" }));
|
|
446
|
+
opts.onUploadStart?.(file, objectKey);
|
|
447
|
+
const controller = new AbortController();
|
|
448
|
+
abortRef.current = controller;
|
|
449
|
+
try {
|
|
450
|
+
const result = await uploadFile(
|
|
451
|
+
opts.api,
|
|
452
|
+
file,
|
|
453
|
+
objectKey,
|
|
454
|
+
{
|
|
455
|
+
multipart: opts.multipart,
|
|
456
|
+
multipartThreshold: opts.multipartThreshold,
|
|
457
|
+
concurrentParts: opts.concurrentParts
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
onProgress: (progress) => {
|
|
461
|
+
setState((s) => ({ ...s, progress }));
|
|
462
|
+
opts.onProgress?.(file, progress);
|
|
463
|
+
},
|
|
464
|
+
onPhaseChange: (phase) => setState((s) => ({ ...s, phase }))
|
|
465
|
+
},
|
|
466
|
+
controller.signal,
|
|
467
|
+
requestOptions
|
|
468
|
+
);
|
|
469
|
+
setState((s) => ({
|
|
470
|
+
...s,
|
|
471
|
+
phase: "success",
|
|
472
|
+
result,
|
|
473
|
+
progress: { loaded: file.size, total: file.size, percent: 100 }
|
|
474
|
+
}));
|
|
475
|
+
await opts.onSuccess?.(file, result);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if (err.name === "AbortError") {
|
|
478
|
+
opts.onCancel?.(file);
|
|
479
|
+
setState(INITIAL_STATE);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const message = err instanceof Error ? err.message : "Upload failed";
|
|
483
|
+
setState((s) => ({ ...s, phase: "error", error: message }));
|
|
484
|
+
opts.onError?.(file, err, "uploading");
|
|
485
|
+
} finally {
|
|
486
|
+
abortRef.current = null;
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
[]
|
|
490
|
+
);
|
|
491
|
+
const cancel = useCallback(() => {
|
|
492
|
+
abortRef.current?.abort();
|
|
493
|
+
setState(INITIAL_STATE);
|
|
494
|
+
}, []);
|
|
495
|
+
const reset = useCallback(() => {
|
|
496
|
+
abortRef.current?.abort();
|
|
497
|
+
setState(INITIAL_STATE);
|
|
498
|
+
}, []);
|
|
499
|
+
return { ...state, upload, cancel, reset };
|
|
500
|
+
}
|
|
501
|
+
var INITIAL_PROGRESS2 = { loaded: 0, total: 0, percent: 0 };
|
|
502
|
+
var INITIAL_STATE2 = {
|
|
503
|
+
phase: "idle",
|
|
504
|
+
files: [],
|
|
505
|
+
totalProgress: INITIAL_PROGRESS2,
|
|
506
|
+
error: null
|
|
507
|
+
};
|
|
508
|
+
var nextId = 0;
|
|
509
|
+
function generateId() {
|
|
510
|
+
return `file-${++nextId}`;
|
|
511
|
+
}
|
|
512
|
+
function useMultiUpload(options) {
|
|
513
|
+
const [state, setState] = useState(INITIAL_STATE2);
|
|
514
|
+
const optionsRef = useRef(options);
|
|
515
|
+
optionsRef.current = options;
|
|
516
|
+
const abortRef = useRef(null);
|
|
517
|
+
const fileMapRef = useRef(/* @__PURE__ */ new Map());
|
|
518
|
+
const upload = useCallback(
|
|
519
|
+
async (files, resolveKey) => {
|
|
520
|
+
const opts = optionsRef.current;
|
|
521
|
+
const items = [];
|
|
522
|
+
const fileStates = [];
|
|
523
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
524
|
+
setState((s) => ({ ...s, phase: "validating", error: null }));
|
|
525
|
+
if (opts.maxFiles && files.length > opts.maxFiles) {
|
|
526
|
+
const msg = `Too many files. Maximum is ${opts.maxFiles}.`;
|
|
527
|
+
setState((s) => ({ ...s, phase: "error", error: msg }));
|
|
528
|
+
opts.onError?.(new Error(msg));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
for (const file of files) {
|
|
532
|
+
const validationError = validateFile(file, {
|
|
533
|
+
accept: opts.accept,
|
|
534
|
+
maxFileSize: opts.maxFileSize
|
|
535
|
+
});
|
|
536
|
+
if (validationError) {
|
|
537
|
+
const msg = `${file.name}: ${validationError}`;
|
|
538
|
+
setState((s) => ({ ...s, phase: "error", error: msg }));
|
|
539
|
+
opts.onError?.(new Error(msg));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (opts.beforeUpload) {
|
|
544
|
+
const allowed = await opts.beforeUpload(files);
|
|
545
|
+
if (!allowed) {
|
|
546
|
+
setState((s) => ({
|
|
547
|
+
...s,
|
|
548
|
+
phase: "error",
|
|
549
|
+
error: "Upload blocked by beforeUpload hook"
|
|
550
|
+
}));
|
|
551
|
+
opts.onError?.(new Error("blocked"));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (const file of files) {
|
|
556
|
+
const id = generateId();
|
|
557
|
+
const objectKey = resolveKey(file);
|
|
558
|
+
items.push({ id, file, objectKey });
|
|
559
|
+
fileMap.set(id, file);
|
|
560
|
+
fileStates.push({
|
|
561
|
+
id,
|
|
562
|
+
fileName: file.name,
|
|
563
|
+
fileSize: file.size,
|
|
564
|
+
status: "pending",
|
|
565
|
+
progress: { loaded: 0, total: file.size, percent: 0 },
|
|
566
|
+
error: null
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
fileMapRef.current = fileMap;
|
|
570
|
+
setState({
|
|
571
|
+
phase: "uploading",
|
|
572
|
+
files: fileStates,
|
|
573
|
+
totalProgress: {
|
|
574
|
+
loaded: 0,
|
|
575
|
+
total: files.reduce((s, f) => s + f.size, 0),
|
|
576
|
+
percent: 0
|
|
577
|
+
},
|
|
578
|
+
error: null
|
|
579
|
+
});
|
|
580
|
+
opts.onUploadStart?.(files);
|
|
581
|
+
const controller = new AbortController();
|
|
582
|
+
abortRef.current = controller;
|
|
583
|
+
try {
|
|
584
|
+
const results = await uploadFiles(
|
|
585
|
+
opts.api,
|
|
586
|
+
items,
|
|
587
|
+
{
|
|
588
|
+
multipart: opts.multipart,
|
|
589
|
+
multipartThreshold: opts.multipartThreshold,
|
|
590
|
+
concurrentParts: opts.concurrentParts,
|
|
591
|
+
concurrentFiles: opts.concurrentFiles
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
onFileProgress: (id, progress) => {
|
|
595
|
+
setState((s) => ({
|
|
596
|
+
...s,
|
|
597
|
+
files: s.files.map(
|
|
598
|
+
(f) => f.id === id ? { ...f, status: "uploading", progress } : f
|
|
599
|
+
)
|
|
600
|
+
}));
|
|
601
|
+
const file = fileMap.get(id);
|
|
602
|
+
if (file) opts.onFileProgress?.(file, progress);
|
|
603
|
+
},
|
|
604
|
+
onFileSuccess: (id, result) => {
|
|
605
|
+
setState((s) => ({
|
|
606
|
+
...s,
|
|
607
|
+
files: s.files.map(
|
|
608
|
+
(f) => f.id === id ? {
|
|
609
|
+
...f,
|
|
610
|
+
status: "success",
|
|
611
|
+
progress: {
|
|
612
|
+
loaded: f.fileSize,
|
|
613
|
+
total: f.fileSize,
|
|
614
|
+
percent: 100
|
|
615
|
+
}
|
|
616
|
+
} : f
|
|
617
|
+
)
|
|
618
|
+
}));
|
|
619
|
+
const file = fileMap.get(id);
|
|
620
|
+
if (file) opts.onFileSuccess?.(file, result);
|
|
621
|
+
},
|
|
622
|
+
onFileError: (id, error) => {
|
|
623
|
+
setState((s) => ({
|
|
624
|
+
...s,
|
|
625
|
+
files: s.files.map(
|
|
626
|
+
(f) => f.id === id ? { ...f, status: "error", error } : f
|
|
627
|
+
)
|
|
628
|
+
}));
|
|
629
|
+
const file = fileMap.get(id);
|
|
630
|
+
if (file) opts.onFileError?.(file, error);
|
|
631
|
+
},
|
|
632
|
+
onTotalProgress: (progress) => {
|
|
633
|
+
setState((s) => ({ ...s, totalProgress: progress }));
|
|
634
|
+
opts.onProgress?.(progress);
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
controller.signal,
|
|
638
|
+
(file) => {
|
|
639
|
+
const perFile = opts.getUploadOptions?.(file);
|
|
640
|
+
if (!opts.uploadOptions) return perFile ?? {};
|
|
641
|
+
return { ...opts.uploadOptions, ...perFile };
|
|
642
|
+
}
|
|
643
|
+
);
|
|
644
|
+
const hasErrors = results.some((r) => r.status === "error");
|
|
645
|
+
const successResults = results.filter((r) => r.result !== null).map((r) => r.result);
|
|
646
|
+
setState((s) => ({
|
|
647
|
+
...s,
|
|
648
|
+
phase: hasErrors ? "error" : "success",
|
|
649
|
+
error: hasErrors ? `${results.filter((r) => r.status === "error").length} file(s) failed` : null,
|
|
650
|
+
totalProgress: hasErrors ? s.totalProgress : {
|
|
651
|
+
loaded: s.totalProgress.total,
|
|
652
|
+
total: s.totalProgress.total,
|
|
653
|
+
percent: 100
|
|
654
|
+
}
|
|
655
|
+
}));
|
|
656
|
+
if (!hasErrors) {
|
|
657
|
+
await opts.onSuccess?.(successResults);
|
|
658
|
+
}
|
|
659
|
+
} catch (err) {
|
|
660
|
+
if (err.name === "AbortError") {
|
|
661
|
+
opts.onCancel?.();
|
|
662
|
+
setState(INITIAL_STATE2);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const message = err instanceof Error ? err.message : "Upload failed";
|
|
666
|
+
setState((s) => ({ ...s, phase: "error", error: message }));
|
|
667
|
+
opts.onError?.(err);
|
|
668
|
+
} finally {
|
|
669
|
+
abortRef.current = null;
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
[]
|
|
673
|
+
);
|
|
674
|
+
const cancel = useCallback(() => {
|
|
675
|
+
abortRef.current?.abort();
|
|
676
|
+
setState(INITIAL_STATE2);
|
|
677
|
+
}, []);
|
|
678
|
+
const reset = useCallback(() => {
|
|
679
|
+
abortRef.current?.abort();
|
|
680
|
+
setState(INITIAL_STATE2);
|
|
681
|
+
}, []);
|
|
682
|
+
return { ...state, upload, cancel, reset };
|
|
683
|
+
}
|
|
684
|
+
var INITIAL_PROGRESS3 = { loaded: 0, total: 0, percent: 0 };
|
|
685
|
+
function useUploadControls(options) {
|
|
686
|
+
const isMulti = (options.maxFiles ?? 1) > 1;
|
|
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);
|
|
724
|
+
const inputRef = useRef(null);
|
|
725
|
+
const [fileInfo, setFileInfo] = useState(null);
|
|
726
|
+
const resolveKey = (file) => typeof options.objectKey === "function" ? options.objectKey(file) : options.objectKey;
|
|
727
|
+
const handleFiles = async (files) => {
|
|
728
|
+
if (isMulti) {
|
|
729
|
+
if (!files?.length) return;
|
|
730
|
+
await multi.upload(Array.from(files), resolveKey);
|
|
731
|
+
} else {
|
|
732
|
+
const file = files?.[0];
|
|
733
|
+
if (!file) return;
|
|
734
|
+
setFileInfo({ name: file.name, size: file.size });
|
|
735
|
+
await single.upload(file, resolveKey(file), {
|
|
736
|
+
...options.uploadOptions,
|
|
737
|
+
...options.getUploadOptions?.(file)
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
const openFilePicker = () => inputRef.current?.click();
|
|
742
|
+
const isUploading = isMulti ? multi.phase === "uploading" : single.phase === "uploading";
|
|
743
|
+
return {
|
|
744
|
+
mode: isMulti ? "multi" : "single",
|
|
745
|
+
phase: isMulti ? multi.phase : single.phase,
|
|
746
|
+
fileInfo: isMulti ? null : fileInfo,
|
|
747
|
+
progress: isMulti ? INITIAL_PROGRESS3 : single.progress,
|
|
748
|
+
files: isMulti ? multi.files : [],
|
|
749
|
+
totalProgress: isMulti ? multi.totalProgress : INITIAL_PROGRESS3,
|
|
750
|
+
error: isMulti ? multi.error : single.error,
|
|
751
|
+
isUploading,
|
|
752
|
+
handleFiles,
|
|
753
|
+
openFilePicker,
|
|
754
|
+
cancel: isMulti ? multi.cancel : single.cancel,
|
|
755
|
+
reset: isMulti ? multi.reset : () => {
|
|
756
|
+
single.reset();
|
|
757
|
+
setFileInfo(null);
|
|
758
|
+
},
|
|
759
|
+
inputProps: {
|
|
760
|
+
ref: inputRef,
|
|
761
|
+
type: "file",
|
|
762
|
+
...isMulti && { multiple: true },
|
|
763
|
+
accept: options.accept?.join(","),
|
|
764
|
+
hidden: true,
|
|
765
|
+
onChange: (e) => {
|
|
766
|
+
handleFiles(e.target.files);
|
|
767
|
+
e.target.value = "";
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
dropHandlers: {
|
|
771
|
+
onDragOver: (e) => {
|
|
772
|
+
e.preventDefault();
|
|
773
|
+
e.stopPropagation();
|
|
774
|
+
},
|
|
775
|
+
onDrop: (e) => {
|
|
776
|
+
e.preventDefault();
|
|
777
|
+
e.stopPropagation();
|
|
778
|
+
if (!isUploading) handleFiles(e.dataTransfer.files);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
var INITIAL_STATE3 = {
|
|
784
|
+
phase: "idle",
|
|
785
|
+
error: null,
|
|
786
|
+
url: null,
|
|
787
|
+
expiresIn: null
|
|
788
|
+
};
|
|
789
|
+
function useDownload(options) {
|
|
790
|
+
const [state, setState] = useState(INITIAL_STATE3);
|
|
791
|
+
const optionsRef = useRef(options);
|
|
792
|
+
optionsRef.current = options;
|
|
793
|
+
const presign = useCallback(async (key, downloadName) => {
|
|
794
|
+
const opts = optionsRef.current;
|
|
795
|
+
setState({ phase: "presigning", error: null, url: null, expiresIn: null });
|
|
796
|
+
try {
|
|
797
|
+
const result = await opts.api.download(key, {
|
|
798
|
+
fileName: downloadName,
|
|
799
|
+
bucket: opts.bucket
|
|
800
|
+
});
|
|
801
|
+
setState({
|
|
802
|
+
phase: "idle",
|
|
803
|
+
error: null,
|
|
804
|
+
url: result.url,
|
|
805
|
+
expiresIn: result.expiresIn
|
|
806
|
+
});
|
|
807
|
+
return { url: result.url, expiresIn: result.expiresIn };
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const message = err instanceof Error ? err.message : "Download failed";
|
|
810
|
+
setState({ phase: "error", error: message, url: null, expiresIn: null });
|
|
811
|
+
opts.onError?.(key, err);
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
}, []);
|
|
815
|
+
const download = useCallback(
|
|
816
|
+
async (key, downloadName) => {
|
|
817
|
+
const opts = optionsRef.current;
|
|
818
|
+
if (opts.beforeDownload) {
|
|
819
|
+
const allowed = await opts.beforeDownload(key);
|
|
820
|
+
if (!allowed) {
|
|
821
|
+
setState({
|
|
822
|
+
phase: "error",
|
|
823
|
+
error: "Download blocked by beforeDownload hook",
|
|
824
|
+
url: null,
|
|
825
|
+
expiresIn: null
|
|
826
|
+
});
|
|
827
|
+
opts.onError?.(key, new Error("blocked"));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
const result = await presign(key, downloadName);
|
|
832
|
+
if (!result) return;
|
|
833
|
+
window.location.href = result.url;
|
|
834
|
+
opts.onInitiated?.(key);
|
|
835
|
+
},
|
|
836
|
+
[presign]
|
|
837
|
+
);
|
|
838
|
+
const reset = useCallback(() => {
|
|
839
|
+
setState(INITIAL_STATE3);
|
|
840
|
+
}, []);
|
|
841
|
+
return { ...state, download, presign, reset };
|
|
842
|
+
}
|
|
843
|
+
var INITIAL_PROGRESS4 = {
|
|
844
|
+
loaded: 0,
|
|
845
|
+
total: 0,
|
|
846
|
+
percent: 0
|
|
847
|
+
};
|
|
848
|
+
var INITIAL_STATE4 = {
|
|
849
|
+
phase: "idle",
|
|
850
|
+
progress: INITIAL_PROGRESS4,
|
|
851
|
+
error: null,
|
|
852
|
+
fileName: null,
|
|
853
|
+
fileSize: null
|
|
854
|
+
};
|
|
855
|
+
function useFetchDownload(options) {
|
|
856
|
+
const [state, setState] = useState(INITIAL_STATE4);
|
|
857
|
+
const optionsRef = useRef(options);
|
|
858
|
+
optionsRef.current = options;
|
|
859
|
+
const abortRef = useRef(null);
|
|
860
|
+
const download = useCallback(async (key, downloadName) => {
|
|
861
|
+
const fallback = key.split("/").pop() ?? key;
|
|
862
|
+
const opts = optionsRef.current;
|
|
863
|
+
if (opts.beforeDownload) {
|
|
864
|
+
const allowed = await opts.beforeDownload(key);
|
|
865
|
+
if (!allowed) {
|
|
866
|
+
setState((s) => ({
|
|
867
|
+
...s,
|
|
868
|
+
phase: "error",
|
|
869
|
+
error: "Download blocked by beforeDownload hook"
|
|
870
|
+
}));
|
|
871
|
+
opts.onError?.(key, new Error("blocked"), "presigning");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
setState({
|
|
876
|
+
phase: "presigning",
|
|
877
|
+
progress: INITIAL_PROGRESS4,
|
|
878
|
+
error: null,
|
|
879
|
+
fileName: downloadName ?? null,
|
|
880
|
+
fileSize: null
|
|
881
|
+
});
|
|
882
|
+
try {
|
|
883
|
+
const { url } = await opts.api.download(key, {
|
|
884
|
+
fileName: downloadName,
|
|
885
|
+
bucket: opts.bucket
|
|
886
|
+
});
|
|
887
|
+
setState((s) => ({ ...s, phase: "downloading" }));
|
|
888
|
+
opts.onDownloadStart?.(key);
|
|
889
|
+
const controller = new AbortController();
|
|
890
|
+
abortRef.current = controller;
|
|
891
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
892
|
+
if (!res.ok) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
res.status === 404 ? "File not found" : `Download failed (${res.status})`
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
const contentLength = Number(res.headers.get("content-length") || 0);
|
|
898
|
+
const name = downloadName ?? parseContentDispositionFilename(
|
|
899
|
+
res.headers.get("content-disposition"),
|
|
900
|
+
fallback
|
|
901
|
+
);
|
|
902
|
+
setState((s) => ({
|
|
903
|
+
...s,
|
|
904
|
+
fileName: name,
|
|
905
|
+
fileSize: contentLength || null
|
|
906
|
+
}));
|
|
907
|
+
const reader = res.body?.getReader();
|
|
908
|
+
if (!reader) throw new Error("ReadableStream not supported");
|
|
909
|
+
const chunks = [];
|
|
910
|
+
let loaded = 0;
|
|
911
|
+
while (true) {
|
|
912
|
+
const { done, value } = await reader.read();
|
|
913
|
+
if (done) break;
|
|
914
|
+
chunks.push(value);
|
|
915
|
+
loaded += value.byteLength;
|
|
916
|
+
const percent = contentLength > 0 ? Math.round(loaded / contentLength * 100) : 0;
|
|
917
|
+
const progress = {
|
|
918
|
+
loaded,
|
|
919
|
+
total: contentLength,
|
|
920
|
+
percent
|
|
921
|
+
};
|
|
922
|
+
setState((s) => ({ ...s, progress }));
|
|
923
|
+
opts.onProgress?.(key, progress);
|
|
924
|
+
}
|
|
925
|
+
const blob = new Blob(chunks);
|
|
926
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
927
|
+
const anchor = document.createElement("a");
|
|
928
|
+
anchor.href = blobUrl;
|
|
929
|
+
anchor.download = name ?? fallback;
|
|
930
|
+
anchor.click();
|
|
931
|
+
URL.revokeObjectURL(blobUrl);
|
|
932
|
+
setState((s) => ({
|
|
933
|
+
...s,
|
|
934
|
+
phase: "success",
|
|
935
|
+
fileSize: blob.size,
|
|
936
|
+
progress: { loaded: blob.size, total: blob.size, percent: 100 }
|
|
937
|
+
}));
|
|
938
|
+
await opts.onSuccess?.(key, name ?? fallback);
|
|
939
|
+
} catch (err) {
|
|
940
|
+
if (err.name === "AbortError") {
|
|
941
|
+
opts.onCancel?.(key);
|
|
942
|
+
setState(INITIAL_STATE4);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const message = err instanceof Error ? err.message : "Download failed";
|
|
946
|
+
setState((s) => ({ ...s, phase: "error", error: message }));
|
|
947
|
+
opts.onError?.(key, err, "downloading");
|
|
948
|
+
} finally {
|
|
949
|
+
abortRef.current = null;
|
|
950
|
+
}
|
|
951
|
+
}, []);
|
|
952
|
+
const cancel = useCallback(() => {
|
|
953
|
+
abortRef.current?.abort();
|
|
954
|
+
setState(INITIAL_STATE4);
|
|
955
|
+
}, []);
|
|
956
|
+
const reset = useCallback(() => {
|
|
957
|
+
abortRef.current?.abort();
|
|
958
|
+
setState(INITIAL_STATE4);
|
|
959
|
+
}, []);
|
|
960
|
+
return { ...state, download, cancel, reset };
|
|
961
|
+
}
|
|
962
|
+
var INITIAL_STATE5 = {
|
|
963
|
+
phase: "idle",
|
|
964
|
+
error: null
|
|
965
|
+
};
|
|
966
|
+
function useDelete(options) {
|
|
967
|
+
const [state, setState] = useState(INITIAL_STATE5);
|
|
968
|
+
const [pendingKey, setPendingKey] = useState(null);
|
|
969
|
+
const optionsRef = useRef(options);
|
|
970
|
+
optionsRef.current = options;
|
|
971
|
+
const requestDelete = useCallback((key) => {
|
|
972
|
+
setPendingKey(key);
|
|
973
|
+
setState({ phase: "confirming", error: null });
|
|
974
|
+
}, []);
|
|
975
|
+
const confirmDelete = useCallback(async () => {
|
|
976
|
+
if (!pendingKey) return;
|
|
977
|
+
const opts = optionsRef.current;
|
|
978
|
+
if (opts.beforeDelete) {
|
|
979
|
+
const allowed = await opts.beforeDelete(pendingKey);
|
|
980
|
+
if (!allowed) {
|
|
981
|
+
setState({
|
|
982
|
+
phase: "error",
|
|
983
|
+
error: "Delete blocked by beforeDelete hook"
|
|
984
|
+
});
|
|
985
|
+
opts.onError?.(pendingKey, new Error("blocked"), "confirming");
|
|
986
|
+
setPendingKey(null);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
setState({ phase: "deleting", error: null });
|
|
991
|
+
opts.onDeleteStart?.(pendingKey);
|
|
992
|
+
try {
|
|
993
|
+
await opts.api.delete(pendingKey, { bucket: opts.bucket });
|
|
994
|
+
setState({ phase: "success", error: null });
|
|
995
|
+
await opts.onSuccess?.(pendingKey);
|
|
996
|
+
setPendingKey(null);
|
|
997
|
+
} catch (err) {
|
|
998
|
+
const message = err instanceof Error ? err.message : "Delete failed";
|
|
999
|
+
setState({ phase: "error", error: message });
|
|
1000
|
+
opts.onError?.(pendingKey, err, "deleting");
|
|
1001
|
+
}
|
|
1002
|
+
}, [pendingKey]);
|
|
1003
|
+
const cancelDelete = useCallback(() => {
|
|
1004
|
+
setPendingKey(null);
|
|
1005
|
+
setState(INITIAL_STATE5);
|
|
1006
|
+
}, []);
|
|
1007
|
+
const reset = useCallback(() => {
|
|
1008
|
+
setPendingKey(null);
|
|
1009
|
+
setState(INITIAL_STATE5);
|
|
1010
|
+
}, []);
|
|
1011
|
+
return {
|
|
1012
|
+
...state,
|
|
1013
|
+
pendingKey,
|
|
1014
|
+
requestDelete,
|
|
1015
|
+
confirmDelete,
|
|
1016
|
+
cancelDelete,
|
|
1017
|
+
reset
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
export { formatFileSize, uploadFile, uploadFiles, useDelete, useDownload, useFetchDownload, useMultiUpload, useUpload, useUploadControls };
|
|
1022
|
+
//# sourceMappingURL=index.js.map
|
|
2
1023
|
//# sourceMappingURL=index.js.map
|