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.
package/dist/index.js ADDED
@@ -0,0 +1,407 @@
1
+ const DEFAULT_DB_NAME = 'upload_sdk_db';
2
+ const DEFAULT_STORE_NAME = 'files';
3
+ export function createUploadError(code, message, options) {
4
+ const err = new Error(message);
5
+ err.code = code;
6
+ err.retriable = options?.retriable;
7
+ err.cause = options?.cause;
8
+ return err;
9
+ }
10
+ export class UploadSDK {
11
+ constructor(config) {
12
+ this.adapters = new Map();
13
+ this.allowExtensions = null;
14
+ this.pausedFileIds = new Set();
15
+ this.canceledFileIds = new Set();
16
+ this.activeControllers = new Map();
17
+ this.dbName = config?.dbName ?? DEFAULT_DB_NAME;
18
+ this.storeName = config?.storeName ?? DEFAULT_STORE_NAME;
19
+ }
20
+ registerAdapter(name, adapter) {
21
+ this.adapters.set(name, adapter);
22
+ }
23
+ setExtensionRules(rules) {
24
+ this.allowExtensions = rules.allowExtensions
25
+ ? new Set(rules.allowExtensions.map((ext) => this.normalizeExtension(ext)))
26
+ : null;
27
+ }
28
+ pauseUpload(fileId) {
29
+ this.pausedFileIds.add(fileId);
30
+ const controller = this.activeControllers.get(fileId);
31
+ if (controller) {
32
+ controller.abort();
33
+ }
34
+ }
35
+ resumeUpload(fileId) {
36
+ this.pausedFileIds.delete(fileId);
37
+ }
38
+ async cancelUpload(fileId) {
39
+ this.canceledFileIds.add(fileId);
40
+ this.pausedFileIds.delete(fileId);
41
+ const controller = this.activeControllers.get(fileId);
42
+ if (controller) {
43
+ controller.abort();
44
+ }
45
+ await this.cleanupAllAdapterSessions(fileId, 'canceled');
46
+ await this.removeStoredFileById(fileId);
47
+ }
48
+ async saveFilesToIndexedDB(files) {
49
+ const saved = [];
50
+ const rejected = [];
51
+ const recordsToWrite = [];
52
+ for (const file of files) {
53
+ if (!this.isFileAllowed(file.name)) {
54
+ rejected.push(file.name);
55
+ continue;
56
+ }
57
+ const data = await file.arrayBuffer();
58
+ const id = this.buildFileId(file);
59
+ const record = {
60
+ id,
61
+ name: file.name,
62
+ type: file.type,
63
+ lastModified: file.lastModified,
64
+ data,
65
+ size: file.size,
66
+ };
67
+ recordsToWrite.push(record);
68
+ saved.push(file.name);
69
+ }
70
+ if (!recordsToWrite.length) {
71
+ return { saved, rejected };
72
+ }
73
+ const db = await this.openDB();
74
+ const tx = db.transaction(this.storeName, 'readwrite');
75
+ const store = tx.objectStore(this.storeName);
76
+ for (const record of recordsToWrite) {
77
+ await this.requestToPromise(store.put(record));
78
+ }
79
+ await this.txComplete(tx);
80
+ db.close();
81
+ return { saved, rejected };
82
+ }
83
+ async listStoredFiles() {
84
+ const db = await this.openDB();
85
+ const tx = db.transaction(this.storeName, 'readonly');
86
+ const store = tx.objectStore(this.storeName);
87
+ const records = (await this.requestToPromise(store.getAll()));
88
+ await this.txComplete(tx);
89
+ db.close();
90
+ return records.map((r) => ({ id: r.id, name: r.name, size: r.size, type: r.type }));
91
+ }
92
+ async clearStoredFiles() {
93
+ const db = await this.openDB();
94
+ const tx = db.transaction(this.storeName, 'readwrite');
95
+ const store = tx.objectStore(this.storeName);
96
+ await this.requestToPromise(store.clear());
97
+ await this.txComplete(tx);
98
+ db.close();
99
+ }
100
+ async uploadFiles(adapterName, options = {}) {
101
+ const adapter = this.adapters.get(adapterName);
102
+ if (!adapter) {
103
+ throw createUploadError('ADAPTER_NOT_FOUND', `Adapter not found: ${adapterName}`);
104
+ }
105
+ const db = await this.openDB();
106
+ const tx = db.transaction(this.storeName, 'readonly');
107
+ const store = tx.objectStore(this.storeName);
108
+ const records = (await this.requestToPromise(store.getAll()));
109
+ await this.txComplete(tx);
110
+ db.close();
111
+ const files = await this.prepareUploadItems(records, options);
112
+ await this.uploadWithConcurrency(files, adapter, options);
113
+ }
114
+ async uploadSelectedFiles(adapterName, fileIds, options = {}) {
115
+ const adapter = this.adapters.get(adapterName);
116
+ if (!adapter) {
117
+ throw createUploadError('ADAPTER_NOT_FOUND', `Adapter not found: ${adapterName}`);
118
+ }
119
+ if (!fileIds.length) {
120
+ return;
121
+ }
122
+ const idSet = new Set(fileIds);
123
+ const db = await this.openDB();
124
+ const tx = db.transaction(this.storeName, 'readonly');
125
+ const store = tx.objectStore(this.storeName);
126
+ const records = (await this.requestToPromise(store.getAll()));
127
+ await this.txComplete(tx);
128
+ db.close();
129
+ const selectedRecords = records.filter((record) => idSet.has(record.id));
130
+ const files = await this.prepareUploadItems(selectedRecords, options);
131
+ await this.uploadWithConcurrency(files, adapter, options);
132
+ }
133
+ async uploadWithConcurrency(items, adapter, options) {
134
+ const parallel = Math.max(1, options.parallel ?? 3);
135
+ const retry = Math.max(0, options.retry ?? 2);
136
+ const retryDelayMs = Math.max(0, options.retryDelayMs ?? 300);
137
+ let nextIndex = 0;
138
+ const worker = async () => {
139
+ while (true) {
140
+ const current = nextIndex;
141
+ nextIndex += 1;
142
+ if (current >= items.length) {
143
+ return;
144
+ }
145
+ const item = items[current];
146
+ if (this.canceledFileIds.has(item.id)) {
147
+ continue;
148
+ }
149
+ const canProceed = await this.waitIfPaused(item.id);
150
+ if (!canProceed || this.canceledFileIds.has(item.id)) {
151
+ continue;
152
+ }
153
+ await this.uploadSingleWithRetry(item.id, item.file, adapter, retry, retryDelayMs, options);
154
+ await this.removeStoredFileById(item.id);
155
+ this.canceledFileIds.delete(item.id);
156
+ }
157
+ };
158
+ const workers = Array.from({ length: Math.min(parallel, items.length) }, () => worker());
159
+ await Promise.all(workers);
160
+ }
161
+ async uploadSingleWithRetry(fileId, file, adapter, retry, retryDelayMs, options) {
162
+ let attempts = 0;
163
+ while (attempts <= retry) {
164
+ if (this.canceledFileIds.has(fileId)) {
165
+ return;
166
+ }
167
+ const canProceed = await this.waitIfPaused(fileId);
168
+ if (!canProceed) {
169
+ return;
170
+ }
171
+ const controller = new AbortController();
172
+ this.activeControllers.set(fileId, controller);
173
+ try {
174
+ // Mark file as started before network progress events arrive.
175
+ options.onProgress?.(0, file.name, { fileId });
176
+ let reportedByAdapter = false;
177
+ const reportProgress = (progress) => {
178
+ reportedByAdapter = true;
179
+ const bounded = Math.max(0, Math.min(100, progress));
180
+ options.onProgress?.(bounded, file.name, { fileId });
181
+ };
182
+ const adapterResult = await adapter.uploadFile(file, {
183
+ fileId,
184
+ fileName: file.name,
185
+ fileSize: file.size,
186
+ fileType: file.type,
187
+ signal: controller.signal,
188
+ attempt: attempts,
189
+ onProgress: reportProgress,
190
+ });
191
+ const normalized = typeof adapterResult === 'string'
192
+ ? { url: adapterResult }
193
+ : {
194
+ url: adapterResult.url,
195
+ assetId: adapterResult.assetId,
196
+ key: adapterResult.key,
197
+ verified: adapterResult.verified,
198
+ };
199
+ if (!reportedByAdapter) {
200
+ options.onProgress?.(100, file.name, { fileId });
201
+ }
202
+ else {
203
+ // Ensure final state is always 100 even if adapter reports a lower terminal value.
204
+ options.onProgress?.(100, file.name, { fileId });
205
+ }
206
+ options.onComplete?.(file.name, normalized.url, { fileId });
207
+ options.onCompleteDetailed?.(file.name, normalized, { fileId });
208
+ return;
209
+ }
210
+ catch (error) {
211
+ const aborted = this.isAbortError(error);
212
+ const pausedAbort = this.pausedFileIds.has(fileId) && this.isAbortError(error);
213
+ if (pausedAbort) {
214
+ // Do not consume retries for deliberate pause interruption.
215
+ continue;
216
+ }
217
+ const canceledAbort = this.canceledFileIds.has(fileId) && aborted;
218
+ if (canceledAbort) {
219
+ // Cancellation is user-intentional; do not retry or report as error.
220
+ return;
221
+ }
222
+ attempts += 1;
223
+ const err = this.normalizeUploadError(error);
224
+ const willRetry = attempts <= retry;
225
+ if (willRetry) {
226
+ options.onRetry?.(err, file.name, {
227
+ attempt: attempts,
228
+ maxRetries: retry,
229
+ fileId,
230
+ });
231
+ }
232
+ else {
233
+ await this.cleanupAdapterSession(adapter, fileId, 'final-failure');
234
+ options.onFinalError?.(err, file.name, { fileId });
235
+ options.onError?.(err, file.name, { fileId });
236
+ throw err;
237
+ }
238
+ await this.sleep(retryDelayMs);
239
+ }
240
+ finally {
241
+ this.activeControllers.delete(fileId);
242
+ }
243
+ }
244
+ }
245
+ async waitIfPaused(fileId) {
246
+ while (this.pausedFileIds.has(fileId)) {
247
+ if (this.canceledFileIds.has(fileId)) {
248
+ return false;
249
+ }
250
+ await this.sleep(150);
251
+ }
252
+ return true;
253
+ }
254
+ async prepareUploadItems(records, options) {
255
+ const items = [];
256
+ for (const record of records) {
257
+ items.push({
258
+ id: record.id,
259
+ file: new File([record.data], record.name, {
260
+ type: record.type,
261
+ lastModified: record.lastModified,
262
+ }),
263
+ });
264
+ }
265
+ return items;
266
+ }
267
+ async removeStoredFileById(id) {
268
+ const db = await this.openDB();
269
+ const tx = db.transaction(this.storeName, 'readwrite');
270
+ const store = tx.objectStore(this.storeName);
271
+ await this.requestToPromise(store.delete(id));
272
+ await this.txComplete(tx);
273
+ db.close();
274
+ }
275
+ isFileAllowed(fileName) {
276
+ const ext = this.extractExtension(fileName);
277
+ if (!ext) {
278
+ return this.allowExtensions === null;
279
+ }
280
+ if (this.allowExtensions && !this.allowExtensions.has(ext)) {
281
+ return false;
282
+ }
283
+ return true;
284
+ }
285
+ extractExtension(fileName) {
286
+ const idx = fileName.lastIndexOf('.');
287
+ if (idx < 0 || idx === fileName.length - 1) {
288
+ return null;
289
+ }
290
+ return this.normalizeExtension(fileName.slice(idx + 1));
291
+ }
292
+ normalizeExtension(ext) {
293
+ return ext.replace(/^\./, '').trim().toLowerCase();
294
+ }
295
+ buildFileId(_file) {
296
+ const uuid = globalThis.crypto?.randomUUID?.();
297
+ if (uuid) {
298
+ return uuid;
299
+ }
300
+ return `f_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
301
+ }
302
+ openDB() {
303
+ return new Promise((resolve, reject) => {
304
+ const req = indexedDB.open(this.dbName, 1);
305
+ req.onupgradeneeded = () => {
306
+ const db = req.result;
307
+ if (!db.objectStoreNames.contains(this.storeName)) {
308
+ db.createObjectStore(this.storeName, { keyPath: 'id' });
309
+ }
310
+ };
311
+ req.onsuccess = () => resolve(req.result);
312
+ req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB'));
313
+ });
314
+ }
315
+ requestToPromise(request) {
316
+ return new Promise((resolve, reject) => {
317
+ request.onsuccess = () => resolve(request.result);
318
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
319
+ });
320
+ }
321
+ txComplete(tx) {
322
+ return new Promise((resolve, reject) => {
323
+ tx.oncomplete = () => resolve();
324
+ tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'));
325
+ tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted'));
326
+ });
327
+ }
328
+ sleep(ms) {
329
+ return new Promise((resolve) => setTimeout(resolve, ms));
330
+ }
331
+ isAbortError(error) {
332
+ if (!error || typeof error !== 'object') {
333
+ return false;
334
+ }
335
+ const maybe = error;
336
+ return maybe.name === 'AbortError';
337
+ }
338
+ normalizeUploadError(error) {
339
+ if (error && typeof error === 'object' && 'code' in error) {
340
+ return error;
341
+ }
342
+ if (this.isAbortError(error)) {
343
+ return createUploadError('UPLOAD_ABORTED', 'Upload aborted', {
344
+ retriable: false,
345
+ cause: error,
346
+ });
347
+ }
348
+ const message = error && typeof error === 'object' && 'message' in error
349
+ ? String(error.message || 'Upload failed')
350
+ : 'Upload failed';
351
+ return createUploadError('UPLOAD_FAILED', message, {
352
+ retriable: true,
353
+ cause: error,
354
+ });
355
+ }
356
+ async cleanupAdapterSession(adapter, fileId, reason) {
357
+ if (!adapter.cleanupUploadSession) {
358
+ return;
359
+ }
360
+ try {
361
+ await adapter.cleanupUploadSession(fileId, { reason });
362
+ }
363
+ catch {
364
+ // Cleanup is best-effort and should not mask primary upload errors.
365
+ }
366
+ }
367
+ async cleanupAllAdapterSessions(fileId, reason) {
368
+ const tasks = [];
369
+ for (const adapter of this.adapters.values()) {
370
+ tasks.push(this.cleanupAdapterSession(adapter, fileId, reason));
371
+ }
372
+ await Promise.all(tasks);
373
+ }
374
+ }
375
+ export function createUploadClient(config) {
376
+ return new UploadSDK(config);
377
+ }
378
+ export function registerStorageAdapter(client, name, adapter) {
379
+ client.registerAdapter(name, adapter);
380
+ }
381
+ export function setFileExtensionRules(client, rules) {
382
+ client.setExtensionRules(rules);
383
+ }
384
+ export async function addFiles(client, files) {
385
+ return client.saveFilesToIndexedDB(files);
386
+ }
387
+ export async function listFiles(client) {
388
+ return client.listStoredFiles();
389
+ }
390
+ export async function clearFiles(client) {
391
+ await client.clearStoredFiles();
392
+ }
393
+ export async function uploadFiles(client, adapterName, options = {}) {
394
+ await client.uploadFiles(adapterName, options);
395
+ }
396
+ export async function uploadSelectedFiles(client, adapterName, fileIds, options = {}) {
397
+ await client.uploadSelectedFiles(adapterName, fileIds, options);
398
+ }
399
+ export function pauseFileUpload(client, fileId) {
400
+ client.pauseUpload(fileId);
401
+ }
402
+ export function resumeFileUpload(client, fileId) {
403
+ client.resumeUpload(fileId);
404
+ }
405
+ export async function cancelFileUpload(client, fileId) {
406
+ await client.cancelUpload(fileId);
407
+ }
@@ -0,0 +1,34 @@
1
+ import type { StorageAdapter, UploadResult } from './index';
2
+ export type PresignedS3AdapterConfig = {
3
+ apiBaseUrl: string;
4
+ multipartThresholdBytes?: number;
5
+ multipartPartSizeBytes?: number;
6
+ };
7
+ type UploadRuntimeOptions = {
8
+ fileId?: string;
9
+ onProgress?: (progress: number) => void;
10
+ signal?: AbortSignal;
11
+ };
12
+ export declare class PresignedS3Adapter implements StorageAdapter {
13
+ private apiBaseUrl;
14
+ private multipartThresholdBytes;
15
+ private multipartPartSizeBytes;
16
+ constructor(config: PresignedS3AdapterConfig);
17
+ uploadFile(file: File, options?: UploadRuntimeOptions): Promise<UploadResult>;
18
+ cleanupUploadSession(fileId: string, _meta?: {
19
+ reason: 'canceled' | 'final-failure';
20
+ }): Promise<void>;
21
+ private abortMultipartWithRetry;
22
+ private uploadSimple;
23
+ private getSessionKey;
24
+ private loadSession;
25
+ private saveSession;
26
+ private clearSession;
27
+ private computeUploadedBytes;
28
+ private uploadMultipart;
29
+ private waitForPartEtag;
30
+ private fetchPartEtag;
31
+ private putWithProgress;
32
+ }
33
+ export declare function createPresignedS3Adapter(config: PresignedS3AdapterConfig): PresignedS3Adapter;
34
+ export {};