uploader-sdk 1.0.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.
@@ -0,0 +1,369 @@
1
+ export class PresignedS3Adapter {
2
+ constructor(config) {
3
+ this.apiBaseUrl = config.apiBaseUrl;
4
+ this.multipartThresholdBytes =
5
+ typeof config.multipartThresholdBytes === 'number' && config.multipartThresholdBytes > 0
6
+ ? config.multipartThresholdBytes
7
+ : 20 * 1024 * 1024;
8
+ this.multipartPartSizeBytes =
9
+ typeof config.multipartPartSizeBytes === 'number' && config.multipartPartSizeBytes >= 5 * 1024 * 1024
10
+ ? config.multipartPartSizeBytes
11
+ : 8 * 1024 * 1024;
12
+ }
13
+ async uploadFile(file, options = {}) {
14
+ const fileId = options.fileId || `${file.name}::${file.lastModified}::${file.size}`;
15
+ const report = typeof options.onProgress === 'function' ? options.onProgress : () => { };
16
+ const signal = options.signal;
17
+ if (file.size >= this.multipartThresholdBytes) {
18
+ return this.uploadMultipart(file, fileId, report, signal);
19
+ }
20
+ return this.uploadSimple(file, report, signal);
21
+ }
22
+ async cleanupUploadSession(fileId, _meta = { reason: 'canceled' }) {
23
+ const session = this.loadSession(fileId);
24
+ if (!session?.key || !session?.uploadId) {
25
+ this.clearSession(fileId);
26
+ return;
27
+ }
28
+ const aborted = await this.abortMultipartWithRetry(session.key, session.uploadId);
29
+ if (aborted) {
30
+ this.clearSession(fileId);
31
+ return;
32
+ }
33
+ // Keep session so cleanup can be retried later if abort endpoint was unreachable.
34
+ throw new Error('Failed to abort multipart session after retries');
35
+ }
36
+ async abortMultipartWithRetry(key, uploadId) {
37
+ const maxAttempts = 3;
38
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
39
+ try {
40
+ const response = await fetch(`${this.apiBaseUrl}/api/multipart/abort`, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ key, uploadId }),
44
+ });
45
+ if (response.ok) {
46
+ return true;
47
+ }
48
+ }
49
+ catch {
50
+ // Network/transient failure; retry below.
51
+ }
52
+ if (attempt < maxAttempts) {
53
+ const delayMs = 250 * 2 ** (attempt - 1);
54
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
55
+ }
56
+ }
57
+ return false;
58
+ }
59
+ async uploadSimple(file, report, signal) {
60
+ report(0);
61
+ const presign = await fetch(`${this.apiBaseUrl}/api/presign`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({
65
+ fileName: file.name,
66
+ contentType: file.type || 'application/octet-stream',
67
+ size: file.size,
68
+ }),
69
+ });
70
+ if (!presign.ok) {
71
+ const data = await presign.json().catch(() => ({}));
72
+ throw new Error(data.error || 'Failed to get signed URL');
73
+ }
74
+ const { assetId, uploadUrl, key } = await presign.json();
75
+ await this.putWithProgress({
76
+ url: uploadUrl,
77
+ body: file,
78
+ contentType: file.type || 'application/octet-stream',
79
+ signal,
80
+ onProgress: (loaded, total) => {
81
+ if (total > 0) {
82
+ report((loaded / total) * 100);
83
+ }
84
+ },
85
+ });
86
+ const verifyResp = await fetch(`${this.apiBaseUrl}/api/verify`, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({
90
+ assetId,
91
+ key,
92
+ size: file.size,
93
+ contentType: file.type || 'application/octet-stream',
94
+ }),
95
+ });
96
+ if (!verifyResp.ok) {
97
+ const data = await verifyResp.json().catch(() => ({}));
98
+ throw new Error(data.error || 'Verification failed');
99
+ }
100
+ const verifyData = await verifyResp.json();
101
+ if (!verifyData.verified) {
102
+ throw new Error('Uploaded object verification failed (HEAD mismatch)');
103
+ }
104
+ const downloadResp = await fetch(`${this.apiBaseUrl}/api/download-url?key=${encodeURIComponent(key)}&assetId=${encodeURIComponent(assetId)}`);
105
+ if (!downloadResp.ok) {
106
+ const data = await downloadResp.json().catch(() => ({}));
107
+ throw new Error(data.error || 'Failed to get download URL');
108
+ }
109
+ const downloadData = await downloadResp.json();
110
+ report(100);
111
+ return {
112
+ url: downloadData.url,
113
+ assetId,
114
+ key,
115
+ verified: true,
116
+ };
117
+ }
118
+ getSessionKey(fileId) {
119
+ return `upload-sdk-mpu:${fileId}`;
120
+ }
121
+ loadSession(fileId) {
122
+ try {
123
+ const raw = localStorage.getItem(this.getSessionKey(fileId));
124
+ return raw ? JSON.parse(raw) : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ saveSession(fileId, session) {
131
+ localStorage.setItem(this.getSessionKey(fileId), JSON.stringify(session));
132
+ }
133
+ clearSession(fileId) {
134
+ localStorage.removeItem(this.getSessionKey(fileId));
135
+ }
136
+ computeUploadedBytes(fileSize, partSize, completedPartNumbers) {
137
+ let bytes = 0;
138
+ for (const partNumber of completedPartNumbers) {
139
+ const start = (partNumber - 1) * partSize;
140
+ const end = Math.min(start + partSize, fileSize);
141
+ bytes += Math.max(0, end - start);
142
+ }
143
+ return bytes;
144
+ }
145
+ async uploadMultipart(file, fileId, report, signal) {
146
+ let session = this.loadSession(fileId);
147
+ if (session &&
148
+ (session.fileName !== file.name || session.size !== file.size || session.lastModified !== file.lastModified)) {
149
+ this.clearSession(fileId);
150
+ session = null;
151
+ }
152
+ if (!session) {
153
+ const startResp = await fetch(`${this.apiBaseUrl}/api/multipart/start`, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json' },
156
+ body: JSON.stringify({
157
+ fileName: file.name,
158
+ contentType: file.type || 'application/octet-stream',
159
+ size: file.size,
160
+ partSize: this.multipartPartSizeBytes,
161
+ }),
162
+ });
163
+ if (!startResp.ok) {
164
+ const data = await startResp.json().catch(() => ({}));
165
+ throw new Error(data.error || 'Failed to start multipart upload');
166
+ }
167
+ const started = await startResp.json();
168
+ session = {
169
+ assetId: started.assetId,
170
+ key: started.key,
171
+ uploadId: started.uploadId,
172
+ partSize: started.partSize,
173
+ totalParts: started.totalParts,
174
+ fileName: file.name,
175
+ size: file.size,
176
+ lastModified: file.lastModified,
177
+ completedParts: [],
178
+ };
179
+ this.saveSession(fileId, session);
180
+ }
181
+ else {
182
+ const listResp = await fetch(`${this.apiBaseUrl}/api/multipart/parts?key=${encodeURIComponent(session.key)}&uploadId=${encodeURIComponent(session.uploadId)}`);
183
+ if (listResp.ok) {
184
+ const listed = await listResp.json();
185
+ const remote = Array.isArray(listed.parts) ? listed.parts : [];
186
+ const partMap = new Map();
187
+ for (const part of session.completedParts || []) {
188
+ partMap.set(part.PartNumber, part);
189
+ }
190
+ for (const part of remote) {
191
+ if (part.PartNumber && part.ETag) {
192
+ partMap.set(part.PartNumber, { PartNumber: part.PartNumber, ETag: part.ETag });
193
+ }
194
+ }
195
+ session.completedParts = Array.from(partMap.values()).sort((a, b) => a.PartNumber - b.PartNumber);
196
+ this.saveSession(fileId, session);
197
+ }
198
+ }
199
+ const completedNumbers = new Set((session.completedParts || []).map((p) => p.PartNumber));
200
+ let uploadedBytes = this.computeUploadedBytes(file.size, session.partSize, completedNumbers);
201
+ report((uploadedBytes / file.size) * 100);
202
+ for (let partNumber = 1; partNumber <= session.totalParts; partNumber += 1) {
203
+ if (completedNumbers.has(partNumber)) {
204
+ continue;
205
+ }
206
+ const signResp = await fetch(`${this.apiBaseUrl}/api/multipart/sign-part`, {
207
+ method: 'POST',
208
+ headers: { 'Content-Type': 'application/json' },
209
+ body: JSON.stringify({
210
+ key: session.key,
211
+ uploadId: session.uploadId,
212
+ partNumber,
213
+ }),
214
+ });
215
+ if (!signResp.ok) {
216
+ const data = await signResp.json().catch(() => ({}));
217
+ throw new Error(data.error || 'Failed to sign multipart part');
218
+ }
219
+ const { uploadUrl } = await signResp.json();
220
+ const start = (partNumber - 1) * session.partSize;
221
+ const end = Math.min(start + session.partSize, file.size);
222
+ const chunk = file.slice(start, end);
223
+ const baseUploaded = uploadedBytes;
224
+ let etag = await this.putWithProgress({
225
+ url: uploadUrl,
226
+ body: chunk,
227
+ signal,
228
+ onProgress: (loaded, total) => {
229
+ const partTotal = total > 0 ? total : chunk.size;
230
+ const progressed = Math.min(partTotal, loaded);
231
+ report(((baseUploaded + progressed) / file.size) * 100);
232
+ },
233
+ });
234
+ if (!etag) {
235
+ etag = await this.waitForPartEtag(session.key, session.uploadId, partNumber);
236
+ }
237
+ if (!etag) {
238
+ throw new Error(`Missing ETag for uploaded part ${partNumber}. Add ETag to S3 CORS ExposeHeaders.`);
239
+ }
240
+ session.completedParts.push({ PartNumber: partNumber, ETag: etag });
241
+ session.completedParts.sort((a, b) => a.PartNumber - b.PartNumber);
242
+ this.saveSession(fileId, session);
243
+ uploadedBytes += end - start;
244
+ report((uploadedBytes / file.size) * 100);
245
+ }
246
+ const completeResp = await fetch(`${this.apiBaseUrl}/api/multipart/complete`, {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({
250
+ assetId: session.assetId,
251
+ key: session.key,
252
+ uploadId: session.uploadId,
253
+ parts: session.completedParts,
254
+ }),
255
+ });
256
+ if (!completeResp.ok) {
257
+ const data = await completeResp.json().catch(() => ({}));
258
+ throw new Error(data.error || 'Failed to complete multipart upload');
259
+ }
260
+ const verifyResp = await fetch(`${this.apiBaseUrl}/api/verify`, {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({
264
+ assetId: session.assetId,
265
+ key: session.key,
266
+ size: file.size,
267
+ contentType: file.type || 'application/octet-stream',
268
+ }),
269
+ });
270
+ if (!verifyResp.ok) {
271
+ const data = await verifyResp.json().catch(() => ({}));
272
+ throw new Error(data.error || 'Verification failed');
273
+ }
274
+ const verifyData = await verifyResp.json();
275
+ if (!verifyData.verified) {
276
+ throw new Error('Uploaded object verification failed (HEAD mismatch)');
277
+ }
278
+ const downloadResp = await fetch(`${this.apiBaseUrl}/api/download-url?key=${encodeURIComponent(session.key)}&assetId=${encodeURIComponent(session.assetId)}`);
279
+ if (!downloadResp.ok) {
280
+ const data = await downloadResp.json().catch(() => ({}));
281
+ throw new Error(data.error || 'Failed to get download URL');
282
+ }
283
+ const downloadData = await downloadResp.json();
284
+ this.clearSession(fileId);
285
+ report(100);
286
+ return {
287
+ url: downloadData.url,
288
+ assetId: session.assetId,
289
+ key: session.key,
290
+ verified: true,
291
+ };
292
+ }
293
+ async waitForPartEtag(key, uploadId, partNumber) {
294
+ const attempts = 6;
295
+ for (let i = 0; i < attempts; i += 1) {
296
+ const etag = await this.fetchPartEtag(key, uploadId, partNumber);
297
+ if (etag) {
298
+ return etag;
299
+ }
300
+ await new Promise((resolve) => setTimeout(resolve, 300));
301
+ }
302
+ return null;
303
+ }
304
+ async fetchPartEtag(key, uploadId, partNumber) {
305
+ const listResp = await fetch(`${this.apiBaseUrl}/api/multipart/parts?key=${encodeURIComponent(key)}&uploadId=${encodeURIComponent(uploadId)}`);
306
+ if (!listResp.ok) {
307
+ return null;
308
+ }
309
+ const data = await listResp.json().catch(() => ({}));
310
+ const parts = Array.isArray(data.parts) ? data.parts : [];
311
+ const found = parts.find((p) => Number(p.PartNumber) === Number(partNumber));
312
+ return found && found.ETag ? found.ETag : null;
313
+ }
314
+ putWithProgress({ url, body, contentType, signal, onProgress, }) {
315
+ return new Promise((resolve, reject) => {
316
+ const xhr = new XMLHttpRequest();
317
+ xhr.open('PUT', url);
318
+ const extractEtag = () => {
319
+ const headers = xhr.getAllResponseHeaders() || '';
320
+ const lines = headers.split(/\r?\n/);
321
+ for (const line of lines) {
322
+ const idx = line.indexOf(':');
323
+ if (idx < 0)
324
+ continue;
325
+ const key = line.slice(0, idx).trim().toLowerCase();
326
+ if (key === 'etag') {
327
+ return line.slice(idx + 1).trim();
328
+ }
329
+ }
330
+ return null;
331
+ };
332
+ if (contentType) {
333
+ xhr.setRequestHeader('Content-Type', contentType);
334
+ }
335
+ xhr.upload.onprogress = (event) => {
336
+ if (onProgress) {
337
+ onProgress(event.loaded || 0, event.total || body.size || 0);
338
+ }
339
+ };
340
+ xhr.onload = () => {
341
+ if (xhr.status >= 200 && xhr.status < 300) {
342
+ const etag = extractEtag();
343
+ resolve(etag);
344
+ return;
345
+ }
346
+ reject(new Error(`S3 upload failed (${xhr.status})`));
347
+ };
348
+ xhr.onerror = () => reject(new Error('Network error during upload'));
349
+ xhr.onabort = () => {
350
+ const abortErr = new Error('Upload aborted');
351
+ abortErr.name = 'AbortError';
352
+ reject(abortErr);
353
+ };
354
+ if (signal) {
355
+ if (signal.aborted) {
356
+ xhr.abort();
357
+ }
358
+ else {
359
+ const onAbort = () => xhr.abort();
360
+ signal.addEventListener('abort', onAbort, { once: true });
361
+ }
362
+ }
363
+ xhr.send(body);
364
+ });
365
+ }
366
+ }
367
+ export function createPresignedS3Adapter(config) {
368
+ return new PresignedS3Adapter(config);
369
+ }
@@ -0,0 +1,49 @@
1
+ import type { StorageAdapter, UploadClient, UploadOptions } from './index';
2
+ export type HookFileInfo = {
3
+ id: string;
4
+ name: string;
5
+ size: number;
6
+ type: string;
7
+ };
8
+ export type UploadState = 'pending' | 'queued' | 'uploading' | 'paused' | 'uploaded' | 'failed' | 'retrying';
9
+ export type HookFileStatus = {
10
+ state: UploadState;
11
+ progress: number;
12
+ uploadedBytes: number;
13
+ totalBytes: number;
14
+ error?: string;
15
+ retryAttempt?: number;
16
+ maxRetries?: number;
17
+ };
18
+ export type UploaderRules = {
19
+ allowExtensions?: string[];
20
+ };
21
+ export type UseUploaderConfig = {
22
+ adapterName: string;
23
+ adapter?: StorageAdapter;
24
+ dbName?: string;
25
+ storeName?: string;
26
+ rules?: UploaderRules;
27
+ defaultUploadOptions?: Pick<UploadOptions, 'parallel' | 'retry' | 'retryDelayMs'>;
28
+ autoRefreshOnMount?: boolean;
29
+ };
30
+ export type UseUploaderResult = {
31
+ client: UploadClient;
32
+ files: HookFileInfo[];
33
+ statusById: Record<string, HookFileStatus>;
34
+ logs: string[];
35
+ isUploading: boolean;
36
+ refresh: () => Promise<HookFileInfo[]>;
37
+ addFiles: (files: File[]) => Promise<{
38
+ saved: string[];
39
+ rejected: string[];
40
+ }>;
41
+ clearFiles: () => Promise<void>;
42
+ uploadAll: (options?: UploadOptions) => Promise<void>;
43
+ uploadSelected: (fileIds: string[], options?: UploadOptions) => Promise<void>;
44
+ pause: (fileId: string) => void;
45
+ resume: (fileId: string) => void;
46
+ cancel: (fileId: string) => Promise<void>;
47
+ setRules: (rules: UploaderRules) => void;
48
+ };
49
+ export declare function useUploader(config: UseUploaderConfig): UseUploaderResult;