@idealyst/files 1.2.96
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/package.json +94 -0
- package/src/components/DropZone.native.tsx +96 -0
- package/src/components/DropZone.styles.tsx +99 -0
- package/src/components/DropZone.web.tsx +178 -0
- package/src/components/FilePickerButton.native.tsx +82 -0
- package/src/components/FilePickerButton.styles.tsx +112 -0
- package/src/components/FilePickerButton.web.tsx +84 -0
- package/src/components/UploadProgress.native.tsx +203 -0
- package/src/components/UploadProgress.styles.tsx +90 -0
- package/src/components/UploadProgress.web.tsx +201 -0
- package/src/components/index.native.ts +8 -0
- package/src/components/index.ts +6 -0
- package/src/components/index.web.ts +8 -0
- package/src/constants.ts +336 -0
- package/src/examples/index.ts +181 -0
- package/src/hooks/createUseFilePickerHook.ts +169 -0
- package/src/hooks/createUseFileUploadHook.ts +173 -0
- package/src/hooks/index.native.ts +12 -0
- package/src/hooks/index.ts +12 -0
- package/src/hooks/index.web.ts +12 -0
- package/src/index.native.ts +142 -0
- package/src/index.ts +139 -0
- package/src/index.web.ts +142 -0
- package/src/permissions/index.native.ts +8 -0
- package/src/permissions/index.ts +8 -0
- package/src/permissions/index.web.ts +8 -0
- package/src/permissions/permissions.native.ts +177 -0
- package/src/permissions/permissions.web.ts +96 -0
- package/src/picker/FilePicker.native.ts +407 -0
- package/src/picker/FilePicker.web.ts +366 -0
- package/src/picker/index.native.ts +2 -0
- package/src/picker/index.ts +2 -0
- package/src/picker/index.web.ts +2 -0
- package/src/types.ts +990 -0
- package/src/uploader/ChunkedUploader.ts +312 -0
- package/src/uploader/FileUploader.native.ts +435 -0
- package/src/uploader/FileUploader.web.ts +350 -0
- package/src/uploader/UploadQueue.ts +519 -0
- package/src/uploader/index.native.ts +4 -0
- package/src/uploader/index.ts +4 -0
- package/src/uploader/index.web.ts +4 -0
- package/src/utils.ts +586 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileType,
|
|
3
|
+
PickedFile,
|
|
4
|
+
FilePickerConfig,
|
|
5
|
+
UploadConfig,
|
|
6
|
+
ValidationResult,
|
|
7
|
+
RejectedFile,
|
|
8
|
+
FilePickerError,
|
|
9
|
+
FilePickerErrorCode,
|
|
10
|
+
UploadError,
|
|
11
|
+
UploadErrorCode,
|
|
12
|
+
} from './types';
|
|
13
|
+
import {
|
|
14
|
+
FILE_TYPE_MIME_TYPES,
|
|
15
|
+
FILE_TYPE_EXTENSIONS,
|
|
16
|
+
DEFAULT_FILE_PICKER_CONFIG,
|
|
17
|
+
DEFAULT_UPLOAD_CONFIG,
|
|
18
|
+
FILE_PICKER_ERROR_MESSAGES,
|
|
19
|
+
UPLOAD_ERROR_MESSAGES,
|
|
20
|
+
} from './constants';
|
|
21
|
+
|
|
22
|
+
// ============================================
|
|
23
|
+
// ID GENERATION
|
|
24
|
+
// ============================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate a unique ID for files and uploads.
|
|
28
|
+
*/
|
|
29
|
+
export function generateId(prefix = 'file'): string {
|
|
30
|
+
const timestamp = Date.now().toString(36);
|
|
31
|
+
const random = Math.random().toString(36).substring(2, 9);
|
|
32
|
+
return `${prefix}_${timestamp}_${random}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================
|
|
36
|
+
// FILE SIZE FORMATTING
|
|
37
|
+
// ============================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format bytes to human-readable string.
|
|
41
|
+
* @param bytes Number of bytes
|
|
42
|
+
* @param decimals Number of decimal places
|
|
43
|
+
*/
|
|
44
|
+
export function formatBytes(bytes: number, decimals = 2): string {
|
|
45
|
+
if (bytes === 0) return '0 Bytes';
|
|
46
|
+
|
|
47
|
+
const k = 1024;
|
|
48
|
+
const dm = decimals < 0 ? 0 : decimals;
|
|
49
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
50
|
+
|
|
51
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
52
|
+
const value = bytes / Math.pow(k, i);
|
|
53
|
+
|
|
54
|
+
return `${value.toFixed(dm)} ${sizes[i]}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse size string to bytes (e.g., "10MB" -> 10485760).
|
|
59
|
+
*/
|
|
60
|
+
export function parseSize(size: string): number {
|
|
61
|
+
const match = size.match(/^(\d+(?:\.\d+)?)\s*(bytes?|kb?|mb?|gb?|tb?)?$/i);
|
|
62
|
+
if (!match) return 0;
|
|
63
|
+
|
|
64
|
+
const value = parseFloat(match[1]);
|
|
65
|
+
const unit = (match[2] || 'b').toLowerCase();
|
|
66
|
+
|
|
67
|
+
const multipliers: Record<string, number> = {
|
|
68
|
+
b: 1,
|
|
69
|
+
byte: 1,
|
|
70
|
+
bytes: 1,
|
|
71
|
+
k: 1024,
|
|
72
|
+
kb: 1024,
|
|
73
|
+
m: 1024 * 1024,
|
|
74
|
+
mb: 1024 * 1024,
|
|
75
|
+
g: 1024 * 1024 * 1024,
|
|
76
|
+
gb: 1024 * 1024 * 1024,
|
|
77
|
+
t: 1024 * 1024 * 1024 * 1024,
|
|
78
|
+
tb: 1024 * 1024 * 1024 * 1024,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return Math.floor(value * (multipliers[unit] || 1));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================
|
|
85
|
+
// DURATION FORMATTING
|
|
86
|
+
// ============================================
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format milliseconds to human-readable duration string.
|
|
90
|
+
*/
|
|
91
|
+
export function formatDuration(ms: number): string {
|
|
92
|
+
if (ms < 1000) return 'less than a second';
|
|
93
|
+
|
|
94
|
+
const seconds = Math.floor(ms / 1000);
|
|
95
|
+
const minutes = Math.floor(seconds / 60);
|
|
96
|
+
const hours = Math.floor(minutes / 60);
|
|
97
|
+
|
|
98
|
+
if (hours > 0) {
|
|
99
|
+
const remainingMinutes = minutes % 60;
|
|
100
|
+
return remainingMinutes > 0
|
|
101
|
+
? `${hours}h ${remainingMinutes}m`
|
|
102
|
+
: `${hours}h`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (minutes > 0) {
|
|
106
|
+
const remainingSeconds = seconds % 60;
|
|
107
|
+
return remainingSeconds > 0
|
|
108
|
+
? `${minutes}m ${remainingSeconds}s`
|
|
109
|
+
: `${minutes}m`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return `${seconds}s`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================
|
|
116
|
+
// MIME TYPE UTILITIES
|
|
117
|
+
// ============================================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get MIME types for the given file types.
|
|
121
|
+
*/
|
|
122
|
+
export function getMimeTypes(fileTypes: FileType[]): string[] {
|
|
123
|
+
const mimeTypes = new Set<string>();
|
|
124
|
+
|
|
125
|
+
for (const fileType of fileTypes) {
|
|
126
|
+
const types = FILE_TYPE_MIME_TYPES[fileType] || [];
|
|
127
|
+
for (const type of types) {
|
|
128
|
+
mimeTypes.add(type);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Array.from(mimeTypes);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get file extensions for the given file types.
|
|
137
|
+
*/
|
|
138
|
+
export function getExtensions(fileTypes: FileType[]): string[] {
|
|
139
|
+
const extensions = new Set<string>();
|
|
140
|
+
|
|
141
|
+
for (const fileType of fileTypes) {
|
|
142
|
+
const exts = FILE_TYPE_EXTENSIONS[fileType] || [];
|
|
143
|
+
for (const ext of exts) {
|
|
144
|
+
extensions.add(ext);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Array.from(extensions);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build accept string for HTML input element.
|
|
153
|
+
*/
|
|
154
|
+
export function buildAcceptString(config: Partial<FilePickerConfig>): string {
|
|
155
|
+
if (config.customMimeTypes?.length) {
|
|
156
|
+
return config.customMimeTypes.join(',');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (config.customExtensions?.length) {
|
|
160
|
+
return config.customExtensions.join(',');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const types = config.allowedTypes || ['any'];
|
|
164
|
+
|
|
165
|
+
if (types.includes('any')) {
|
|
166
|
+
return '*/*';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const mimeTypes = getMimeTypes(types);
|
|
170
|
+
const extensions = getExtensions(types);
|
|
171
|
+
|
|
172
|
+
return [...mimeTypes, ...extensions].join(',');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get file extension from file name.
|
|
177
|
+
*/
|
|
178
|
+
export function getFileExtension(fileName: string): string {
|
|
179
|
+
const lastDot = fileName.lastIndexOf('.');
|
|
180
|
+
if (lastDot === -1) return '';
|
|
181
|
+
return fileName.substring(lastDot + 1).toLowerCase();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Determine file type from MIME type.
|
|
186
|
+
*/
|
|
187
|
+
export function getFileTypeFromMime(mimeType: string): FileType {
|
|
188
|
+
for (const [type, mimes] of Object.entries(FILE_TYPE_MIME_TYPES)) {
|
|
189
|
+
if (type === 'any') continue;
|
|
190
|
+
if (mimes.some(mime => mimeType.startsWith(mime.replace('/*', '')))) {
|
|
191
|
+
return type as FileType;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return 'any';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if a MIME type matches allowed types.
|
|
199
|
+
*/
|
|
200
|
+
export function isMimeTypeAllowed(mimeType: string, config: Partial<FilePickerConfig>): boolean {
|
|
201
|
+
// Custom MIME types take precedence
|
|
202
|
+
if (config.customMimeTypes?.length) {
|
|
203
|
+
return config.customMimeTypes.some(allowed => {
|
|
204
|
+
if (allowed === '*/*') return true;
|
|
205
|
+
if (allowed.endsWith('/*')) {
|
|
206
|
+
return mimeType.startsWith(allowed.replace('/*', '/'));
|
|
207
|
+
}
|
|
208
|
+
return mimeType === allowed;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const allowedTypes = config.allowedTypes || ['any'];
|
|
213
|
+
|
|
214
|
+
if (allowedTypes.includes('any')) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const allowedMimes = getMimeTypes(allowedTypes);
|
|
219
|
+
return allowedMimes.some(allowed => {
|
|
220
|
+
if (allowed === '*/*') return true;
|
|
221
|
+
if (allowed.endsWith('/*')) {
|
|
222
|
+
return mimeType.startsWith(allowed.replace('/*', '/'));
|
|
223
|
+
}
|
|
224
|
+
return mimeType === allowed;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ============================================
|
|
229
|
+
// VALIDATION
|
|
230
|
+
// ============================================
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Validate files against picker configuration.
|
|
234
|
+
*/
|
|
235
|
+
export function validateFiles(
|
|
236
|
+
files: Array<File | PickedFile>,
|
|
237
|
+
config: Partial<FilePickerConfig>
|
|
238
|
+
): ValidationResult {
|
|
239
|
+
const finalConfig = { ...DEFAULT_FILE_PICKER_CONFIG, ...config };
|
|
240
|
+
const accepted: PickedFile[] = [];
|
|
241
|
+
const rejected: RejectedFile[] = [];
|
|
242
|
+
|
|
243
|
+
let totalSize = 0;
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < files.length; i++) {
|
|
246
|
+
const file = files[i];
|
|
247
|
+
const name = 'name' in file ? file.name : (file as File).name;
|
|
248
|
+
const size = 'size' in file ? file.size : (file as File).size;
|
|
249
|
+
const type = 'type' in file ? file.type : (file as File).type;
|
|
250
|
+
|
|
251
|
+
// Check file count
|
|
252
|
+
if (finalConfig.maxFiles && accepted.length >= finalConfig.maxFiles) {
|
|
253
|
+
rejected.push({
|
|
254
|
+
name,
|
|
255
|
+
reason: 'count',
|
|
256
|
+
message: `Maximum of ${finalConfig.maxFiles} files allowed`,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Check file type
|
|
262
|
+
if (!isMimeTypeAllowed(type, finalConfig)) {
|
|
263
|
+
rejected.push({
|
|
264
|
+
name,
|
|
265
|
+
reason: 'type',
|
|
266
|
+
message: `File type '${type}' is not allowed`,
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check file size
|
|
272
|
+
if (finalConfig.maxFileSize && size > finalConfig.maxFileSize) {
|
|
273
|
+
rejected.push({
|
|
274
|
+
name,
|
|
275
|
+
reason: 'size',
|
|
276
|
+
message: `File size (${formatBytes(size)}) exceeds maximum (${formatBytes(finalConfig.maxFileSize)})`,
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Check total size
|
|
282
|
+
if (finalConfig.maxTotalSize && totalSize + size > finalConfig.maxTotalSize) {
|
|
283
|
+
rejected.push({
|
|
284
|
+
name,
|
|
285
|
+
reason: 'total_size',
|
|
286
|
+
message: `Total size would exceed maximum (${formatBytes(finalConfig.maxTotalSize)})`,
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
totalSize += size;
|
|
292
|
+
|
|
293
|
+
// Convert File to PickedFile if needed
|
|
294
|
+
if ('id' in file) {
|
|
295
|
+
accepted.push(file as PickedFile);
|
|
296
|
+
} else {
|
|
297
|
+
accepted.push(createPickedFileFromFile(file as File));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
accepted,
|
|
303
|
+
rejected,
|
|
304
|
+
isValid: rejected.length === 0,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ============================================
|
|
309
|
+
// FILE CONVERSION
|
|
310
|
+
// ============================================
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Create a PickedFile from a browser File object.
|
|
314
|
+
*/
|
|
315
|
+
export function createPickedFileFromFile(file: File): PickedFile {
|
|
316
|
+
const uri = URL.createObjectURL(file);
|
|
317
|
+
const extension = getFileExtension(file.name);
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
id: generateId(),
|
|
321
|
+
name: file.name,
|
|
322
|
+
size: file.size,
|
|
323
|
+
type: file.type,
|
|
324
|
+
uri,
|
|
325
|
+
extension,
|
|
326
|
+
lastModified: file.lastModified,
|
|
327
|
+
getArrayBuffer: () => file.arrayBuffer(),
|
|
328
|
+
getData: () => Promise.resolve(file),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Clean up blob URLs to prevent memory leaks.
|
|
334
|
+
*/
|
|
335
|
+
export function revokeFileUri(uri: string): void {
|
|
336
|
+
if (uri.startsWith('blob:')) {
|
|
337
|
+
URL.revokeObjectURL(uri);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Clean up multiple file URIs.
|
|
343
|
+
*/
|
|
344
|
+
export function revokeFileUris(files: PickedFile[]): void {
|
|
345
|
+
for (const file of files) {
|
|
346
|
+
revokeFileUri(file.uri);
|
|
347
|
+
if (file.thumbnailUri) {
|
|
348
|
+
revokeFileUri(file.thumbnailUri);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ============================================
|
|
354
|
+
// ERROR CREATION
|
|
355
|
+
// ============================================
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Create a file picker error.
|
|
359
|
+
*/
|
|
360
|
+
export function createFilePickerError(
|
|
361
|
+
code: FilePickerErrorCode,
|
|
362
|
+
message?: string,
|
|
363
|
+
originalError?: Error
|
|
364
|
+
): FilePickerError {
|
|
365
|
+
return {
|
|
366
|
+
code,
|
|
367
|
+
message: message || FILE_PICKER_ERROR_MESSAGES[code] || 'Unknown error',
|
|
368
|
+
originalError,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Create an upload error.
|
|
374
|
+
*/
|
|
375
|
+
export function createUploadError(
|
|
376
|
+
code: UploadErrorCode,
|
|
377
|
+
message?: string,
|
|
378
|
+
statusCode?: number,
|
|
379
|
+
originalError?: Error
|
|
380
|
+
): UploadError {
|
|
381
|
+
return {
|
|
382
|
+
code,
|
|
383
|
+
message: message || UPLOAD_ERROR_MESSAGES[code] || 'Unknown error',
|
|
384
|
+
statusCode,
|
|
385
|
+
originalError,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ============================================
|
|
390
|
+
// CONFIG MERGING
|
|
391
|
+
// ============================================
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Merge partial file picker config with defaults.
|
|
395
|
+
*/
|
|
396
|
+
export function mergeFilePickerConfig(config?: Partial<FilePickerConfig>): FilePickerConfig {
|
|
397
|
+
return { ...DEFAULT_FILE_PICKER_CONFIG, ...config };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Merge partial upload config with defaults.
|
|
402
|
+
*/
|
|
403
|
+
export function mergeUploadConfig(config: Partial<UploadConfig> & { url: string }): UploadConfig {
|
|
404
|
+
return { ...DEFAULT_UPLOAD_CONFIG, ...config } as UploadConfig;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ============================================
|
|
408
|
+
// SPEED CALCULATION
|
|
409
|
+
// ============================================
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Calculate upload speed using a sliding window of samples.
|
|
413
|
+
*/
|
|
414
|
+
export class SpeedCalculator {
|
|
415
|
+
private samples: Array<{ bytes: number; timestamp: number }> = [];
|
|
416
|
+
private windowMs: number;
|
|
417
|
+
|
|
418
|
+
constructor(windowMs = 2000) {
|
|
419
|
+
this.windowMs = windowMs;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
addSample(bytes: number, timestamp = Date.now()): void {
|
|
423
|
+
this.samples.push({ bytes, timestamp });
|
|
424
|
+
this.pruneOldSamples(timestamp);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
getSpeed(): number {
|
|
428
|
+
if (this.samples.length < 2) return 0;
|
|
429
|
+
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
this.pruneOldSamples(now);
|
|
432
|
+
|
|
433
|
+
if (this.samples.length < 2) return 0;
|
|
434
|
+
|
|
435
|
+
const oldest = this.samples[0];
|
|
436
|
+
const newest = this.samples[this.samples.length - 1];
|
|
437
|
+
const timeDiff = (newest.timestamp - oldest.timestamp) / 1000; // Convert to seconds
|
|
438
|
+
|
|
439
|
+
if (timeDiff <= 0) return 0;
|
|
440
|
+
|
|
441
|
+
const bytesDiff = newest.bytes - oldest.bytes;
|
|
442
|
+
return bytesDiff / timeDiff;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
reset(): void {
|
|
446
|
+
this.samples = [];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private pruneOldSamples(now: number): void {
|
|
450
|
+
const cutoff = now - this.windowMs;
|
|
451
|
+
this.samples = this.samples.filter(s => s.timestamp > cutoff);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Calculate estimated time remaining.
|
|
457
|
+
*/
|
|
458
|
+
export function calculateETA(bytesRemaining: number, speed: number): number {
|
|
459
|
+
if (speed <= 0 || bytesRemaining <= 0) return 0;
|
|
460
|
+
return Math.ceil((bytesRemaining / speed) * 1000); // Return in milliseconds
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ============================================
|
|
464
|
+
// RETRY LOGIC
|
|
465
|
+
// ============================================
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Calculate retry delay based on strategy.
|
|
469
|
+
*/
|
|
470
|
+
export function calculateRetryDelay(
|
|
471
|
+
attempt: number,
|
|
472
|
+
strategy: 'fixed' | 'exponential',
|
|
473
|
+
baseDelay: number,
|
|
474
|
+
maxDelay = 30000
|
|
475
|
+
): number {
|
|
476
|
+
if (strategy === 'fixed') {
|
|
477
|
+
return baseDelay;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Exponential backoff with jitter
|
|
481
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
|
482
|
+
const jitter = Math.random() * 0.3 * exponentialDelay; // 0-30% jitter
|
|
483
|
+
return Math.min(exponentialDelay + jitter, maxDelay);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Determine if an error is retryable.
|
|
488
|
+
*/
|
|
489
|
+
export function isRetryableError(error: UploadError): boolean {
|
|
490
|
+
const retryableCodes: UploadErrorCode[] = [
|
|
491
|
+
'NETWORK_ERROR',
|
|
492
|
+
'TIMEOUT',
|
|
493
|
+
'SERVER_ERROR',
|
|
494
|
+
'CHUNK_FAILED',
|
|
495
|
+
];
|
|
496
|
+
|
|
497
|
+
// Don't retry client errors (4xx) except 429 (rate limit)
|
|
498
|
+
if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 429) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return retryableCodes.includes(error.code);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ============================================
|
|
506
|
+
// CHUNKED UPLOAD UTILITIES
|
|
507
|
+
// ============================================
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Calculate the number of chunks for a file.
|
|
511
|
+
*/
|
|
512
|
+
export function calculateChunkCount(fileSize: number, chunkSize: number): number {
|
|
513
|
+
return Math.ceil(fileSize / chunkSize);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Get chunk boundaries (start and end byte offsets).
|
|
518
|
+
*/
|
|
519
|
+
export function getChunkBoundaries(
|
|
520
|
+
fileSize: number,
|
|
521
|
+
chunkSize: number,
|
|
522
|
+
chunkIndex: number
|
|
523
|
+
): { start: number; end: number } {
|
|
524
|
+
const start = chunkIndex * chunkSize;
|
|
525
|
+
const end = Math.min(start + chunkSize, fileSize);
|
|
526
|
+
return { start, end };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Determine if a file should use chunked upload.
|
|
531
|
+
*/
|
|
532
|
+
export function shouldUseChunkedUpload(
|
|
533
|
+
fileSize: number,
|
|
534
|
+
config: Partial<UploadConfig>
|
|
535
|
+
): boolean {
|
|
536
|
+
if (config.chunkedUpload === true) {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (config.chunkedUpload === false) {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const threshold = config.chunkedUploadThreshold || DEFAULT_UPLOAD_CONFIG.chunkedUploadThreshold;
|
|
545
|
+
return fileSize > threshold;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ============================================
|
|
549
|
+
// SUBSCRIPTION UTILITIES
|
|
550
|
+
// ============================================
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Simple event emitter for subscriptions.
|
|
554
|
+
*/
|
|
555
|
+
export class EventEmitter<T extends Record<string, unknown[]>> {
|
|
556
|
+
private listeners = new Map<keyof T, Set<(...args: unknown[]) => void>>();
|
|
557
|
+
|
|
558
|
+
on<K extends keyof T>(event: K, callback: (...args: T[K]) => void): () => void {
|
|
559
|
+
if (!this.listeners.has(event)) {
|
|
560
|
+
this.listeners.set(event, new Set());
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
this.listeners.get(event)!.add(callback as (...args: unknown[]) => void);
|
|
564
|
+
|
|
565
|
+
return () => {
|
|
566
|
+
this.listeners.get(event)?.delete(callback as (...args: unknown[]) => void);
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
emit<K extends keyof T>(event: K, ...args: T[K]): void {
|
|
571
|
+
const callbacks = this.listeners.get(event);
|
|
572
|
+
if (callbacks) {
|
|
573
|
+
for (const callback of callbacks) {
|
|
574
|
+
callback(...args);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
removeAllListeners(event?: keyof T): void {
|
|
580
|
+
if (event) {
|
|
581
|
+
this.listeners.delete(event);
|
|
582
|
+
} else {
|
|
583
|
+
this.listeners.clear();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|