@wlindabla/file_uploader 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/README.md ADDED
@@ -0,0 +1,2172 @@
1
+ # @wlindabla/file_uploader
2
+
3
+ > A powerful, event-driven chunked file uploader for Browser and Node.js — Universal, Resumable, and Production-Ready.
4
+ ---
5
+
6
+ ## 📖 Table of Contents
7
+
8
+ - [@wlindabla/file\_uploader](#wlindablafile_uploader)
9
+ - [📖 Table of Contents](#-table-of-contents)
10
+ - [Overview](#overview)
11
+ - [Features](#features)
12
+ - [Installation](#installation)
13
+ - [Requirements](#requirements)
14
+ - [Quick Start](#quick-start)
15
+ - [Browser (Minimal Example)](#browser-minimal-example)
16
+ - [Core Concepts](#core-concepts)
17
+ - [How It Works](#how-it-works)
18
+ - [Upload Lifecycle](#upload-lifecycle)
19
+ - [The Three-Endpoint Pattern](#the-three-endpoint-pattern)
20
+ - [API Reference](#api-reference)
21
+ - [ChunkedFileUploader](#chunkedfileuploader)
22
+ - [Constructor](#constructor)
23
+ - [Methods](#methods)
24
+ - [`.withFile(file: File): this`](#withfilefile-file-this)
25
+ - [`.withEndpoints(endpoints: UploadEndpoints): this`](#withendpointsendpoints-uploadendpoints-this)
26
+ - [`.upload(): Promise<void>`](#upload-promisevoid)
27
+ - [`.pause(): void`](#pause-void)
28
+ - [`.resume(): void`](#resume-void)
29
+ - [`.cancel(): void`](#cancel-void)
30
+ - [`.getState(): UploadState`](#getstate-uploadstate)
31
+ - [`.resumeUpload(resumeData: ResumeData): Promise<void>`](#resumeuploadresumedata-resumedata-promisevoid)
32
+ - [`.loadResumeData(fileName: string): Promise<ResumeData | null>`](#loadresumedatafilename-string-promiseresumedata--null)
33
+ - [HttpFileUploaderEvents](#httpfileuploaderevents)
34
+ - [UploadOptions](#uploadoptions)
35
+ - [Chunk Size Auto-Calculation](#chunk-size-auto-calculation)
36
+ - [UploadEndpoints](#uploadendpoints)
37
+ - [UploadResumeCacheInterface](#uploadresumecacheinterface)
38
+ - [Example Implementations](#example-implementations)
39
+ - [Browser — localStorage](#browser--localstorage)
40
+ - [Browser — IndexedDB](#browser--indexeddb)
41
+ - [Node.js — Redis](#nodejs--redis)
42
+ - [Node.js — Filesystem](#nodejs--filesystem)
43
+ - [Events System](#events-system)
44
+ - [Event Architecture](#event-architecture)
45
+ - [All Available Events](#all-available-events)
46
+ - [Initialize Events](#initialize-events)
47
+ - [Chunk Upload Events](#chunk-upload-events)
48
+ - [Upload State Events](#upload-state-events)
49
+ - [Completion Events](#completion-events)
50
+ - [Metadata Events](#metadata-events)
51
+ - [Event Classes Reference](#event-classes-reference)
52
+ - [`UploadProgressEvent`](#uploadprogressevent)
53
+ - [`UploadStateChangedEvent`](#uploadstatechangedevent)
54
+ - [`UploadMediaCompleteEvent`](#uploadmediacompleteevent)
55
+ - [`UploadCancelledEvent`](#uploadcancelledevent)
56
+ - [`UploadPausedEvent`](#uploadpausedevent)
57
+ - [`UploadResumedEvent`](#uploadresumedevent)
58
+ - [`ChunkUploadHttpErrorResponseEvent`](#chunkuploadhttperrorresponseevent)
59
+ - [`InitializeUploadStartedEvent`](#initializeuploadstartedevent)
60
+ - [`InitializeUploadSuccessEvent`](#initializeuploadsuccessevent)
61
+ - [`InitializeUploadFailureEvent`](#initializeuploadfailureevent)
62
+ - [`ResumeUploadEvent`](#resumeuploadevent)
63
+ - [Subscribers — Upload Lifecycle Handlers](#subscribers--upload-lifecycle-handlers)
64
+ - [⚠️ Important — You Must Register the Subscribers](#️-important--you-must-register-the-subscribers)
65
+ - [Registration](#registration)
66
+ - [Browser Environment](#browser-environment)
67
+ - [Node.js Environment](#nodejs-environment)
68
+ - [Complete Setup Example](#complete-setup-example)
69
+ - [Browser](#browser)
70
+ - [Node.js](#nodejs)
71
+ - [InitializeUploadSubscriber](#initializeuploadsubscriber)
72
+ - [What It Does Internally](#what-it-does-internally)
73
+ - [Events It Dispatches](#events-it-dispatches)
74
+ - [Retry Behavior](#retry-behavior)
75
+ - [Error Cases Handled](#error-cases-handled)
76
+ - [FinalizeUploadSubscriber](#finalizeuploadsubscriber)
77
+ - [What It Does Internally](#what-it-does-internally-1)
78
+ - [Events It Dispatches](#events-it-dispatches-1)
79
+ - [Error Cases Handled](#error-cases-handled-1)
80
+ - [Subscriber Priority](#subscriber-priority)
81
+ - [Common Mistakes](#common-mistakes)
82
+ - [❌ Forgetting to register subscribers](#-forgetting-to-register-subscribers)
83
+ - [❌ Registering subscribers AFTER calling upload()](#-registering-subscribers-after-calling-upload)
84
+ - [✅ Correct order](#-correct-order)
85
+ - [Subscribers](#subscribers)
86
+ - [InitializeUploadSubscriber](#initializeuploadsubscriber-1)
87
+ - [FinalizeUploadSubscriber](#finalizeuploadsubscriber-1)
88
+ - [Types Reference](#types-reference)
89
+ - [`UploadState` (enum)](#uploadstate-enum)
90
+ - [`ChunkInfo`](#chunkinfo)
91
+ - [`UploadProgress`](#uploadprogress)
92
+ - [`ResumeData`](#resumedata)
93
+ - [`UploadResult`](#uploadresult)
94
+ - [`ChunkError`](#chunkerror)
95
+ - [`InitializeUploadResponse`](#initializeuploadresponse)
96
+ - [Exceptions](#exceptions)
97
+ - [`InitializeUploadFailureException`](#initializeuploadfailureexception)
98
+ - [`FileUploadChunkError`](#fileuploadchunkerror)
99
+ - [`ChunkUploadHttpErrorException`](#chunkuploadhttperrorexception)
100
+ - [`UploadCancelledException`](#uploadcancelledexception)
101
+ - [Advanced Usage](#advanced-usage)
102
+ - [Concurrency Control](#concurrency-control)
103
+ - [Pause and Resume](#pause-and-resume)
104
+ - [Cancel Upload](#cancel-upload)
105
+ - [Resumable Upload](#resumable-upload)
106
+ - [Custom Logger](#custom-logger)
107
+ - [Custom Cache Implementation](#custom-cache-implementation)
108
+ - [Environment-Specific Examples](#environment-specific-examples)
109
+ - [Browser Example](#browser-example)
110
+ - [Node.js Example](#nodejs-example)
111
+ - [Server-Side Integration Guide](#server-side-integration-guide)
112
+ - [Init Endpoint](#init-endpoint)
113
+ - [Chunk Upload Endpoint](#chunk-upload-endpoint)
114
+ - [Finalize Endpoint](#finalize-endpoint)
115
+ - [Error Handling](#error-handling)
116
+ - [Complete Error Handling Example](#complete-error-handling-example)
117
+ - [Contributing](#contributing)
118
+ - [Development Setup](#development-setup)
119
+ - [Running Tests](#running-tests)
120
+ - [License](#license)
121
+ - [Support](#support)
122
+
123
+ ---
124
+
125
+ ## Overview
126
+
127
+ `@wlindabla/file_uploader` is a **universal chunked file upload library** built on top of
128
+ [`@wlindabla/http_client`](https://github.com/Agbokoudjo/http_client) and
129
+ [`@wlindabla/event_dispatcher`](https://github.com/Agbokoudjo/event_dispatcher).
130
+
131
+ It splits large files into small chunks, uploads them in parallel with configurable
132
+ concurrency, and provides a **Symfony-inspired event system** so your application
133
+ can react to every stage of the upload lifecycle — from initialization to completion.
134
+
135
+ The library is designed to work seamlessly in **browsers** (via the File API) and
136
+ in **Node.js** (server-to-server transfers), with full TypeScript support.
137
+
138
+ ---
139
+
140
+ ## Features
141
+
142
+ - 🚀 **Chunked Upload** — Split large files into manageable chunks
143
+ - ⚡ **Concurrent Uploads** — Upload multiple chunks in parallel (configurable)
144
+ - 🔄 **Resumable** — Resume interrupted uploads from where they stopped
145
+ - 🎯 **Event-Driven** — Rich Symfony-style event system for full lifecycle control
146
+ - 🌐 **Universal** — Works in Browser and Node.js
147
+ - 💪 **TypeScript-First** — Full type safety with generics
148
+ - 🔌 **Extensible** — Bring your own cache, logger, and event dispatcher
149
+ - 🛡️ **Robust** — Built-in retry logic, exponential backoff, and error handling
150
+ - 🪶 **Lightweight** — Minimal core dependencies
151
+
152
+ ---
153
+
154
+ ## Installation
155
+
156
+ ```bash
157
+ # Using yarn (recommended)
158
+ yarn add @wlindabla/file_uploader @wlindabla/http_client @wlindabla/event_dispatcher
159
+
160
+ # Using npm
161
+ npm install @wlindabla/file_uploader @wlindabla/http_client @wlindabla/event_dispatcher
162
+
163
+ # Using pnpm
164
+ pnpm add @wlindabla/file_uploader @wlindabla/http_client @wlindabla/event_dispatcher
165
+ ```
166
+
167
+ ---
168
+
169
+ ## Requirements
170
+
171
+ | Requirement | Version |
172
+ |-------------|---------|
173
+ | Node.js | >= 18.0.0 |
174
+ | TypeScript | >= 5.3.0 |
175
+ | `@wlindabla/http_client` | >=^1.0.0 |
176
+ | `@wlindabla/event_dispatcher` | >=^1.0.0 |
177
+
178
+ ---
179
+
180
+ ## Quick Start
181
+
182
+ ### Browser (Minimal Example)
183
+
184
+ ```typescript
185
+ import {
186
+ ChunkedFileUploader,
187
+ HttpFileUploaderEvents,
188
+ UploadStateChangedEvent,
189
+ UploadProgressEvent,
190
+ UploadMediaCompleteEvent
191
+ } from '@wlindabla/file_uploader';
192
+
193
+ import { BrowserEventDispatcher } from '@wlindabla/event_dispatcher';
194
+
195
+ // 1. Implement your cache (localStorage example)
196
+ class LocalStorageCache {
197
+ async getItem(key: string) {
198
+ const item = localStorage.getItem(key);
199
+ return item ? JSON.parse(item) : null;
200
+ }
201
+
202
+ async setItem(key: string, value: any) {
203
+ localStorage.setItem(key, JSON.stringify(value));
204
+ }
205
+
206
+ async removeItem(key: string) {
207
+ localStorage.removeItem(key);
208
+ }
209
+ }
210
+
211
+ // 2. Create the event dispatcher
212
+ const dispatcher = new BrowserEventDispatcher();
213
+
214
+ // 3. Listen to upload events
215
+ dispatcher.addListener(
216
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_SUCCESS,
217
+ (event: UploadProgressEvent) => {
218
+ console.log(`Progress: ${event.percentage}%`);
219
+ }
220
+ );
221
+
222
+ dispatcher.addListener(
223
+ HttpFileUploaderEvents.DOWNLOAD_MEDIA_COMPLETE,
224
+ (event: UploadMediaCompleteEvent) => {
225
+ console.log('Upload complete! File ID:', event.mediaId);
226
+ }
227
+ );
228
+
229
+ // 4. Create the uploader
230
+ const uploader = new ChunkedFileUploader(
231
+ dispatcher,
232
+ new LocalStorageCache(),
233
+ {
234
+ maxRetries: 3,
235
+ concurrency: 3,
236
+ timeout: 60000
237
+ }
238
+ );
239
+
240
+ // 5. Set the file and endpoints, then upload
241
+ const fileInput = document.getElementById('file') as HTMLInputElement;
242
+ const file = fileInput.files![0];
243
+
244
+ await uploader
245
+ .withFile(file)
246
+ .withEndpoints({
247
+ init: 'https://api.example.com/upload/init',
248
+ upload: 'https://api.example.com/upload/chunk',
249
+ finalize: 'https://api.example.com/upload/finalize'
250
+ })
251
+ .upload();
252
+ ```
253
+
254
+ ---
255
+
256
+ ## Core Concepts
257
+
258
+ ### How It Works
259
+
260
+ ```
261
+ ┌─────────────────────────────────────────────────────────────────┐
262
+ │ YOUR APPLICATION │
263
+ │ dispatcher.addListener(HttpFileUploaderEvents.*, handler) │
264
+ └──────────────────────────────┬──────────────────────────────────┘
265
+ │ listens to
266
+
267
+ ┌───────────────▼──────────────────┐
268
+ │ UPLOAD EVENTS (public) │
269
+ │ HttpFileUploaderEvents.* │
270
+ │ e.g. MEDIA_CHUNK_UPLOAD_SUCCESS │
271
+ └───────────────▲──────────────────┘
272
+ │ emitted by
273
+
274
+ ┌───────────────┴──────────────────┐
275
+ │ ChunkedFileUploader │
276
+ │ - Slices file into chunks │
277
+ │ - Manages concurrency (p-limit) │
278
+ │ - Manages state machine │
279
+ └──────┬───────────────┬────────────┘
280
+ │ │
281
+ ┌─────────▼───┐ ┌───────▼──────────┐
282
+ │ safeFetch │ │ Subscribers │
283
+ │ (init & │ │ Initialize & │
284
+ │ finalize) │ │ Finalize Upload │
285
+ └─────────────┘ └──────────────────┘
286
+ ```
287
+
288
+ ### Upload Lifecycle
289
+
290
+ ```
291
+ IDLE → INITIALIZING → UPLOADING → FINALIZING → COMPLETED
292
+ ↓ ↓
293
+ FAILED PAUSED ↔ UPLOADING
294
+
295
+ CANCELLED
296
+ ```
297
+
298
+ ### The Three-Endpoint Pattern
299
+
300
+ Your server must expose **three endpoints**:
301
+
302
+ | Endpoint | Method | Purpose |
303
+ |----------|--------|---------|
304
+ | `init` | POST | Creates an upload session, returns a `mediaId` |
305
+ | `upload` | POST | Receives individual file chunks |
306
+ | `finalize` | POST | Assembles chunks and finalizes the upload |
307
+
308
+ ---
309
+
310
+ ## API Reference
311
+
312
+ ### ChunkedFileUploader
313
+
314
+ The main class. Implements `ChunkedFileUploaderInterface`.
315
+
316
+ #### Constructor
317
+
318
+ ```typescript
319
+ new ChunkedFileUploader(
320
+ uploadEventDispatcher: EventDispatcherInterface,
321
+ uploadResumeData: UploadResumeCacheInterface,
322
+ options: UploadOptions,
323
+ logger?: LoggerInterface
324
+ )
325
+ ```
326
+
327
+ | Parameter | Type | Required | Description |
328
+ |-----------|------|----------|-------------|
329
+ | `uploadEventDispatcher` | `EventDispatcherInterface` | ✅ | Dispatcher for upload lifecycle events |
330
+ | `uploadResumeData` | `UploadResumeCacheInterface` | ✅ | Cache implementation for resumable uploads |
331
+ | `options` | `UploadOptions` | ✅ | Upload configuration options |
332
+ | `logger` | `LoggerInterface` | ❌ | Custom logger (default: `NoopLogger`) |
333
+
334
+ #### Methods
335
+
336
+ ##### `.withFile(file: File): this`
337
+
338
+ Sets the file to upload. **Must be called before `.upload()`.**
339
+
340
+ ```typescript
341
+ uploader.withFile(file);
342
+ ```
343
+
344
+ | Throws | Condition |
345
+ |--------|-----------|
346
+ | `Error` | If `file` is null or undefined |
347
+ | `Error` | If `file.size === 0` (empty file) |
348
+ | `TypeError` | If value is not a `File` or `Blob` instance |
349
+
350
+ ---
351
+
352
+ ##### `.withEndpoints(endpoints: UploadEndpoints): this`
353
+
354
+ Sets the server endpoints. **Must be called before `.upload()`.**
355
+
356
+ ```typescript
357
+ uploader.withEndpoints({
358
+ init: 'https://api.example.com/upload/init',
359
+ upload: 'https://api.example.com/upload/chunk',
360
+ finalize: 'https://api.example.com/upload/finalize'
361
+ });
362
+ ```
363
+
364
+ | Throws | Condition |
365
+ |--------|-----------|
366
+ | `Error` | If any endpoint is missing |
367
+
368
+ ---
369
+
370
+ ##### `.upload(): Promise<void>`
371
+
372
+ Starts the upload process. Internally:
373
+ 1. Hashes the file (SHA-256 of first 1MB)
374
+ 2. Dispatches `INITIALIZE_UPLOAD` → handled by `InitializeUploadSubscriber`
375
+ 3. Splits file into chunks and uploads them with concurrency control
376
+ 4. Dispatches `FINALIZE_UPLOAD` → handled by `FinalizeUploadSubscriber`
377
+
378
+ ```typescript
379
+ await uploader
380
+ .withFile(file)
381
+ .withEndpoints(endpoints)
382
+ .upload();
383
+ ```
384
+
385
+ | Throws | Condition |
386
+ |--------|-----------|
387
+ | `Error` | If `.withFile()` was not called |
388
+ | `Error` | If `.withEndpoints()` was not called |
389
+ | `InitializeUploadFailureException` | If the server init request fails |
390
+ | `FileUploadChunkError` | If a chunk fails after all retries |
391
+
392
+ ---
393
+
394
+ ##### `.pause(): void`
395
+
396
+ Pauses the upload. The current chunk finishes uploading before pausing.
397
+
398
+ ```typescript
399
+ uploader.pause();
400
+ ```
401
+
402
+ Dispatches: `HttpFileUploaderEvents.UPLOAD_PAUSED`
403
+
404
+ ---
405
+
406
+ ##### `.resume(): void`
407
+
408
+ Resumes a paused upload.
409
+
410
+ ```typescript
411
+ uploader.resume();
412
+ ```
413
+
414
+ Dispatches: `HttpFileUploaderEvents.UPLOAD_RESUMED`
415
+
416
+ ---
417
+
418
+ ##### `.cancel(): void`
419
+
420
+ Cancels the upload immediately using `AbortController`.
421
+
422
+ ```typescript
423
+ uploader.cancel();
424
+ ```
425
+
426
+ Dispatches: `HttpFileUploaderEvents.UPLOAD_CANCELLED`
427
+
428
+ ---
429
+
430
+ ##### `.getState(): UploadState`
431
+
432
+ Returns the current state of the upload.
433
+
434
+ ```typescript
435
+ const state = uploader.getState();
436
+ // 'idle' | 'initializing' | 'uploading' | 'paused' | 'cancelled' | 'finalizing' | 'completed' | 'failed'
437
+ ```
438
+
439
+ ---
440
+
441
+ ##### `.resumeUpload(resumeData: ResumeData): Promise<void>`
442
+
443
+ Resumes an upload from a saved state (after page refresh or network failure).
444
+
445
+ ```typescript
446
+ const resumeData = await uploader.loadResumeData('my-video.mp4');
447
+
448
+ if (resumeData) {
449
+ await uploader
450
+ .withFile(file)
451
+ .withEndpoints(endpoints)
452
+ .resumeUpload(resumeData);
453
+ }
454
+ ```
455
+
456
+ ---
457
+
458
+ ##### `.loadResumeData(fileName: string): Promise<ResumeData | null>`
459
+
460
+ Loads previously saved upload progress from the cache.
461
+
462
+ ```typescript
463
+ const resumeData = await uploader.loadResumeData('my-video.mp4');
464
+
465
+ if (resumeData) {
466
+ console.log(`${resumeData.uploadedChunks} chunks already uploaded`);
467
+ }
468
+ ```
469
+
470
+ ---
471
+
472
+ ### HttpFileUploaderEvents
473
+
474
+ Abstract class containing all event name constants.
475
+ **This is the single source of truth for event names.**
476
+
477
+ ```typescript
478
+ import { HttpFileUploaderEvents } from '@wlindabla/file_uploader';
479
+
480
+ dispatcher.addListener(HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_SUCCESS, handler);
481
+ ```
482
+
483
+ > See [All Available Events](#all-available-events) for the complete list.
484
+
485
+ ---
486
+
487
+ ### UploadOptions
488
+
489
+ ```typescript
490
+ interface UploadOptions {
491
+ // Chunk configuration
492
+ chunkSize?: number; // Chunk size in bytes (auto-calculated if not set)
493
+ speedMbps?: number; // Connection speed hint for auto chunk size calculation
494
+ config?: ChunkSizeConfig; // Custom chunk size thresholds
495
+
496
+ // Retry & concurrency
497
+ maxRetries?: number; // Max retry attempts per chunk (default: 3)
498
+ concurrency?: number; // Max parallel chunk uploads (default: 3)
499
+
500
+ // Timeouts
501
+ timeout?: number; // Chunk upload timeout in ms (default: 60000)
502
+ initTimeout?: number; // Init request timeout in ms (default: 45000)
503
+
504
+ // Headers
505
+ headers?: HeadersInit; // Headers for chunk upload requests
506
+ headerInitialzingUpload?: HeadersInit; // Headers for init request
507
+ headerFinalezingUpload?: HeadersInit; // Headers for finalize request
508
+
509
+ // Metadata
510
+ metadata?: Record<string, any>; // Extra data sent to the init endpoint
511
+ chunkOtherData?: Record<string, any>; // Extra data appended to each chunk FormData
512
+
513
+ // Resume
514
+ autoSave?: boolean; // Auto-save progress after each chunk (default: false)
515
+ }
516
+ ```
517
+
518
+ #### Chunk Size Auto-Calculation
519
+
520
+ When `chunkSize` is not provided, the library automatically calculates the optimal
521
+ chunk size based on `fileSize` and the `config` thresholds:
522
+
523
+ ```typescript
524
+ // Default thresholds (DEFAULT_CONFIG)
525
+ {
526
+ defaultChunkSizeMB: 50,
527
+ slowSpeedThresholdMbps: 5,
528
+ slowSpeedChunkSizeMB: 2,
529
+ fileSizeThresholds: [
530
+ { maxSizeMB: 200, chunkSizeMB: 50 },
531
+ { maxSizeMB: 400, chunkSizeMB: 100 },
532
+ { maxSizeMB: 800, chunkSizeMB: 300 },
533
+ { maxSizeMB: 1000, chunkSizeMB: 500 },
534
+ { maxSizeMB: Infinity, chunkSizeMB: 700 }
535
+ ]
536
+ }
537
+ ```
538
+
539
+ ---
540
+
541
+ ### UploadEndpoints
542
+
543
+ ```typescript
544
+ interface UploadEndpoints {
545
+ init: string | URL; // Upload session initialization endpoint
546
+ upload: string | URL; // Chunk upload endpoint
547
+ finalize: string | URL; // Upload finalization endpoint
548
+ }
549
+ ```
550
+
551
+ ---
552
+
553
+ ### UploadResumeCacheInterface
554
+
555
+ Your cache implementation **must** implement this interface.
556
+ The library does **not** provide a default implementation to avoid
557
+ unnecessary dependencies (localStorage, IndexedDB, Redis, filesystem, etc.).
558
+
559
+ ```typescript
560
+ interface UploadResumeCacheInterface {
561
+ getItem(key: string): Promise<ResumeData | null>;
562
+ setItem(key: string, value: ResumeData): Promise<void>;
563
+ removeItem(key: string): Promise<void>;
564
+ }
565
+ ```
566
+
567
+ #### Example Implementations
568
+
569
+ ##### Browser — localStorage
570
+
571
+ ```typescript
572
+ class LocalStorageCache implements UploadResumeCacheInterface {
573
+ async getItem(key: string): Promise<ResumeData | null> {
574
+ const item = localStorage.getItem(key);
575
+ return item ? JSON.parse(item) : null;
576
+ }
577
+
578
+ async setItem(key: string, value: ResumeData): Promise<void> {
579
+ localStorage.setItem(key, JSON.stringify(value));
580
+ }
581
+
582
+ async removeItem(key: string): Promise<void> {
583
+ localStorage.removeItem(key);
584
+ }
585
+ }
586
+ ```
587
+
588
+ ##### Browser — IndexedDB
589
+
590
+ ```typescript
591
+ class IndexedDBCache implements UploadResumeCacheInterface {
592
+ private db: IDBDatabase | null = null;
593
+
594
+ private async openDB(): Promise<IDBDatabase> {
595
+ return new Promise((resolve, reject) => {
596
+ const request = indexedDB.open('file-uploader-cache', 1);
597
+ request.onupgradeneeded = () => {
598
+ request.result.createObjectStore('uploads');
599
+ };
600
+ request.onsuccess = () => resolve(request.result);
601
+ request.onerror = () => reject(request.error);
602
+ });
603
+ }
604
+
605
+ async getItem(key: string): Promise<ResumeData | null> {
606
+ const db = await this.openDB();
607
+ return new Promise((resolve, reject) => {
608
+ const tx = db.transaction('uploads', 'readonly');
609
+ const request = tx.objectStore('uploads').get(key);
610
+ request.onsuccess = () => resolve(request.result ?? null);
611
+ request.onerror = () => reject(request.error);
612
+ });
613
+ }
614
+
615
+ async setItem(key: string, value: ResumeData): Promise<void> {
616
+ const db = await this.openDB();
617
+ return new Promise((resolve, reject) => {
618
+ const tx = db.transaction('uploads', 'readwrite');
619
+ tx.objectStore('uploads').put(value, key);
620
+ tx.oncomplete = () => resolve();
621
+ tx.onerror = () => reject(tx.error);
622
+ });
623
+ }
624
+
625
+ async removeItem(key: string): Promise<void> {
626
+ const db = await this.openDB();
627
+ return new Promise((resolve, reject) => {
628
+ const tx = db.transaction('uploads', 'readwrite');
629
+ tx.objectStore('uploads').delete(key);
630
+ tx.oncomplete = () => resolve();
631
+ tx.onerror = () => reject(tx.error);
632
+ });
633
+ }
634
+ }
635
+ ```
636
+
637
+ ##### Node.js — Redis
638
+
639
+ ```typescript
640
+ import { createClient } from 'redis';
641
+
642
+ class RedisCache implements UploadResumeCacheInterface {
643
+ private client = createClient({ url: process.env.REDIS_URL });
644
+
645
+ constructor() {
646
+ this.client.connect();
647
+ }
648
+
649
+ async getItem(key: string): Promise<ResumeData | null> {
650
+ const item = await this.client.get(key);
651
+ return item ? JSON.parse(item) : null;
652
+ }
653
+
654
+ async setItem(key: string, value: ResumeData): Promise<void> {
655
+ // TTL: 24 hours
656
+ await this.client.set(key, JSON.stringify(value), { EX: 86400 });
657
+ }
658
+
659
+ async removeItem(key: string): Promise<void> {
660
+ await this.client.del(key);
661
+ }
662
+ }
663
+ ```
664
+
665
+ ##### Node.js — Filesystem
666
+
667
+ ```typescript
668
+ import fs from 'fs/promises';
669
+ import path from 'path';
670
+
671
+ class FileSystemCache implements UploadResumeCacheInterface {
672
+ constructor(private readonly cacheDir: string = '/tmp/upload-cache') {
673
+ fs.mkdir(cacheDir, { recursive: true }).catch(() => {});
674
+ }
675
+
676
+ private filePath(key: string): string {
677
+ return path.join(this.cacheDir, `${key}.json`);
678
+ }
679
+
680
+ async getItem(key: string): Promise<ResumeData | null> {
681
+ try {
682
+ const content = await fs.readFile(this.filePath(key), 'utf-8');
683
+ return JSON.parse(content);
684
+ } catch {
685
+ return null;
686
+ }
687
+ }
688
+
689
+ async setItem(key: string, value: ResumeData): Promise<void> {
690
+ await fs.writeFile(this.filePath(key), JSON.stringify(value), 'utf-8');
691
+ }
692
+
693
+ async removeItem(key: string): Promise<void> {
694
+ await fs.unlink(this.filePath(key)).catch(() => {});
695
+ }
696
+ }
697
+ ```
698
+
699
+ ## Events System
700
+
701
+ ### Event Architecture
702
+
703
+ The library uses a **two-level event architecture**:
704
+
705
+ ```
706
+ YOUR APPLICATION
707
+ └── listens to Upload Events (public)
708
+ └── dispatched by ChunkedFileUploader
709
+ └── which internally handles HTTP events (private)
710
+ ```
711
+
712
+ You **only need to interact with Upload Events** via `HttpFileUploaderEvents.*`.
713
+ HTTP-level events are handled internally by the library's subscribers.
714
+
715
+ ### All Available Events
716
+
717
+ #### Initialize Events
718
+
719
+ | Constant | Value | Dispatched When |
720
+ |----------|-------|-----------------|
721
+ | `INITIALIZE_UPLOAD` | `"initializeUpload"` | Upload session is about to start (internal) |
722
+ | `INITIALIZE_UPLOAD_STARTED` | `"initializeUploadStarted"` | Init HTTP request is sent |
723
+ | `INITIALIZE_UPLOAD_SUCCESS` | `"initializeUploadSuccess"` | Server confirmed the session |
724
+ | `INITIALIZE_UPLOAD_FAILURE` | `"initializeUploadFailure"` | Init request failed |
725
+
726
+ #### Chunk Upload Events
727
+
728
+ | Constant | Value | Dispatched When |
729
+ |----------|-------|-----------------|
730
+ | `MEDIA_CHUNK_UPLOAD_STARTED` | `"mediaChunkUploadStarted"` | A chunk is about to be uploaded |
731
+ | `MEDIA_CHUNK_UPLOAD_SUCCESS` | `"mediaChunkUploadSuccess"` | A chunk was uploaded successfully |
732
+ | `MEDIA_CHUNK_UPLOAD_FAILED` | `"mediaChunkUploadFailed"` | A chunk upload attempt failed |
733
+ | `MEDIA_CHUNK_UPLOAD_HTTP_ERROR_RESPONSE` | `"mediaChunkUploadHttpErrorResponse"` | Server returned 4xx/5xx for a chunk |
734
+ | `MEDIA_CHUNK_UPLOAD_STATUS` | `"mediaChunkUploadStatus"` | Chunk status update |
735
+ | `MEDIA_CHUNK_UPLOAD_MAXRETRY_EXPIRE` | `"mediaChunkUploadMaxRetryExpire"` | All retry attempts exhausted |
736
+ | `MEDIA_CHUNK_UPLOAD_RESUME` | `"mediaChunkUploadResume"` | Upload resumed from saved state |
737
+
738
+ #### Upload State Events
739
+
740
+ | Constant | Value | Dispatched When |
741
+ |----------|-------|-----------------|
742
+ | `UPLOAD_PAUSED` | `"uploadPaused"` | Upload was paused |
743
+ | `UPLOAD_RESUMED` | `"uploadResumed"` | Upload was resumed after pause |
744
+ | `UPLOAD_CANCELLED` | `"uploadCancelled"` | Upload was cancelled |
745
+ | `UPLOAD_STATE_CHANGED` | `"uploadStateChanged"` | Any state transition occurs |
746
+
747
+ #### Completion Events
748
+
749
+ | Constant | Value | Dispatched When |
750
+ |----------|-------|-----------------|
751
+ | `FINALIZE_UPLOAD` | `"finalizeUpload"` | Finalize request is about to be sent (internal) |
752
+ | `FINALIZE_UPLOAD_FAILURE` | `"finalizeUploadFailure"` | Finalize request failed |
753
+ | `DOWNLOAD_MEDIA_COMPLETE` | `"downloadMediaComplete"` | Upload fully completed |
754
+ | `DOWNLOAD_MEDIA_FAILURE` | `"downloadMediaFailure"` | Upload failed completely |
755
+ | `DOWNLOAD_MEDIA_RESUME` | `"downloadMediaResume"` | Upload resumed from cache |
756
+
757
+ #### Metadata Events
758
+
759
+ | Constant | Value | Dispatched When |
760
+ |----------|-------|-----------------|
761
+ | `MEDIA_METADATA_SAVE_SUCCESS` | `"mediaMetadataSaveSuccess"` | Media metadata was saved |
762
+
763
+ ---
764
+
765
+ ### Event Classes Reference
766
+
767
+ #### `UploadProgressEvent`
768
+
769
+ Dispatched on `MEDIA_CHUNK_UPLOAD_SUCCESS`.
770
+
771
+ ```typescript
772
+ dispatcher.addListener(
773
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_SUCCESS,
774
+ (event: UploadProgressEvent) => {
775
+ console.log(event.percentage); // 0-100
776
+ console.log(event.uploadedChunks); // number of chunks uploaded
777
+ console.log(event.totalChunks); // total number of chunks
778
+ console.log(event.uploadedBytes); // bytes uploaded so far
779
+ console.log(event.totalBytes); // total file size in bytes
780
+ console.log(event.currentChunk); // current chunk index
781
+ console.log(event.speed); // bytes per second (optional)
782
+ console.log(event.estimatedTimeRemaining); // seconds remaining (optional)
783
+ console.log(event.elapsed); // seconds elapsed
784
+ console.log(event.responseData); // raw server response data
785
+ console.log(event.httpStatus); // HTTP status code
786
+ }
787
+ );
788
+ ```
789
+
790
+ ---
791
+
792
+ #### `UploadStateChangedEvent`
793
+
794
+ Dispatched on `UPLOAD_STATE_CHANGED`.
795
+
796
+ ```typescript
797
+ dispatcher.addListener(
798
+ HttpFileUploaderEvents.UPLOAD_STATE_CHANGED,
799
+ (event: UploadStateChangedEvent) => {
800
+ console.log(event.oldState); // Previous UploadState
801
+ console.log(event.newState); // New UploadState
802
+ console.log(event.changedAt); // Timestamp (Date.now())
803
+ }
804
+ );
805
+ ```
806
+
807
+ ---
808
+
809
+ #### `UploadMediaCompleteEvent`
810
+
811
+ Dispatched on `DOWNLOAD_MEDIA_COMPLETE`.
812
+
813
+ ```typescript
814
+ dispatcher.addListener(
815
+ HttpFileUploaderEvents.DOWNLOAD_MEDIA_COMPLETE,
816
+ (event: UploadMediaCompleteEvent) => {
817
+ console.log(event.success); // boolean
818
+ console.log(event.mediaId); // string | number
819
+ console.log(event.totalBytes); // number
820
+ console.log(event.totalChunks); // number
821
+ console.log(event.operationDuration); // seconds
822
+ console.log(event.averageSpeed); // bytes/second
823
+ console.log(event.finalizeUploadHttpResponse); // server response
824
+ }
825
+ );
826
+ ```
827
+
828
+ ---
829
+
830
+ #### `UploadCancelledEvent`
831
+
832
+ Dispatched on `UPLOAD_CANCELLED`.
833
+
834
+ ```typescript
835
+ dispatcher.addListener(
836
+ HttpFileUploaderEvents.UPLOAD_CANCELLED,
837
+ (event: UploadCancelledEvent) => {
838
+ console.log(event.mediaName); // file name
839
+ console.log(event.totalChunks); // number
840
+ console.log(event.uploadedBytes); // number
841
+ console.log(event.percentage); // number
842
+ console.log(event.currentChunkIndex); // number
843
+ console.log(event.message); // string
844
+ console.log(event.cancelledAt); // timestamp
845
+ }
846
+ );
847
+ ```
848
+
849
+ ---
850
+
851
+ #### `UploadPausedEvent`
852
+
853
+ Dispatched on `UPLOAD_PAUSED`.
854
+
855
+ ```typescript
856
+ dispatcher.addListener(
857
+ HttpFileUploaderEvents.UPLOAD_PAUSED,
858
+ (event: UploadPausedEvent) => {
859
+ console.log(event.mediaName); // file name
860
+ console.log(event.totalChunks); // number
861
+ console.log(event.uploadedBytes); // number
862
+ console.log(event.percentage); // number
863
+ console.log(event.pausedAt); // timestamp
864
+ }
865
+ );
866
+ ```
867
+
868
+ ---
869
+
870
+ #### `UploadResumedEvent`
871
+
872
+ Dispatched on `UPLOAD_RESUMED`.
873
+
874
+ ```typescript
875
+ dispatcher.addListener(
876
+ HttpFileUploaderEvents.UPLOAD_RESUMED,
877
+ (event: UploadResumedEvent) => {
878
+ console.log(event.mediaName); // file name
879
+ console.log(event.totalChunks); // number
880
+ console.log(event.uploadedBytes); // number
881
+ console.log(event.percentage); // number
882
+ console.log(event.resumedAt); // timestamp
883
+ }
884
+ );
885
+ ```
886
+
887
+ ---
888
+
889
+ #### `ChunkUploadHttpErrorResponseEvent`
890
+
891
+ Dispatched on `MEDIA_CHUNK_UPLOAD_HTTP_ERROR_RESPONSE`.
892
+
893
+ ```typescript
894
+ dispatcher.addListener(
895
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_HTTP_ERROR_RESPONSE,
896
+ (event: ChunkUploadHttpErrorResponseEvent) => {
897
+ console.log(event.statusResponse); // 4xx or 5xx status code
898
+ console.log(event.errorPayload); // server error body
899
+ console.log(event.urlEndpoint); // endpoint URL
900
+ console.log(event.chunkIndex); // which chunk failed
901
+ console.log(event.chunkInfo); // full ChunkInfo object
902
+ }
903
+ );
904
+ ```
905
+
906
+ ---
907
+
908
+ #### `InitializeUploadStartedEvent`
909
+
910
+ Dispatched on `INITIALIZE_UPLOAD_STARTED`.
911
+
912
+ ```typescript
913
+ dispatcher.addListener(
914
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_STARTED,
915
+ (event: InitializeUploadStartedEvent) => {
916
+ console.log(event.fileName); // string
917
+ console.log(event.fileSize); // number (bytes)
918
+ console.log(event.fileHash); // SHA-256 hex string
919
+ }
920
+ );
921
+ ```
922
+
923
+ ---
924
+
925
+ #### `InitializeUploadSuccessEvent`
926
+
927
+ Dispatched on `INITIALIZE_UPLOAD_SUCCESS`.
928
+
929
+ ```typescript
930
+ dispatcher.addListener(
931
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_SUCCESS,
932
+ (event: InitializeUploadSuccessEvent) => {
933
+ console.log(event.status); // HTTP status code
934
+ console.log(event.sessionId); // server-assigned session/media ID
935
+ console.log(event.responseData); // full server response
936
+ }
937
+ );
938
+ ```
939
+
940
+ ---
941
+
942
+ #### `InitializeUploadFailureEvent`
943
+
944
+ Dispatched on `INITIALIZE_UPLOAD_FAILURE`.
945
+
946
+ ```typescript
947
+ dispatcher.addListener(
948
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_FAILURE,
949
+ (event: InitializeUploadFailureEvent) => {
950
+ console.log(event.error); // Error instance
951
+ console.log(event.status); // HTTP status (optional)
952
+ console.log(event.errorData); // raw error data (optional)
953
+ console.log(event.isNetworkError); // boolean (optional)
954
+ }
955
+ );
956
+ ```
957
+
958
+ ---
959
+
960
+ #### `ResumeUploadEvent`
961
+
962
+ Dispatched on `MEDIA_CHUNK_UPLOAD_RESUME`.
963
+
964
+ ```typescript
965
+ dispatcher.addListener(
966
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_RESUME,
967
+ (event: ResumeUploadEvent) => {
968
+ console.log(event.mediaId); // string | number
969
+ console.log(event.fileName); // string
970
+ console.log(event.uploadedChunks); // number
971
+ console.log(event.lastChunkIndex); // number
972
+ console.log(event.lastBytePosition); // number
973
+ console.log(event.chunkSize); // number
974
+ console.log(event.fileSize); // number
975
+ console.log(event.message); // info message
976
+ }
977
+ );
978
+ ```
979
+
980
+ ---
981
+
982
+ # Subscribers — Upload Lifecycle Handlers
983
+
984
+ The library ships with two built-in **EventSubscribers** that handle the critical
985
+ HTTP communication phases of the upload lifecycle:
986
+
987
+ | Subscriber | Listens To | Responsibility |
988
+ |------------|-----------|----------------|
989
+ | `InitializeUploadSubscriber` | `HttpFileUploaderEvents.INITIALIZE_UPLOAD` | Opens an upload session with the server |
990
+ | `FinalizeUploadSubscriber` | `HttpFileUploaderEvents.FINALIZE_UPLOAD` | Assembles chunks and closes the upload session |
991
+
992
+ > These subscribers follow the **Symfony EventSubscriber pattern** via
993
+ > `@wlindabla/event_dispatcher`. They must be **registered manually**
994
+ > on your event dispatcher before calling `.upload()`.
995
+
996
+ ---
997
+
998
+ ## ⚠️ Important — You Must Register the Subscribers
999
+
1000
+ Unlike regular event listeners, subscribers are **not auto-registered**.
1001
+ You are responsible for adding them to your dispatcher instance.
1002
+ **If you forget to register them, the upload will fail silently.**
1003
+
1004
+ ---
1005
+
1006
+ ## Registration
1007
+
1008
+ ### Browser Environment
1009
+
1010
+ ```typescript
1011
+ import {
1012
+ InitializeUploadSubscriber,
1013
+ FinalizeUploadSubscriber
1014
+ } from '@wlindabla/file_uploader';
1015
+
1016
+ import { BrowserEventDispatcher } from '@wlindabla/event_dispatcher';
1017
+
1018
+ // BrowserEventDispatcher accepts: document, window, or new EventTarget()
1019
+ const dispatcher = new BrowserEventDispatcher(document);
1020
+ // or: new BrowserEventDispatcher(window)
1021
+ // or: new BrowserEventDispatcher(new EventTarget())
1022
+
1023
+ // ✅ Register both subscribers BEFORE calling .upload()
1024
+ dispatcher.addSubscriber(new InitializeUploadSubscriber(dispatcher));
1025
+ dispatcher.addSubscriber(new FinalizeUploadSubscriber(dispatcher));
1026
+ ```
1027
+
1028
+ ### Node.js Environment
1029
+
1030
+ ```typescript
1031
+ import {
1032
+ InitializeUploadSubscriber,
1033
+ FinalizeUploadSubscriber
1034
+ } from '@wlindabla/file_uploader';
1035
+
1036
+ import { NodeEventDispatcher } from '@wlindabla/event_dispatcher';
1037
+
1038
+ const dispatcher = new NodeEventDispatcher();
1039
+
1040
+ // ✅ Register both subscribers BEFORE calling .upload()
1041
+ dispatcher.addSubscriber(new InitializeUploadSubscriber(dispatcher));
1042
+ dispatcher.addSubscriber(new FinalizeUploadSubscriber(dispatcher));
1043
+ ```
1044
+
1045
+ > **Note:** Both subscribers receive the **same dispatcher instance** as their
1046
+ > constructor argument. They use it internally to dispatch their own
1047
+ > success/failure events back to your application.
1048
+
1049
+ ---
1050
+
1051
+ ## Complete Setup Example
1052
+
1053
+ ### Browser
1054
+
1055
+ ```typescript
1056
+ import {
1057
+ ChunkedFileUploader,
1058
+ InitializeUploadSubscriber,
1059
+ FinalizeUploadSubscriber,
1060
+ HttpFileUploaderEvents,
1061
+ InitializeUploadSuccessEvent,
1062
+ InitializeUploadFailureEvent,
1063
+ FinalizeUploadFailureEvent,
1064
+ UploadMediaCompleteEvent
1065
+ } from '@wlindabla/file_uploader';
1066
+
1067
+ import { BrowserEventDispatcher } from '@wlindabla/event_dispatcher';
1068
+
1069
+ // 1. Create the dispatcher
1070
+ const dispatcher = new BrowserEventDispatcher(document);
1071
+
1072
+ // 2. Register the subscribers ← MANDATORY
1073
+ dispatcher.addSubscriber(new InitializeUploadSubscriber(dispatcher));
1074
+ dispatcher.addSubscriber(new FinalizeUploadSubscriber(dispatcher));
1075
+
1076
+ // 3. (Optional) Listen to events emitted BY the subscribers
1077
+ dispatcher.addListener(
1078
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_STARTED,
1079
+ () => console.log('⏳ Initializing upload session...')
1080
+ );
1081
+
1082
+ dispatcher.addListener(
1083
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_SUCCESS,
1084
+ (event: InitializeUploadSuccessEvent) => {
1085
+ console.log('✅ Session created. Media ID:', event.sessionId);
1086
+ }
1087
+ );
1088
+
1089
+ dispatcher.addListener(
1090
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_FAILURE,
1091
+ (event: InitializeUploadFailureEvent) => {
1092
+ console.error('❌ Init failed:', event.error.message);
1093
+ }
1094
+ );
1095
+
1096
+ dispatcher.addListener(
1097
+ HttpFileUploaderEvents.FINALIZE_UPLOAD_FAILURE,
1098
+ (event: FinalizeUploadFailureEvent) => {
1099
+ console.error('❌ Finalize failed:', event.error.message);
1100
+ }
1101
+ );
1102
+
1103
+ dispatcher.addListener(
1104
+ HttpFileUploaderEvents.DOWNLOAD_MEDIA_COMPLETE,
1105
+ (event: UploadMediaCompleteEvent) => {
1106
+ console.log('🎉 Upload complete! Media ID:', event.mediaId);
1107
+ }
1108
+ );
1109
+
1110
+ // 4. Create and use the uploader
1111
+ const uploader = new ChunkedFileUploader(dispatcher, cache, options);
1112
+
1113
+ await uploader
1114
+ .withFile(file)
1115
+ .withEndpoints(endpoints)
1116
+ .upload();
1117
+ ```
1118
+
1119
+ ---
1120
+
1121
+ ### Node.js
1122
+
1123
+ ```typescript
1124
+ import {
1125
+ ChunkedFileUploader,
1126
+ InitializeUploadSubscriber,
1127
+ FinalizeUploadSubscriber,
1128
+ HttpFileUploaderEvents,
1129
+ InitializeUploadSuccessEvent,
1130
+ FinalizeUploadFailureEvent
1131
+ } from '@wlindabla/file_uploader';
1132
+
1133
+ import { NodeEventDispatcher } from '@wlindabla/event_dispatcher';
1134
+
1135
+ // 1. Create the dispatcher
1136
+ const dispatcher = new NodeEventDispatcher();
1137
+
1138
+ // 2. Register the subscribers ← MANDATORY
1139
+ dispatcher.addSubscriber(new InitializeUploadSubscriber(dispatcher));
1140
+ dispatcher.addSubscriber(new FinalizeUploadSubscriber(dispatcher));
1141
+
1142
+ // 3. (Optional) Listen to events emitted BY the subscribers
1143
+ dispatcher.addListener(
1144
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_SUCCESS,
1145
+ (event: InitializeUploadSuccessEvent) => {
1146
+ console.log('Session ID:', event.sessionId);
1147
+ }
1148
+ );
1149
+
1150
+ dispatcher.addListener(
1151
+ HttpFileUploaderEvents.FINALIZE_UPLOAD_FAILURE,
1152
+ (event: FinalizeUploadFailureEvent) => {
1153
+ console.error('Finalize error:', event.error.message);
1154
+ }
1155
+ );
1156
+
1157
+ // 4. Create and use the uploader
1158
+ const uploader = new ChunkedFileUploader(dispatcher, cache, options);
1159
+
1160
+ await uploader
1161
+ .withFile(file)
1162
+ .withEndpoints(endpoints)
1163
+ .upload();
1164
+ ```
1165
+
1166
+ ---
1167
+
1168
+ ## InitializeUploadSubscriber
1169
+
1170
+ ### What It Does Internally
1171
+
1172
+ ```
1173
+ INITIALIZE_UPLOAD event received
1174
+
1175
+ Dispatch INITIALIZE_UPLOAD_STARTED
1176
+
1177
+ POST → endpoints.init
1178
+
1179
+ ┌───┴────────────────────┐
1180
+ │ response.failed? │
1181
+ │ YES → dispatch │
1182
+ │ INITIALIZE_UPLOAD_ │
1183
+ │ FAILURE + throw │
1184
+ └───┬────────────────────┘
1185
+
1186
+ Validate responseData structure
1187
+
1188
+ Extract sessionId from response
1189
+ (mediaId | mediaIdFromServer
1190
+ | sessionId | uploadId)
1191
+
1192
+ event.setMediaId(sessionId)
1193
+
1194
+ Dispatch INITIALIZE_UPLOAD_SUCCESS
1195
+ ```
1196
+
1197
+ ### Events It Dispatches
1198
+
1199
+ | Event | When |
1200
+ |-------|------|
1201
+ | `INITIALIZE_UPLOAD_STARTED` | Before the HTTP request is sent |
1202
+ | `INITIALIZE_UPLOAD_SUCCESS` | Server returned a valid session ID |
1203
+ | `INITIALIZE_UPLOAD_FAILURE` | HTTP error, network error, or invalid response |
1204
+
1205
+ ### Retry Behavior
1206
+
1207
+ The subscriber uses `safeFetch` internally with:
1208
+
1209
+ ```
1210
+ retryCount: 3
1211
+ retryOnStatusCode: true ← Retries on 5xx errors
1212
+ timeout: 45000 ← 45 seconds
1213
+ ```
1214
+
1215
+ > 4xx errors (client errors) are **not retried** — they dispatch
1216
+ > `INITIALIZE_UPLOAD_FAILURE` immediately.
1217
+
1218
+ ### Error Cases Handled
1219
+
1220
+ | Scenario | Behavior |
1221
+ |----------|----------|
1222
+ | HTTP 4xx from server | Dispatch `INITIALIZE_UPLOAD_FAILURE` + throw |
1223
+ | HTTP 5xx from server | Retry up to 3 times, then dispatch `INITIALIZE_UPLOAD_FAILURE` + throw |
1224
+ | Network error / Timeout | Dispatch `INITIALIZE_UPLOAD_FAILURE` + return silently |
1225
+ | Invalid response structure | Throw `InitializeUploadFailureException` |
1226
+ | Missing session ID in response | Throw `InitializeUploadFailureException` |
1227
+
1228
+ ---
1229
+
1230
+ ## FinalizeUploadSubscriber
1231
+
1232
+ ### What It Does Internally
1233
+
1234
+ ```
1235
+ FINALIZE_UPLOAD event received
1236
+
1237
+ POST → endpoints.finalize
1238
+ body: { mediaId, mediaHash }
1239
+
1240
+ ┌───┴──────────────────┐
1241
+ │ HttpFetchError? │
1242
+ │ YES → dispatch │
1243
+ │ FINALIZE_UPLOAD_ │
1244
+ │ FAILURE + return │
1245
+ └───┬──────────────────┘
1246
+
1247
+ event.setResponse(response)
1248
+ (ChunkedFileUploader reads it
1249
+ to build the UploadResult)
1250
+ ```
1251
+
1252
+ ### Events It Dispatches
1253
+
1254
+ | Event | When |
1255
+ |-------|------|
1256
+ | `FINALIZE_UPLOAD_FAILURE` | Network error or timeout during finalize |
1257
+
1258
+ ### Error Cases Handled
1259
+
1260
+ | Scenario | Behavior |
1261
+ |----------|----------|
1262
+ | Network error / Timeout | Dispatch `FINALIZE_UPLOAD_FAILURE` + return silently |
1263
+ | Any other error | Re-thrown to `ChunkedFileUploader` |
1264
+
1265
+ ---
1266
+
1267
+ ## Subscriber Priority
1268
+
1269
+ Both subscribers are registered with **priority 100**, which means they execute
1270
+ **before** any listeners you add manually at the default priority (0).
1271
+
1272
+ ```typescript
1273
+ // This returns:
1274
+ getSubscribedEvents() {
1275
+ return {
1276
+ [HttpFileUploaderEvents.INITIALIZE_UPLOAD]: {
1277
+ listener: 'onInitializeUpload',
1278
+ priority: 100 // ← Executes first
1279
+ }
1280
+ };
1281
+ }
1282
+ ```
1283
+
1284
+ > If you add your own listener on `INITIALIZE_UPLOAD` or `FINALIZE_UPLOAD`,
1285
+ > it will run **after** the subscriber has already handled the event,
1286
+ > unless you set a priority higher than 100.
1287
+
1288
+ ---
1289
+
1290
+ ## Common Mistakes
1291
+
1292
+ ### ❌ Forgetting to register subscribers
1293
+
1294
+ ```typescript
1295
+ const dispatcher = new BrowserEventDispatcher(document);
1296
+
1297
+ // ❌ Missing addSubscriber() calls!
1298
+
1299
+ const uploader = new ChunkedFileUploader(dispatcher, cache, options);
1300
+ await uploader.upload();
1301
+ // → Upload will hang or fail: no one handles INITIALIZE_UPLOAD
1302
+ ```
1303
+
1304
+ ### ❌ Registering subscribers AFTER calling upload()
1305
+
1306
+ ```typescript
1307
+ const dispatcher = new BrowserEventDispatcher(document);
1308
+
1309
+ uploader.upload(); // ← upload starts immediately
1310
+
1311
+ // ❌ Too late! INITIALIZE_UPLOAD was already dispatched
1312
+ dispatcher.addSubscriber(new InitializeUploadSubscriber(dispatcher));
1313
+ ```
1314
+
1315
+ ### ✅ Correct order
1316
+
1317
+ ```typescript
1318
+ const dispatcher = new BrowserEventDispatcher(document);
1319
+
1320
+ // ✅ 1. Register subscribers first
1321
+ dispatcher.addSubscriber(new InitializeUploadSubscriber(dispatcher));
1322
+ dispatcher.addSubscriber(new FinalizeUploadSubscriber(dispatcher));
1323
+
1324
+ // ✅ 2. Add your own listeners
1325
+ dispatcher.addListener(HttpFileUploaderEvents.DOWNLOAD_MEDIA_COMPLETE, handler);
1326
+
1327
+ // ✅ 3. Then upload
1328
+ await uploader.withFile(file).withEndpoints(endpoints).upload();
1329
+ ```
1330
+
1331
+ ## Subscribers
1332
+
1333
+ The library uses two internal `EventSubscriberInterface` implementations.
1334
+ You do **not** need to instantiate them manually — `ChunkedFileUploader` does it
1335
+ automatically. However, you can **extend** them if needed.
1336
+
1337
+ ### InitializeUploadSubscriber
1338
+
1339
+ Handles the `INITIALIZE_UPLOAD` event internally.
1340
+
1341
+ **What it does:**
1342
+ 1. Sends a POST request to `endpoints.init` using `safeFetch`
1343
+ 2. Validates the server response
1344
+ 3. Extracts the `mediaId`/`sessionId`/`uploadId` from the response
1345
+ 4. Dispatches `INITIALIZE_UPLOAD_STARTED`, `INITIALIZE_UPLOAD_SUCCESS`,
1346
+ or `INITIALIZE_UPLOAD_FAILURE`
1347
+
1348
+ **Expected server response** (any of these keys is accepted):
1349
+
1350
+ ```json
1351
+ {
1352
+ "mediaId": "abc-123",
1353
+ "message": "Upload session created"
1354
+ }
1355
+ ```
1356
+
1357
+ or
1358
+
1359
+ ```json
1360
+ { "sessionId": "abc-123" }
1361
+ ```
1362
+
1363
+ or
1364
+
1365
+ ```json
1366
+ { "uploadId": "abc-123" }
1367
+ ```
1368
+
1369
+ or
1370
+
1371
+ ```json
1372
+ { "mediaIdFromServer": "abc-123" }
1373
+ ```
1374
+
1375
+ ---
1376
+
1377
+ ### FinalizeUploadSubscriber
1378
+
1379
+ Handles the `FINALIZE_UPLOAD` event internally.
1380
+
1381
+ **What it does:**
1382
+ 1. Sends a POST request to `endpoints.finalize` using `safeFetch`
1383
+ 2. Passes `{ mediaId, mediaHash }` in the request body
1384
+ 3. Dispatches `FINALIZE_UPLOAD_FAILURE` on error
1385
+
1386
+ **Request body sent to finalize endpoint:**
1387
+
1388
+ ```json
1389
+ {
1390
+ "mediaId": "abc-123",
1391
+ "mediaHash": "sha256hexstring..."
1392
+ }
1393
+ ```
1394
+
1395
+ ---
1396
+
1397
+ ## Types Reference
1398
+
1399
+ ### `UploadState` (enum)
1400
+
1401
+ ```typescript
1402
+ enum UploadState {
1403
+ IDLE = 'idle',
1404
+ INITIALIZING = 'initializing',
1405
+ UPLOADING = 'uploading',
1406
+ PAUSED = 'paused',
1407
+ CANCELLED = 'cancelled',
1408
+ FINALIZING = 'finalizing',
1409
+ COMPLETED = 'completed',
1410
+ FAILED = 'failed'
1411
+ }
1412
+ ```
1413
+
1414
+ ---
1415
+
1416
+ ### `ChunkInfo`
1417
+
1418
+ ```typescript
1419
+ interface ChunkInfo {
1420
+ index: number; // Zero-based chunk index
1421
+ start: number; // Start byte position in file
1422
+ end: number; // End byte position in file
1423
+ size: number; // Chunk size in bytes
1424
+ attempt: number; // Current attempt number
1425
+ status: UploadStatus; // 'pending' | 'uploading' | 'success' | 'error'
1426
+ }
1427
+ ```
1428
+
1429
+ ---
1430
+
1431
+ ### `UploadProgress`
1432
+
1433
+ ```typescript
1434
+ interface UploadProgress {
1435
+ uploadedChunks: number;
1436
+ totalChunks: number;
1437
+ uploadedBytes: number;
1438
+ totalBytes: number;
1439
+ percentage: number; // 0-100
1440
+ currentChunk: number;
1441
+ speed?: number; // bytes per second
1442
+ estimatedTimeRemaining?: number | null; // seconds
1443
+ elapsed: number; // seconds elapsed
1444
+ }
1445
+ ```
1446
+
1447
+ ---
1448
+
1449
+ ### `ResumeData`
1450
+
1451
+ ```typescript
1452
+ interface ResumeData {
1453
+ fileId: string;
1454
+ fileName: string;
1455
+ fileSize: number;
1456
+ uploadedChunks: number;
1457
+ lastChunkIndex: number;
1458
+ lastBytePosition: number;
1459
+ chunkSize: number;
1460
+ concurrency: number;
1461
+ }
1462
+ ```
1463
+
1464
+ ---
1465
+
1466
+ ### `UploadResult`
1467
+
1468
+ ```typescript
1469
+ interface UploadResult {
1470
+ success: boolean;
1471
+ fileId?: string;
1472
+ totalChunks: number;
1473
+ totalBytes: number;
1474
+ duration: number; // seconds
1475
+ averageSpeed: number; // bytes per second
1476
+ finalizeUploadResponse?: FetchResponseInterface | null;
1477
+ statusResponse?: number;
1478
+ }
1479
+ ```
1480
+
1481
+ ---
1482
+
1483
+ ### `ChunkError`
1484
+
1485
+ ```typescript
1486
+ interface ChunkError {
1487
+ chunk: ChunkInfo;
1488
+ error: Error;
1489
+ attempt: number;
1490
+ willRetry: boolean;
1491
+ }
1492
+ ```
1493
+
1494
+ ---
1495
+
1496
+ ### `InitializeUploadResponse`
1497
+
1498
+ Your server's init endpoint must return an object with **at least one** of these keys:
1499
+
1500
+ ```typescript
1501
+ interface InitializeUploadResponse {
1502
+ mediaId?: string | number;
1503
+ mediaIdFromServer?: string | number;
1504
+ sessionId?: string | number;
1505
+ uploadId?: string | number;
1506
+ message?: string;
1507
+ [key: string]: any; // Additional fields are allowed
1508
+ }
1509
+ ```
1510
+
1511
+ ---
1512
+
1513
+ ## Exceptions
1514
+
1515
+ ### `InitializeUploadFailureException`
1516
+
1517
+ Thrown when the server init request fails or returns an invalid response.
1518
+
1519
+ ```typescript
1520
+ try {
1521
+ await uploader.upload();
1522
+ } catch (error) {
1523
+ if (error instanceof InitializeUploadFailureException) {
1524
+ console.log(error.message); // Error description
1525
+ console.log(error.responseData); // Raw server response
1526
+ }
1527
+ }
1528
+ ```
1529
+
1530
+ ---
1531
+
1532
+ ### `FileUploadChunkError`
1533
+
1534
+ Thrown when a chunk fails after all retry attempts.
1535
+
1536
+ ```typescript
1537
+ try {
1538
+ await uploader.upload();
1539
+ } catch (error) {
1540
+ if (error instanceof FileUploadChunkError) {
1541
+ console.log(error.message); // "Failed to upload chunk X after N attempts"
1542
+ console.log(error.chunkIndex); // Which chunk failed (0-based)
1543
+ console.log(error.attemptNumber); // How many attempts were made
1544
+ console.log(error.willRetry); // false (max retries exhausted)
1545
+ console.log(error.underlyingError); // Original error
1546
+ console.log(error.chunk); // Full ChunkInfo object
1547
+ console.log(error.toJSON()); // JSON representation
1548
+ }
1549
+ }
1550
+ ```
1551
+
1552
+ ---
1553
+
1554
+ ### `ChunkUploadHttpErrorException`
1555
+
1556
+ Thrown internally when the server returns a 4xx/5xx response for a chunk.
1557
+ You can catch it via the `MEDIA_CHUNK_UPLOAD_HTTP_ERROR_RESPONSE` event.
1558
+
1559
+ ```typescript
1560
+ dispatcher.addListener(
1561
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_HTTP_ERROR_RESPONSE,
1562
+ (event: ChunkUploadHttpErrorResponseEvent) => {
1563
+ console.log(event.statusResponse); // e.g. 413 (Payload Too Large)
1564
+ console.log(event.errorPayload); // server error body
1565
+ }
1566
+ );
1567
+ ```
1568
+
1569
+ ---
1570
+
1571
+ ### `UploadCancelledException`
1572
+
1573
+ Thrown when an upload is cancelled via `.cancel()`.
1574
+
1575
+ ```typescript
1576
+ try {
1577
+ await uploader.upload();
1578
+ } catch (error) {
1579
+ if (error instanceof UploadCancelledException) {
1580
+ console.log(error.chunkIndex); // Which chunk was being uploaded
1581
+ console.log(error.totalChunks); // Total number of chunks
1582
+ console.log(error.uploadedBytes); // How many bytes were uploaded
1583
+ }
1584
+ }
1585
+ ```
1586
+
1587
+ ---
1588
+
1589
+ ## Advanced Usage
1590
+
1591
+ ### Concurrency Control
1592
+
1593
+ Control how many chunks are uploaded in parallel.
1594
+
1595
+ ```typescript
1596
+ // Conservative: 1 chunk at a time (slow connections, mobile)
1597
+ new ChunkedFileUploader(dispatcher, cache, { concurrency: 1 });
1598
+
1599
+ // Balanced: 3 chunks in parallel (default, recommended)
1600
+ new ChunkedFileUploader(dispatcher, cache, { concurrency: 3 });
1601
+
1602
+ // Aggressive: 5 chunks in parallel (fast connections)
1603
+ new ChunkedFileUploader(dispatcher, cache, { concurrency: 5 });
1604
+ ```
1605
+
1606
+ **Concurrency visual:**
1607
+
1608
+ ```
1609
+ concurrency = 3
1610
+
1611
+ Time → → → → → →
1612
+ Chunk 0: [████████]
1613
+ Chunk 1: [████████] ← 3 chunks in parallel
1614
+ Chunk 2: [████████]
1615
+ ↓ batch complete
1616
+ Chunk 3: [████████]
1617
+ Chunk 4: [████████] ← next batch
1618
+ Chunk 5: [████████]
1619
+ ```
1620
+
1621
+ > **Note:** Values higher than 5 are not recommended as they may trigger
1622
+ > server-side rate limiting or browser connection limits.
1623
+
1624
+ ---
1625
+
1626
+ ### Pause and Resume
1627
+
1628
+ ```typescript
1629
+ const uploader = new ChunkedFileUploader(dispatcher, cache, options);
1630
+
1631
+ // Start upload
1632
+ uploader.withFile(file).withEndpoints(endpoints);
1633
+ uploader.upload(); // Don't await here if you want to control it
1634
+
1635
+ // Pause after 2 seconds
1636
+ setTimeout(() => {
1637
+ uploader.pause();
1638
+ console.log('Upload paused. State:', uploader.getState()); // 'paused'
1639
+ }, 2000);
1640
+
1641
+ // Resume after 5 seconds
1642
+ setTimeout(() => {
1643
+ uploader.resume();
1644
+ console.log('Upload resumed. State:', uploader.getState()); // 'uploading'
1645
+ }, 5000);
1646
+ ```
1647
+
1648
+ ---
1649
+
1650
+ ### Cancel Upload
1651
+
1652
+ ```typescript
1653
+ const uploader = new ChunkedFileUploader(dispatcher, cache, options);
1654
+
1655
+ // Listen for cancellation
1656
+ dispatcher.addListener(
1657
+ HttpFileUploaderEvents.UPLOAD_CANCELLED,
1658
+ (event: UploadCancelledEvent) => {
1659
+ console.log(`Upload of "${event.mediaName}" was cancelled`);
1660
+ console.log(`${event.percentage}% was completed`);
1661
+ }
1662
+ );
1663
+
1664
+ // Start upload (fire and forget)
1665
+ uploader.withFile(file).withEndpoints(endpoints).upload().catch(() => {});
1666
+
1667
+ // Cancel after 3 seconds
1668
+ setTimeout(() => {
1669
+ uploader.cancel();
1670
+ }, 3000);
1671
+ ```
1672
+
1673
+ ---
1674
+
1675
+ ### Resumable Upload
1676
+
1677
+ Enable upload resumption after page refresh or network failure.
1678
+
1679
+ ```typescript
1680
+ // Step 1: Enable auto-save during upload
1681
+ const uploader = new ChunkedFileUploader(
1682
+ dispatcher,
1683
+ new IndexedDBCache(),
1684
+ {
1685
+ autoSave: true, // ← Save progress after each chunk
1686
+ concurrency: 3
1687
+ }
1688
+ );
1689
+
1690
+ // Step 2: On page load, check for saved progress
1691
+ async function startOrResumeUpload(file: File) {
1692
+ const resumeData = await uploader.loadResumeData(file.name);
1693
+
1694
+ uploader.withFile(file).withEndpoints(endpoints);
1695
+
1696
+ if (resumeData) {
1697
+ const confirmed = confirm(
1698
+ `Resume upload from ${resumeData.uploadedChunks} chunks? ` +
1699
+ `(${Math.round(resumeData.lastBytePosition / 1024 / 1024)}MB already uploaded)`
1700
+ );
1701
+
1702
+ if (confirmed) {
1703
+ // Resume from where we left off
1704
+ await uploader.resumeUpload(resumeData);
1705
+ return;
1706
+ }
1707
+ }
1708
+
1709
+ // Start fresh
1710
+ await uploader.upload();
1711
+ }
1712
+ ```
1713
+
1714
+ ---
1715
+
1716
+ ### Custom Logger
1717
+
1718
+ ```typescript
1719
+ import { LoggerInterface } from '@wlindabla/file_uploader';
1720
+
1721
+ // Example: Send logs to a monitoring service
1722
+ class MonitoringLogger implements LoggerInterface {
1723
+ info(message: string, ...args: any[]) {
1724
+ myMonitoringService.track('info', message, args);
1725
+ }
1726
+ warn(message: string, ...args: any[]) {
1727
+ myMonitoringService.track('warn', message, args);
1728
+ }
1729
+ error(message: string, ...args: any[]) {
1730
+ myMonitoringService.track('error', message, args);
1731
+ alertOnCallTeam(message);
1732
+ }
1733
+ debug(message: string, ...args: any[]) {
1734
+ if (process.env.DEBUG) {
1735
+ console.debug('[FileUploader]', message, ...args);
1736
+ }
1737
+ }
1738
+ }
1739
+
1740
+ const uploader = new ChunkedFileUploader(
1741
+ dispatcher,
1742
+ cache,
1743
+ options,
1744
+ new MonitoringLogger()
1745
+ );
1746
+ ```
1747
+
1748
+ ---
1749
+
1750
+ ### Custom Cache Implementation
1751
+
1752
+ Implement `UploadResumeCacheInterface` with any storage backend you prefer.
1753
+ See [UploadResumeCacheInterface](#uploadresumecacheinterface) for ready-to-use examples.
1754
+
1755
+ ---
1756
+
1757
+ ## Environment-Specific Examples
1758
+
1759
+ ### Browser Example
1760
+
1761
+ Complete browser example with progress bar and UI controls.
1762
+
1763
+ ```typescript
1764
+ import {
1765
+ ChunkedFileUploader,
1766
+ HttpFileUploaderEvents,
1767
+ UploadProgressEvent,
1768
+ UploadStateChangedEvent,
1769
+ UploadMediaCompleteEvent,
1770
+ UploadCancelledEvent,
1771
+ ConsoleLogger
1772
+ } from '@wlindabla/file_uploader';
1773
+
1774
+ import { BrowserEventDispatcher } from '@wlindabla/event_dispatcher';
1775
+
1776
+ // Cache
1777
+ class LocalStorageCache {
1778
+ async getItem(key: string) {
1779
+ const v = localStorage.getItem(key);
1780
+ return v ? JSON.parse(v) : null;
1781
+ }
1782
+ async setItem(key: string, value: any) {
1783
+ localStorage.setItem(key, JSON.stringify(value));
1784
+ }
1785
+ async removeItem(key: string) {
1786
+ localStorage.removeItem(key);
1787
+ }
1788
+ }
1789
+
1790
+ // Setup
1791
+ const dispatcher = new BrowserEventDispatcher();
1792
+
1793
+ // Progress bar
1794
+ dispatcher.addListener(
1795
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_SUCCESS,
1796
+ (event: UploadProgressEvent) => {
1797
+ const bar = document.getElementById('progress-bar') as HTMLElement;
1798
+ const label = document.getElementById('progress-label') as HTMLElement;
1799
+ const speed = document.getElementById('speed') as HTMLElement;
1800
+
1801
+ bar.style.width = `${event.percentage}%`;
1802
+ label.textContent = `${event.percentage}%`;
1803
+
1804
+ if (event.speed) {
1805
+ const mbps = (event.speed / 1024 / 1024).toFixed(2);
1806
+ speed.textContent = `${mbps} MB/s`;
1807
+ }
1808
+
1809
+ if (event.estimatedTimeRemaining) {
1810
+ const eta = document.getElementById('eta') as HTMLElement;
1811
+ eta.textContent = `${Math.ceil(event.estimatedTimeRemaining)}s remaining`;
1812
+ }
1813
+ }
1814
+ );
1815
+
1816
+ // State changes
1817
+ dispatcher.addListener(
1818
+ HttpFileUploaderEvents.UPLOAD_STATE_CHANGED,
1819
+ (event: UploadStateChangedEvent) => {
1820
+ const status = document.getElementById('status') as HTMLElement;
1821
+ status.textContent = event.newState.toUpperCase();
1822
+ }
1823
+ );
1824
+
1825
+ // Completion
1826
+ dispatcher.addListener(
1827
+ HttpFileUploaderEvents.DOWNLOAD_MEDIA_COMPLETE,
1828
+ (event: UploadMediaCompleteEvent) => {
1829
+ const duration = event.operationDuration.toFixed(1);
1830
+ alert(`✅ Upload complete in ${duration}s! Media ID: ${event.mediaId}`);
1831
+ }
1832
+ );
1833
+
1834
+ // Error
1835
+ dispatcher.addListener(
1836
+ HttpFileUploaderEvents.DOWNLOAD_MEDIA_FAILURE,
1837
+ (error: Error) => {
1838
+ alert(`❌ Upload failed: ${error.message}`);
1839
+ }
1840
+ );
1841
+
1842
+ // Uploader instance
1843
+ const uploader = new ChunkedFileUploader(
1844
+ dispatcher,
1845
+ new LocalStorageCache(),
1846
+ {
1847
+ concurrency: 3,
1848
+ maxRetries: 3,
1849
+ autoSave: true,
1850
+ timeout: 60000
1851
+ },
1852
+ new ConsoleLogger()
1853
+ );
1854
+
1855
+ // Wire up DOM
1856
+ document.getElementById('upload-btn')?.addEventListener('click', async () => {
1857
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
1858
+ const file = fileInput.files?.[0];
1859
+
1860
+ if (!file) return alert('Please select a file');
1861
+
1862
+ uploader
1863
+ .withFile(file)
1864
+ .withEndpoints({
1865
+ init: '/api/upload/init',
1866
+ upload: '/api/upload/chunk',
1867
+ finalize: '/api/upload/finalize'
1868
+ });
1869
+
1870
+ try {
1871
+ await uploader.upload();
1872
+ } catch (error) {
1873
+ console.error('Upload failed:', error);
1874
+ }
1875
+ });
1876
+
1877
+ document.getElementById('pause-btn')?.addEventListener('click', () => uploader.pause());
1878
+ document.getElementById('resume-btn')?.addEventListener('click', () => uploader.resume());
1879
+ document.getElementById('cancel-btn')?.addEventListener('click', () => uploader.cancel());
1880
+ ```
1881
+
1882
+ ---
1883
+
1884
+ ### Node.js Example
1885
+
1886
+ Server-to-server file transfer.
1887
+
1888
+ ```typescript
1889
+ import {
1890
+ ChunkedFileUploader,
1891
+ HttpFileUploaderEvents,
1892
+ UploadProgressEvent,
1893
+ UploadMediaCompleteEvent,
1894
+ ConsoleLogger
1895
+ } from '@wlindabla/file_uploader';
1896
+
1897
+ import { NodeEventDispatcher } from '@wlindabla/event_dispatcher';
1898
+ import fs from 'fs';
1899
+
1900
+ // Node.js File-like object
1901
+ function createFileFromPath(filePath: string): File {
1902
+ const buffer = fs.readFileSync(filePath);
1903
+ const blob = new Blob([buffer]);
1904
+ return new File([blob], path.basename(filePath));
1905
+ }
1906
+
1907
+ // Setup
1908
+ const dispatcher = new NodeEventDispatcher();
1909
+
1910
+ // Track progress
1911
+ dispatcher.addListener(
1912
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_SUCCESS,
1913
+ (event: UploadProgressEvent) => {
1914
+ process.stdout.write(`\r⬆️ ${event.percentage}% uploaded...`);
1915
+ }
1916
+ );
1917
+
1918
+ dispatcher.addListener(
1919
+ HttpFileUploaderEvents.DOWNLOAD_MEDIA_COMPLETE,
1920
+ (event: UploadMediaCompleteEvent) => {
1921
+ console.log(`\n✅ Done! Media ID: ${event.mediaId}`);
1922
+ console.log(` Duration: ${event.operationDuration.toFixed(1)}s`);
1923
+ console.log(` Average speed: ${(event.averageSpeed / 1024 / 1024).toFixed(2)} MB/s`);
1924
+ }
1925
+ );
1926
+
1927
+ // File system cache for Node.js
1928
+ class FileSystemCache {
1929
+ async getItem(key: string) {
1930
+ try {
1931
+ const data = fs.readFileSync(`/tmp/${key}.json`, 'utf-8');
1932
+ return JSON.parse(data);
1933
+ } catch { return null; }
1934
+ }
1935
+ async setItem(key: string, value: any) {
1936
+ fs.writeFileSync(`/tmp/${key}.json`, JSON.stringify(value));
1937
+ }
1938
+ async removeItem(key: string) {
1939
+ try { fs.unlinkSync(`/tmp/${key}.json`); } catch {}
1940
+ }
1941
+ }
1942
+
1943
+ // Upload
1944
+ async function uploadFile(filePath: string) {
1945
+ const file = createFileFromPath(filePath);
1946
+
1947
+ const uploader = new ChunkedFileUploader(
1948
+ dispatcher,
1949
+ new FileSystemCache(),
1950
+ {
1951
+ concurrency: 5,
1952
+ maxRetries: 3,
1953
+ autoSave: true,
1954
+ headers: {
1955
+ 'Authorization': `Bearer ${process.env.API_TOKEN}`,
1956
+ 'X-Server-ID': process.env.SERVER_ID ?? 'unknown'
1957
+ }
1958
+ }
1959
+ );
1960
+
1961
+ await uploader
1962
+ .withFile(file)
1963
+ .withEndpoints({
1964
+ init: 'https://api.example.com/upload/init',
1965
+ upload: 'https://api.example.com/upload/chunk',
1966
+ finalize: 'https://api.example.com/upload/finalize'
1967
+ })
1968
+ .upload();
1969
+ }
1970
+
1971
+ uploadFile('/path/to/large-video.mp4').catch(console.error);
1972
+ ```
1973
+
1974
+ ---
1975
+
1976
+ ## Server-Side Integration Guide
1977
+
1978
+ ### Init Endpoint
1979
+
1980
+ **Request received:**
1981
+
1982
+ ```json
1983
+ {
1984
+ "fileName": "video.mp4",
1985
+ "fileSize": 1073741824,
1986
+ "fileType": "video/mp4",
1987
+ "fileHash": "a3f5b9c...",
1988
+ "metadata": { "userId": "42", "albumId": "5" }
1989
+ }
1990
+ ```
1991
+
1992
+ **Expected response (HTTP 200/201):**
1993
+
1994
+ ```json
1995
+ {
1996
+ "mediaId": "upload-session-abc-123",
1997
+ "message": "Upload session created"
1998
+ }
1999
+ ```
2000
+
2001
+ ---
2002
+
2003
+ ### Chunk Upload Endpoint
2004
+
2005
+ **FormData fields received per chunk:**
2006
+
2007
+ | Field | Type | Description |
2008
+ |-------|------|-------------|
2009
+ | `chunk` | `Blob` | The binary chunk data |
2010
+ | `chunkIndex` | `string` | Zero-based chunk index |
2011
+ | `chunkSize` | `string` | Chunk size in bytes |
2012
+ | `totalChunks` | `string` | Total number of chunks |
2013
+ | `fileName` | `string` | Original file name |
2014
+ | `fileSize` | `string` | Total file size in bytes |
2015
+ | `fileHash` | `string` | SHA-256 hash of the file |
2016
+ | `mediaId` | `string` | Session ID from init |
2017
+
2018
+ **Expected response (HTTP 200):**
2019
+
2020
+ ```json
2021
+ {
2022
+ "chunkIndex": 5,
2023
+ "received": true,
2024
+ "message": "Chunk received"
2025
+ }
2026
+ ```
2027
+
2028
+ ---
2029
+
2030
+ ### Finalize Endpoint
2031
+
2032
+ **Request received:**
2033
+
2034
+ ```json
2035
+ {
2036
+ "mediaId": "upload-session-abc-123",
2037
+ "mediaHash": "a3f5b9c..."
2038
+ }
2039
+ ```
2040
+
2041
+ **Expected response (HTTP 200):**
2042
+
2043
+ ```json
2044
+ {
2045
+ "success": true,
2046
+ "fileUrl": "https://cdn.example.com/videos/video.mp4",
2047
+ "mediaId": "upload-session-abc-123"
2048
+ }
2049
+ ```
2050
+
2051
+ ---
2052
+
2053
+ ## Error Handling
2054
+
2055
+ ### Complete Error Handling Example
2056
+
2057
+ ```typescript
2058
+ import {
2059
+ ChunkedFileUploader,
2060
+ HttpFileUploaderEvents,
2061
+ InitializeUploadFailureEvent,
2062
+ FileUploadChunkError,
2063
+ InitializeUploadFailureException,
2064
+ UploadCancelledException
2065
+ } from '@wlindabla/file_uploader';
2066
+
2067
+ // Listen to granular error events
2068
+ dispatcher.addListener(
2069
+ HttpFileUploaderEvents.INITIALIZE_UPLOAD_FAILURE,
2070
+ (event: InitializeUploadFailureEvent) => {
2071
+ console.error('Init failed:', event.error.message);
2072
+ if (event.status === 401) {
2073
+ // Redirect to login
2074
+ }
2075
+ }
2076
+ );
2077
+
2078
+ dispatcher.addListener(
2079
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_HTTP_ERROR_RESPONSE,
2080
+ (event: ChunkUploadHttpErrorResponseEvent) => {
2081
+ console.warn(
2082
+ `Chunk ${event.chunkIndex} got HTTP ${event.statusResponse}`,
2083
+ event.errorPayload
2084
+ );
2085
+ }
2086
+ );
2087
+
2088
+ dispatcher.addListener(
2089
+ HttpFileUploaderEvents.MEDIA_CHUNK_UPLOAD_MAXRETRY_EXPIRE,
2090
+ (error: FileUploadChunkError) => {
2091
+ console.error(
2092
+ `Chunk ${error.chunkIndex} permanently failed after ${error.attemptNumber} attempts`
2093
+ );
2094
+ }
2095
+ );
2096
+
2097
+ dispatcher.addListener(
2098
+ HttpFileUploaderEvents.DOWNLOAD_MEDIA_FAILURE,
2099
+ (error: Error) => {
2100
+ console.error('Upload completely failed:', error.message);
2101
+ }
2102
+ );
2103
+
2104
+ // Handle thrown exceptions
2105
+ try {
2106
+ await uploader.upload();
2107
+ } catch (error) {
2108
+ if (error instanceof UploadCancelledException) {
2109
+ console.log('User cancelled the upload');
2110
+
2111
+ } else if (error instanceof InitializeUploadFailureException) {
2112
+ console.error('Could not start upload session:', error.message);
2113
+
2114
+ } else if (error instanceof FileUploadChunkError) {
2115
+ console.error(`Chunk ${error.chunkIndex} failed:`, error.message);
2116
+ console.error('Underlying cause:', error.underlyingError.message);
2117
+
2118
+ } else {
2119
+ console.error('Unexpected error:', error);
2120
+ }
2121
+ }
2122
+ ```
2123
+
2124
+ ---
2125
+
2126
+ ## Contributing
2127
+
2128
+ Contributions are welcome! Please follow these steps:
2129
+
2130
+ 1. Fork the repository: [github.com/Agbokoudjo/file_uploader](https://github.com/Agbokoudjo/file_uploader)
2131
+ 2. Create your feature branch: `git checkout -b feature/my-feature`
2132
+ 3. Commit your changes: `git commit -m 'feat: add my feature'`
2133
+ 4. Push to the branch: `git push origin feature/my-feature`
2134
+ 5. Open a Pull Request
2135
+
2136
+ ### Development Setup
2137
+
2138
+ ```bash
2139
+ git clone https://github.com/Agbokoudjo/file_uploader.git
2140
+ cd file_uploader
2141
+ yarn install
2142
+ yarn build
2143
+ yarn test
2144
+ ```
2145
+
2146
+ ### Running Tests
2147
+
2148
+ ```bash
2149
+ yarn test # Run all tests
2150
+ yarn test:watch # Watch mode
2151
+ yarn test:coverage # With coverage report
2152
+ yarn test:ui # Vitest UI
2153
+ ```
2154
+
2155
+ ---
2156
+
2157
+ ## License
2158
+
2159
+ MIT © [AGBOKOUDJO Franck — INTERNATIONALES WEB APPS & SERVICES](https://github.com/Agbokoudjo)
2160
+
2161
+ ---
2162
+
2163
+ ## Support
2164
+
2165
+ - 📧 Email: [internationaleswebservices@gmail.com](mailto:internationaleswebservices@gmail.com)
2166
+ - 🐛 Issues: [GitHub Issues](https://github.com/Agbokoudjo/file_uploader/issues)
2167
+ - 💼 LinkedIn: [INTERNATIONALES WEB APPS & SERVICES](https://www.linkedin.com/in/internationales-web-apps-services-120520193/)
2168
+ - 📞 Phone: +229 0167 25 18 86
2169
+
2170
+ ---
2171
+
2172
+ **Built with ❤️ by AGBOKOUDJO Franck — INTERNATIONALES WEB APPS & SERVICES**