From 9b833b2d3e182d6f3a1a329fd247646325c6cbd9 Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 17:02:52 +0300 Subject: [PATCH 1/9] feat(uploader): add chunked file upload Add opt-in chunked uploads to FileUploader. Setting `chunkSize` sends each file as a sequence of requests, one per chunk: - multipart mode sends the chunk as the file field plus `chunkIndex` and `totalChunks` fields (names configurable via `chunkIndexParam` / `totalChunksParam`) - with `disableMultipart` the raw chunk is sent with a `Content-Range` header - `chunkRetries` retries a failed chunk before the item fails - new `onBeforeUploadChunk` / `onCompleteChunk` hooks; changing `item.url`, `item.method` or `item.headers` there targets the next chunk request - `item.cancel()` stops the remaining chunks, also when called between chunks - progress is reported across the whole file Without `chunkSize` uploads behave exactly as before. Thanks to @PauloPeres for the original implementation and API idea in #977. Closes #880 Closes #435 Co-authored-by: Paulo --- README.md | 5 + apps/demo/src/doc.md | 5 + .../file-upload/file-item.class.ts | 6 +- .../file-upload/file-uploader.class.ts | 120 ++++++++- .../testing/spec/file-uploader-chunk.spec.ts | 244 ++++++++++++++++++ 5 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts diff --git a/README.md b/README.md index a9fae039..2121790e 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g 5. `formatDataFunction` - Function to modify the request body. 'DisableMultipart' must be 'true' for this function to be called. 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. + 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). + 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Defaults to 0. + 10. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. + + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. `item.cancel()` stops the remaining chunks. ### Events diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index afd7b8a5..3c391e3a 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -29,6 +29,11 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u 5. `formatDataFunction` - Function to modify the request body. 'DisableMultipart' must be 'true' for this function to be called. 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. + 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). + 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Defaults to 0. + 10. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. + + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. `item.cancel()` stops the remaining chunks. ### Events - `onFileSelected` - fires when files are selected and added to the uploader queue diff --git a/libs/ng2-file-upload/file-upload/file-item.class.ts b/libs/ng2-file-upload/file-upload/file-item.class.ts index 187bb2a6..1ff91b5f 100644 --- a/libs/ng2-file-upload/file-upload/file-item.class.ts +++ b/libs/ng2-file-upload/file-upload/file-item.class.ts @@ -1,5 +1,5 @@ import { FileLikeObject } from './file-like-object.class'; -import { FileUploader, ParsedResponseHeaders, FileUploaderOptions } from './file-uploader.class'; +import { FileUploader, ParsedResponseHeaders, FileUploaderOptions, FileChunk } from './file-uploader.class'; export class FileItem { file: FileLikeObject; @@ -18,8 +18,10 @@ export class FileItem { isError = false; progress = 0; index?: number; + chunk?: FileChunk; _xhr?: XMLHttpRequest; _form: any; + _cancelRequested = false; protected uploader: FileUploader; protected some: File; @@ -91,6 +93,8 @@ export class FileItem { this.isCancel = false; this.isError = false; this.progress = 0; + this.chunk = undefined; + this._cancelRequested = false; this.onBeforeUpload(); } diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index c5f0e58a..61db03cd 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -40,6 +40,19 @@ export interface FileUploaderOptions { // eslint-disable-next-line formatDataFunction?: Function; formatDataFunctionIsAsync?: boolean; + chunkSize?: number; + chunkRetries?: number; + chunkIndexParam?: string; + totalChunksParam?: string; +} + +export interface FileChunk { + index: number; + total: number; + start: number; + end: number; + blob: Blob; + retry: number; } export class FileUploader { @@ -167,6 +180,8 @@ export class FileUploader { const item = this.queue[ index ]; const prop = this.options.isHTML5 ? item._xhr : item._form; if (item && item.isUploading) { + // between chunks there is no request in flight to abort, so the next chunk checks this flag + item._cancelRequested = true; prop.abort(); } } @@ -255,6 +270,14 @@ export class FileUploader { return void 0; } + onBeforeUploadChunk(item: FileItem, chunk: FileChunk): any { + return { item, chunk }; + } + + onCompleteChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): any { + return { item, chunk, response, status, headers }; + } + _mimeTypeFilter(item: FileLikeObject): boolean { return !(item?.type && this.options.allowedMimeType && this.options.allowedMimeType?.indexOf(item.type) === -1); } @@ -299,20 +322,80 @@ export class FileUploader { } protected _xhrTransport(item: FileItem): any { + this._onBeforeUploadItem(item); + + if (typeof item._file.size !== 'number') { + throw new TypeError('The file specified is no longer valid'); + } + if (this.options.chunkSize && this.options.chunkSize > 0) { + this._uploadChunk(item, 0); + + return; + } + this._sendXhr(item); + } + + protected _uploadChunk(item: FileItem, index: number): void { + const chunkSize = this.options.chunkSize as number; + const size = item._file.size; + const start = index * chunkSize; + const end = Math.min(start + chunkSize, size); + const chunk: FileChunk = { + index, + total: Math.max(1, Math.ceil(size / chunkSize)), + start, + end, + blob: item._file.slice(start, end), + retry: 0 + }; + item.chunk = chunk; + this.onBeforeUploadChunk(item, chunk); + this._sendXhr(item, chunk); + } + + /** + * Handles a finished chunk request. + * Returns true when the upload continues (next chunk, retry or cancel), + * false when the result should be reported as the item's final outcome. + */ + protected _onChunkDone(item: FileItem, chunk: FileChunk, isSuccess: boolean, response: string, status: number, + headers: ParsedResponseHeaders): boolean { + if (!isSuccess) { + if (chunk.retry < (this.options.chunkRetries || 0) && !item._cancelRequested) { + item.chunk = { ...chunk, retry: chunk.retry + 1 }; + this._sendXhr(item, item.chunk); + + return true; + } + + return false; + } + this.onCompleteChunk(item, chunk, response, status, headers); + if (chunk.index + 1 >= chunk.total) { + return false; + } + if (item._cancelRequested) { + this._onCancelItem(item, response, status, headers); + this._onCompleteItem(item, response, status, headers); + + return true; + } + this._uploadChunk(item, chunk.index + 1); + + return true; + } + + protected _sendXhr(item: FileItem, chunk?: FileChunk): void { // tslint:disable-next-line:no-this-assignment // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this; const xhr = item._xhr = new XMLHttpRequest(); let sendable: any; - this._onBeforeUploadItem(item); - if (typeof item._file.size !== 'number') { - throw new TypeError('The file specified is no longer valid'); - } if (!this.options.disableMultipart) { sendable = new FormData(); this._onBuildItemForm(item, sendable); - const appendFile = () => sendable.append(item.alias, item._file, item.file.name); + const appendFile = () => sendable.append(item.alias, chunk ? chunk.blob : item._file, item.file.name); if (!this.options.parametersBeforeFiles) { appendFile(); } @@ -329,9 +412,16 @@ export class FileUploader { }); } + if (chunk) { + sendable.append(this.options.chunkIndexParam || 'chunkIndex', chunk.index.toString()); + sendable.append(this.options.totalChunksParam || 'totalChunks', chunk.total.toString()); + } + if (appendFile && this.options.parametersBeforeFiles) { appendFile(); } + } else if (chunk) { + sendable = chunk.blob; } else { if (this.options.formatDataFunction) { sendable = this.options.formatDataFunction(item); @@ -339,13 +429,21 @@ export class FileUploader { } xhr.upload.onprogress = (event: any) => { - const progress = Math.round(event.lengthComputable ? event.loaded * 100 / event.total : 0); + const ratio = event.lengthComputable ? event.loaded / event.total : 0; + const size = item._file.size; + const progress = chunk && size + ? Math.round((chunk.start + ratio * (chunk.end - chunk.start)) * 100 / size) + : Math.round(ratio * 100); this._onProgressItem(item, progress); }; xhr.onload = () => { const headers = this._parseHeaders(xhr.getAllResponseHeaders()); const response = this._transformResponse(xhr.response); - const gist = this._isSuccessCode(xhr.status) ? 'Success' : 'Error'; + const isSuccess = this._isSuccessCode(xhr.status); + if (chunk && this._onChunkDone(item, chunk, isSuccess, response, xhr.status, headers)) { + return; + } + const gist = isSuccess ? 'Success' : 'Error'; const method = `_on${gist}Item`; (this as any)[ method ](item, response, xhr.status, headers); this._onCompleteItem(item, response, xhr.status, headers); @@ -353,6 +451,9 @@ export class FileUploader { xhr.onerror = () => { const headers = this._parseHeaders(xhr.getAllResponseHeaders()); const response = this._transformResponse(xhr.response); + if (chunk && this._onChunkDone(item, chunk, false, response, xhr.status, headers)) { + return; + } this._onErrorItem(item, response, xhr.status, headers); this._onCompleteItem(item, response, xhr.status, headers); }; @@ -379,12 +480,15 @@ export class FileUploader { if (this.authToken && this.authTokenHeader) { xhr.setRequestHeader(this.authTokenHeader, this.authToken); } + if (chunk && this.options.disableMultipart && chunk.end > chunk.start) { + xhr.setRequestHeader('Content-Range', `bytes ${chunk.start}-${chunk.end - 1}/${item._file.size}`); + } xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { that.response.emit(xhr.responseText); } }; - if (this.options.formatDataFunctionIsAsync) { + if (this.options.formatDataFunctionIsAsync && !chunk) { sendable.then( (result: any) => xhr.send(JSON.stringify(result)) ); diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts new file mode 100644 index 00000000..7bce68a8 --- /dev/null +++ b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts @@ -0,0 +1,244 @@ +import { FileUploader, FileUploaderOptions } from '../../file-upload/file-uploader.class'; + +class FakeXhr { + static instances: FakeXhr[] = []; + upload: any = {}; + status = 0; + response = ''; + responseText = ''; + readyState = 0; + withCredentials = false; + method?: string; + url?: string; + body: any; + requestHeaders: { [ name: string ]: string } = {}; + aborted = false; + onload?: () => void; + onerror?: () => void; + onabort?: () => void; + onreadystatechange?: () => void; + + constructor() { + FakeXhr.instances.push(this); + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.requestHeaders[ name ] = value; + } + + send(body: any): void { + this.body = body; + } + + abort(): void { + // like a real XHR, aborting a finished request fires no events + if (this.readyState === 4) { + return; + } + this.aborted = true; + this.onabort?.(); + } + + getAllResponseHeaders(): string { + return ''; + } + + respond(status: number, response = ''): void { + this.status = status; + this.response = response; + this.readyState = 4; + this.onload?.(); + } +} + +const KB = 1024; + +function createUploader(options: Partial): FileUploader { + const uploader = new FileUploader({ url: '/upload', ...options }); + uploader.addToQueue([ new File([ 'x'.repeat(10 * KB) ], 'file.bin') ]); + + return uploader; +} + +function last(): FakeXhr { + return FakeXhr.instances[ FakeXhr.instances.length - 1 ]; +} + +describe('FileUploader: chunked upload', () => { + const originalXhr = (globalThis as any).XMLHttpRequest; + + beforeEach(() => { + FakeXhr.instances = []; + (globalThis as any).XMLHttpRequest = FakeXhr; + (FakeXhr as any).DONE = 4; + }); + + afterEach(() => { + (globalThis as any).XMLHttpRequest = originalXhr; + }); + + it('sends the whole file in one request when chunkSize is not set', () => { + const uploader = createUploader({}); + const success = jest.spyOn(uploader, 'onSuccessItem'); + + uploader.uploadAll(); + last().respond(200); + + expect(FakeXhr.instances.length).toBe(1); + expect((last().body as FormData).get('chunkIndex')).toBeNull(); + expect(success).toHaveBeenCalledTimes(1); + }); + + it('splits the file into chunks and reports success once', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const beforeChunk = jest.spyOn(uploader, 'onBeforeUploadChunk'); + const completeChunk = jest.spyOn(uploader, 'onCompleteChunk'); + const success = jest.spyOn(uploader, 'onSuccessItem'); + const complete = jest.spyOn(uploader, 'onCompleteItem'); + + uploader.uploadAll(); + for (let i = 0; i < 3; i++) { + const form = last().body as FormData; + expect(form.get('chunkIndex')).toBe(String(i)); + expect(form.get('totalChunks')).toBe('3'); + expect((form.get('file') as File).size).toBe(i < 2 ? 4 * KB : 2 * KB); + last().respond(200, `chunk-${i}`); + } + + expect(FakeXhr.instances.length).toBe(3); + expect(beforeChunk).toHaveBeenCalledTimes(3); + expect(completeChunk).toHaveBeenCalledTimes(3); + expect(success).toHaveBeenCalledTimes(1); + expect(success.mock.calls[ 0 ][ 1 ]).toBe('chunk-2'); + expect(complete).toHaveBeenCalledTimes(1); + expect(item.isSuccess).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('uses custom chunk param names', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkIndexParam: 'current_chunk', totalChunksParam: 'total_chunks' }); + + uploader.uploadAll(); + const form = last().body as FormData; + + expect(form.get('current_chunk')).toBe('0'); + expect(form.get('total_chunks')).toBe('3'); + }); + + it('lets onCompleteChunk change the url and method of the next chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + uploader.onCompleteChunk = (item, chunk, response) => { + item.url = `/upload/${response}`; + item.method = 'PUT'; + }; + + uploader.uploadAll(); + last().respond(200, 'abc'); + + expect(last().url).toBe('/upload/abc'); + expect(last().method).toBe('PUT'); + }); + + it('sends raw slices with Content-Range when multipart is disabled', () => { + const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true }); + + uploader.uploadAll(); + expect(last().body).toBeInstanceOf(Blob); + expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes 0-${4 * KB - 1}/${10 * KB}`); + last().respond(200); + expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes ${4 * KB}-${8 * KB - 1}/${10 * KB}`); + }); + + it('reports overall progress across chunks', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + + uploader.uploadAll(); + last().respond(200); + last().upload.onprogress({ lengthComputable: true, loaded: 1, total: 2 }); + + expect(item.progress).toBe(60); + }); + + it('retries a failed chunk and then continues', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2 }); + const error = jest.spyOn(uploader, 'onErrorItem'); + const success = jest.spyOn(uploader, 'onSuccessItem'); + + uploader.uploadAll(); + last().respond(500); + last().onerror?.(); + expect((last().body as FormData).get('chunkIndex')).toBe('0'); + last().respond(200); + last().respond(200); + last().respond(200); + + expect(FakeXhr.instances.length).toBe(5); + expect(error).not.toHaveBeenCalled(); + expect(success).toHaveBeenCalledTimes(1); + }); + + it('fails the item once retries are used up', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + + uploader.uploadAll(); + last().respond(200); + last().respond(500); + last().respond(500); + + expect(FakeXhr.instances.length).toBe(3); + expect(error).toHaveBeenCalledTimes(1); + expect(item.isError).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('cancel aborts the chunk in flight and stops the upload', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const cancel = jest.spyOn(uploader, 'onCancelItem'); + + uploader.uploadAll(); + last().respond(200); + item.cancel(); + + expect(last().aborted).toBe(true); + expect(FakeXhr.instances.length).toBe(2); + expect(cancel).toHaveBeenCalledTimes(1); + expect(item.isCancel).toBe(true); + }); + + it('cancel from onCompleteChunk stops before the next chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const cancel = jest.spyOn(uploader, 'onCancelItem'); + uploader.onCompleteChunk = (fileItem) => fileItem.cancel(); + + uploader.uploadAll(); + last().respond(200); + + expect(FakeXhr.instances.length).toBe(1); + expect(cancel).toHaveBeenCalledTimes(1); + expect(item.isCancel).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('uploads an empty file as a single chunk', () => { + const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB }); + uploader.addToQueue([ new File([], 'empty.txt') ]); + const success = jest.spyOn(uploader, 'onSuccessItem'); + + uploader.uploadAll(); + expect((last().body as FormData).get('totalChunks')).toBe('1'); + last().respond(200); + + expect(success).toHaveBeenCalledTimes(1); + }); +}); From dd7334cefdca982375e13d8e8e9a6595f619edce Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 17:22:03 +0300 Subject: [PATCH 2/9] fix(uploader): fail chunked upload on hook errors, retry only transient failures - errors thrown while sending a later chunk or retry (e.g. from onBeforeUploadChunk / onCompleteChunk) ran outside FileItem.upload()'s try/catch and left the item and uploader stuck in isUploading; they now fail the item - chunkRetries now only retries network errors, 408, 429 and 5xx; other 4xx responses fail immediately instead of repeating requests that can't succeed --- README.md | 2 +- apps/demo/src/doc.md | 2 +- .../file-upload/file-uploader.class.ts | 39 +++++++++-- .../testing/spec/file-uploader-chunk.spec.ts | 67 +++++++++++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2121790e..66cb773f 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). - 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Defaults to 0. + 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx responses are retried. Defaults to 0. 10. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. `item.cancel()` stops the remaining chunks. diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index be2e6e44..c99114d3 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -30,7 +30,7 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). - 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Defaults to 0. + 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx responses are retried. Defaults to 0. 10. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. `item.cancel()` stops the remaining chunks. diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index 61db03cd..685d5197 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -361,16 +361,20 @@ export class FileUploader { protected _onChunkDone(item: FileItem, chunk: FileChunk, isSuccess: boolean, response: string, status: number, headers: ParsedResponseHeaders): boolean { if (!isSuccess) { - if (chunk.retry < (this.options.chunkRetries || 0) && !item._cancelRequested) { - item.chunk = { ...chunk, retry: chunk.retry + 1 }; - this._sendXhr(item, item.chunk); + if (this._isRetryableChunkStatus(status) && chunk.retry < (this.options.chunkRetries || 0) && !item._cancelRequested) { + this._runChunkStep(item, response, status, headers, () => { + item.chunk = { ...chunk, retry: chunk.retry + 1 }; + this._sendXhr(item, item.chunk); + }); return true; } return false; } - this.onCompleteChunk(item, chunk, response, status, headers); + if (!this._runChunkStep(item, response, status, headers, () => this.onCompleteChunk(item, chunk, response, status, headers))) { + return true; + } if (chunk.index + 1 >= chunk.total) { return false; } @@ -380,11 +384,36 @@ export class FileUploader { return true; } - this._uploadChunk(item, chunk.index + 1); + + this._runChunkStep(item, response, status, headers, () => this._uploadChunk(item, chunk.index + 1)); return true; } + /** + * Chunk steps run inside XHR callbacks, outside FileItem.upload()'s try/catch, + * so a throwing hook or request fails the item here instead of leaving the upload stuck. + * Returns false when the step failed and the item was reported as an error. + */ + protected _runChunkStep(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders, + step: () => void): boolean { + try { + step(); + + return true; + } catch (e) { + this._onErrorItem(item, response, status, headers); + this._onCompleteItem(item, response, status, headers); + + return false; + } + } + + // network errors (status 0), timeouts, rate limits and server errors may succeed on retry + protected _isRetryableChunkStatus(status: number): boolean { + return status === 0 || status === 408 || status === 429 || status >= 500; + } + protected _sendXhr(item: FileItem, chunk?: FileChunk): void { // tslint:disable-next-line:no-this-assignment // eslint-disable-next-line @typescript-eslint/no-this-alias diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts index 7bce68a8..f998b7a4 100644 --- a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts +++ b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts @@ -200,6 +200,73 @@ describe('FileUploader: chunked upload', () => { expect(uploader.isUploading).toBe(false); }); + it('does not retry client errors', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 3 }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + + uploader.uploadAll(); + last().respond(404); + + expect(FakeXhr.instances.length).toBe(1); + expect(error).toHaveBeenCalledTimes(1); + expect(item.isError).toBe(true); + }); + + it('retries timeouts and rate limits', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2 }); + const success = jest.spyOn(uploader, 'onSuccessItem'); + + uploader.uploadAll(); + last().respond(408); + last().respond(429); + last().respond(200); + last().respond(200); + last().respond(200); + + expect(FakeXhr.instances.length).toBe(5); + expect(success).toHaveBeenCalledTimes(1); + }); + + it('fails the item when a chunk hook throws instead of leaving it stuck', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + const complete = jest.spyOn(uploader, 'onCompleteItem'); + uploader.onBeforeUploadChunk = (_item, chunk) => { + if (chunk.index === 1) { + throw new Error('hook failed'); + } + }; + + uploader.uploadAll(); + expect(() => last().respond(200)).not.toThrow(); + + expect(FakeXhr.instances.length).toBe(1); + expect(error).toHaveBeenCalledTimes(1); + expect(complete).toHaveBeenCalledTimes(1); + expect(item.isError).toBe(true); + expect(item.isUploading).toBe(false); + expect(uploader.isUploading).toBe(false); + }); + + it('fails the item when onCompleteChunk throws', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onCompleteChunk = () => { + throw new Error('hook failed'); + }; + + uploader.uploadAll(); + expect(() => last().respond(200)).not.toThrow(); + + expect(FakeXhr.instances.length).toBe(1); + expect(error).toHaveBeenCalledTimes(1); + expect(item.isError).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + it('cancel aborts the chunk in flight and stops the upload', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; From 4c8f861e716d7bc802f58376de0c57da2c4b6beb Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 17:31:14 +0300 Subject: [PATCH 3/9] fix(uploader): address chunked upload review findings - cancel from onBeforeUploadChunk now stops before the chunk is sent; cancelItem no longer throws when no request exists yet - cancel from onCompleteChunk on the last chunk reports cancel - chunks keep the file's MIME type - retries wait chunkRetryDelay (default 1000ms, doubled per retry) or the server's Retry-After, and can be cancelled while waiting - retries go through onBeforeUploadChunk with chunk.retry increased - 501 and 505 are not retried - fractional chunkSize is floored so slices and Content-Range stay valid - empty files in raw mode send Content-Range: bytes */0 - a throwing hook on the first chunk reports error before complete, and an error after a chunk was sent detaches that request so the item is reported once - document per-chunk onBuildItemForm/response, item.chunk and retry delay --- README.md | 9 +- apps/demo/src/doc.md | 9 +- .../file-upload/file-item.class.ts | 2 + .../file-upload/file-uploader.class.ts | 95 +++++-- .../testing/spec/file-uploader-chunk.spec.ts | 244 +++++++++++++++++- 5 files changed, 329 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 66cb773f..5b714e84 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,13 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). - 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx responses are retried. Defaults to 0. - 10. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. + 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. + 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. A `Retry-After` response header takes precedence. Defaults to 1000; 0 retries immediately. + 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. `item.cancel()` stops the remaining chunks. + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. Retries call `onBeforeUploadChunk` again with `chunk.retry` increased. `item.cancel()` stops the remaining chunks, also when called from these callbacks or while waiting to retry. + + With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. ### Events diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index c99114d3..e7806b90 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -30,10 +30,13 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). - 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx responses are retried. Defaults to 0. - 10. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. + 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. + 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. A `Retry-After` response header takes precedence. Defaults to 1000; 0 retries immediately. + 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. `item.cancel()` stops the remaining chunks. + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. Retries call `onBeforeUploadChunk` again with `chunk.retry` increased. `item.cancel()` stops the remaining chunks, also when called from these callbacks or while waiting to retry. + + With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. ### Events - `onFileSelected` - fires when files are selected and added to the uploader queue diff --git a/libs/ng2-file-upload/file-upload/file-item.class.ts b/libs/ng2-file-upload/file-upload/file-item.class.ts index 1ff91b5f..9f015bae 100644 --- a/libs/ng2-file-upload/file-upload/file-item.class.ts +++ b/libs/ng2-file-upload/file-upload/file-item.class.ts @@ -22,6 +22,7 @@ export class FileItem { _xhr?: XMLHttpRequest; _form: any; _cancelRequested = false; + _chunkRetryTimer?: ReturnType; protected uploader: FileUploader; protected some: File; @@ -95,6 +96,7 @@ export class FileItem { this.progress = 0; this.chunk = undefined; this._cancelRequested = false; + this._chunkRetryTimer = undefined; this.onBeforeUpload(); } diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index 685d5197..a709cac2 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -42,6 +42,7 @@ export interface FileUploaderOptions { formatDataFunctionIsAsync?: boolean; chunkSize?: number; chunkRetries?: number; + chunkRetryDelay?: number; chunkIndexParam?: string; totalChunksParam?: string; } @@ -182,7 +183,15 @@ export class FileUploader { if (item && item.isUploading) { // between chunks there is no request in flight to abort, so the next chunk checks this flag item._cancelRequested = true; - prop.abort(); + if (item._chunkRetryTimer !== undefined) { + clearTimeout(item._chunkRetryTimer); + item._chunkRetryTimer = undefined; + this._onCancelItem(item, '', 0, {}); + this._onCompleteItem(item, '', 0, {}); + + return; + } + prop?.abort(); } } @@ -327,16 +336,21 @@ export class FileUploader { if (typeof item._file.size !== 'number') { throw new TypeError('The file specified is no longer valid'); } - if (this.options.chunkSize && this.options.chunkSize > 0) { - this._uploadChunk(item, 0); + if (this._getChunkSize()) { + this._runChunkStep(item, '', 0, {}, () => this._uploadChunk(item, 0)); return; } this._sendXhr(item); } - protected _uploadChunk(item: FileItem, index: number): void { - const chunkSize = this.options.chunkSize as number; + // whole bytes only, so slices and Content-Range stay valid; 0 disables chunking + protected _getChunkSize(): number { + return Math.max(0, Math.floor(this.options.chunkSize || 0)); + } + + protected _uploadChunk(item: FileItem, index: number, retry = 0): void { + const chunkSize = this._getChunkSize(); const size = item._file.size; const start = index * chunkSize; const end = Math.min(start + chunkSize, size); @@ -345,11 +359,18 @@ export class FileUploader { total: Math.max(1, Math.ceil(size / chunkSize)), start, end, - blob: item._file.slice(start, end), - retry: 0 + // keep the file's MIME type so servers checking the part's content type accept it + blob: item._file.slice(start, end, item._file.type), + retry }; item.chunk = chunk; this.onBeforeUploadChunk(item, chunk); + if (item._cancelRequested) { + this._onCancelItem(item, '', 0, {}); + this._onCompleteItem(item, '', 0, {}); + + return; + } this._sendXhr(item, chunk); } @@ -362,10 +383,7 @@ export class FileUploader { headers: ParsedResponseHeaders): boolean { if (!isSuccess) { if (this._isRetryableChunkStatus(status) && chunk.retry < (this.options.chunkRetries || 0) && !item._cancelRequested) { - this._runChunkStep(item, response, status, headers, () => { - item.chunk = { ...chunk, retry: chunk.retry + 1 }; - this._sendXhr(item, item.chunk); - }); + this._retryChunk(item, chunk, response, status, headers); return true; } @@ -375,21 +393,52 @@ export class FileUploader { if (!this._runChunkStep(item, response, status, headers, () => this.onCompleteChunk(item, chunk, response, status, headers))) { return true; } - if (chunk.index + 1 >= chunk.total) { - return false; - } if (item._cancelRequested) { this._onCancelItem(item, response, status, headers); this._onCompleteItem(item, response, status, headers); return true; } - + if (chunk.index + 1 >= chunk.total) { + return false; + } this._runChunkStep(item, response, status, headers, () => this._uploadChunk(item, chunk.index + 1)); return true; } + protected _retryChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): void { + const retry = () => this._runChunkStep(item, response, status, headers, () => this._uploadChunk(item, chunk.index, chunk.retry + 1)); + const delay = this._getChunkRetryDelay(chunk.retry, headers); + if (!delay) { + retry(); + + return; + } + item._chunkRetryTimer = setTimeout(() => { + item._chunkRetryTimer = undefined; + retry(); + }, delay); + } + + // Retry-After from the server wins, otherwise chunkRetryDelay doubled on every retry + protected _getChunkRetryDelay(retry: number, headers: ParsedResponseHeaders): number { + const retryAfter = headers[ 'retry-after' ]; + if (retryAfter) { + const seconds = Number(retryAfter); + if (!isNaN(seconds)) { + return Math.max(0, seconds * 1000); + } + const date = Date.parse(retryAfter); + if (!isNaN(date)) { + return Math.max(0, date - Date.now()); + } + } + const delay = this.options.chunkRetryDelay ?? 1000; + + return Math.max(0, delay) * Math.pow(2, retry); + } + /** * Chunk steps run inside XHR callbacks, outside FileItem.upload()'s try/catch, * so a throwing hook or request fails the item here instead of leaving the upload stuck. @@ -402,6 +451,12 @@ export class FileUploader { return true; } catch (e) { + // a request may already be in flight; detach it so it can't report the item a second time + const xhr = item._xhr; + if (xhr) { + xhr.onload = xhr.onerror = xhr.onabort = null; + xhr.abort(); + } this._onErrorItem(item, response, status, headers); this._onCompleteItem(item, response, status, headers); @@ -409,9 +464,9 @@ export class FileUploader { } } - // network errors (status 0), timeouts, rate limits and server errors may succeed on retry + // network errors (status 0), timeouts, rate limits and temporary server errors may succeed on retry protected _isRetryableChunkStatus(status: number): boolean { - return status === 0 || status === 408 || status === 429 || status >= 500; + return status === 0 || status === 408 || status === 429 || (status >= 500 && status !== 501 && status !== 505); } protected _sendXhr(item: FileItem, chunk?: FileChunk): void { @@ -509,8 +564,10 @@ export class FileUploader { if (this.authToken && this.authTokenHeader) { xhr.setRequestHeader(this.authTokenHeader, this.authToken); } - if (chunk && this.options.disableMultipart && chunk.end > chunk.start) { - xhr.setRequestHeader('Content-Range', `bytes ${chunk.start}-${chunk.end - 1}/${item._file.size}`); + if (chunk && this.options.disableMultipart) { + // an empty file has no byte range, only its length + const range = chunk.end > chunk.start ? `${chunk.start}-${chunk.end - 1}` : '*'; + xhr.setRequestHeader('Content-Range', `bytes ${range}/${item._file.size}`); } xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts index f998b7a4..dc7fefc2 100644 --- a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts +++ b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts @@ -44,8 +44,10 @@ class FakeXhr { this.onabort?.(); } + responseHeaders = ''; + getAllResponseHeaders(): string { - return ''; + return this.responseHeaders; } respond(status: number, response = ''): void { @@ -167,7 +169,7 @@ describe('FileUploader: chunked upload', () => { }); it('retries a failed chunk and then continues', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2 }); + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); const error = jest.spyOn(uploader, 'onErrorItem'); const success = jest.spyOn(uploader, 'onSuccessItem'); @@ -185,7 +187,7 @@ describe('FileUploader: chunked upload', () => { }); it('fails the item once retries are used up', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 0 }); const item = uploader.queue[ 0 ]; const error = jest.spyOn(uploader, 'onErrorItem'); @@ -201,7 +203,7 @@ describe('FileUploader: chunked upload', () => { }); it('does not retry client errors', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 3 }); + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 3, chunkRetryDelay: 0 }); const item = uploader.queue[ 0 ]; const error = jest.spyOn(uploader, 'onErrorItem'); @@ -214,7 +216,7 @@ describe('FileUploader: chunked upload', () => { }); it('retries timeouts and rate limits', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2 }); + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); const success = jest.spyOn(uploader, 'onSuccessItem'); uploader.uploadAll(); @@ -308,4 +310,236 @@ describe('FileUploader: chunked upload', () => { expect(success).toHaveBeenCalledTimes(1); }); + + it('ignores the fractional part of chunkSize', () => { + const uploader = createUploader({ chunkSize: 4 * KB + 0.5, disableMultipart: true }); + + uploader.uploadAll(); + + expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes 0-${4 * KB - 1}/${10 * KB}`); + }); + + it('keeps the file MIME type on chunks', () => { + const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB }); + uploader.addToQueue([ new File([ 'x'.repeat(10 * KB) ], 'photo.png', { type: 'image/png' }) ]); + + uploader.uploadAll(); + + expect((last().body as FormData).get('file')).toHaveProperty('type', 'image/png'); + }); + + it('sends Content-Range with only the length for an empty file in raw mode', () => { + const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, disableMultipart: true }); + uploader.addToQueue([ new File([], 'empty.txt') ]); + + uploader.uploadAll(); + + expect(last().requestHeaders[ 'Content-Range' ]).toBe('bytes */0'); + }); + + it('puts chunk fields before the file when parametersBeforeFiles is set', () => { + const uploader = createUploader({ chunkSize: 4 * KB, parametersBeforeFiles: true, additionalParameter: { id: '1' } }); + + uploader.uploadAll(); + const keys = [ ...(last().body as FormData).keys() ]; + + expect(keys).toEqual([ 'id', 'chunkIndex', 'totalChunks', 'file' ]); + }); + + it('emits response and builds the form once per chunk request', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const buildForm = jest.spyOn(uploader, 'onBuildItemForm'); + const responses: string[] = []; + uploader.response.subscribe((res: string) => responses.push(res)); + + uploader.uploadAll(); + for (let i = 0; i < 3; i++) { + const xhr = last(); + xhr.responseText = `r${ i }`; + xhr.respond(200); + xhr.onreadystatechange?.(); + } + + expect(buildForm).toHaveBeenCalledTimes(3); + expect(responses).toEqual([ 'r0', 'r1', 'r2' ]); + }); + + it('cancel from onBeforeUploadChunk on the first chunk stops before sending', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const cancel = jest.spyOn(uploader, 'onCancelItem'); + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onBeforeUploadChunk = (fileItem) => fileItem.cancel(); + + uploader.uploadAll(); + + expect(FakeXhr.instances.length).toBe(0); + expect(cancel).toHaveBeenCalledTimes(1); + expect(error).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('cancel from onBeforeUploadChunk on a later chunk stops before sending it', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const success = jest.spyOn(uploader, 'onSuccessItem'); + uploader.onBeforeUploadChunk = (fileItem, chunk) => { + if (chunk.index === 2) { + fileItem.cancel(); + } + }; + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + + expect(FakeXhr.instances.length).toBe(2); + expect(success).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('cancel from onCompleteChunk on the last chunk reports cancel', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const success = jest.spyOn(uploader, 'onSuccessItem'); + uploader.onCompleteChunk = (fileItem, chunk) => { + if (chunk.index === chunk.total - 1) { + fileItem.cancel(); + } + }; + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + last().respond(200); + + expect(success).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + }); + + it('calls onBeforeUploadChunk again for retries', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 0 }); + const retries: number[] = []; + uploader.onBeforeUploadChunk = (_item, chunk) => retries.push(chunk.retry); + + uploader.uploadAll(); + last().respond(503); + + expect(retries).toEqual([ 0, 1 ]); + }); + + it('does not retry 501 Not Implemented', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); + + uploader.uploadAll(); + last().respond(501); + + expect(FakeXhr.instances.length).toBe(1); + expect(uploader.queue[ 0 ].isError).toBe(true); + }); + + it('reports error before complete when a hook throws on the first chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const order: string[] = []; + uploader.onErrorItem = () => order.push('error'); + uploader.onCompleteItem = () => order.push('complete'); + uploader.onBeforeUploadChunk = () => { + throw new Error('hook failed'); + }; + + uploader.uploadAll(); + + expect(order).toEqual([ 'error', 'complete' ]); + expect(uploader.isUploading).toBe(false); + }); + + it('reports the item once when an error is thrown after the chunk was sent', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const complete = jest.spyOn(uploader, 'onCompleteItem'); + jest.spyOn(uploader as any, '_render').mockImplementationOnce(() => { + throw new Error('render failed'); + }); + + uploader.uploadAll(); + last().respond(200); + + expect(complete).toHaveBeenCalledTimes(1); + expect(uploader.queue[ 0 ].isError).toBe(true); + }); + + it('continues with the next queued item and removes uploaded items', () => { + const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, removeAfterUpload: true }); + uploader.addToQueue([ new File([ 'x'.repeat(6 * KB) ], 'a.bin'), new File([ 'y'.repeat(2 * KB) ], 'b.bin') ]); + const completeAll = jest.spyOn(uploader, 'onCompleteAll'); + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + last().respond(200); + + expect(FakeXhr.instances.length).toBe(3); + expect(uploader.queue.length).toBe(0); + expect(completeAll).toHaveBeenCalledTimes(1); + }); + + it('uploads chunks automatically with autoUpload', () => { + const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, autoUpload: true }); + + uploader.addToQueue([ new File([ 'x'.repeat(10 * KB) ], 'file.bin') ]); + + expect((last().body as FormData).get('chunkIndex')).toBe('0'); + }); + + describe('retry delay', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('waits 1s by default and doubles the delay on every retry', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2 }); + + uploader.uploadAll(); + last().respond(503); + jest.advanceTimersByTime(999); + expect(FakeXhr.instances.length).toBe(1); + jest.advanceTimersByTime(1); + expect(FakeXhr.instances.length).toBe(2); + + last().respond(503); + jest.advanceTimersByTime(1999); + expect(FakeXhr.instances.length).toBe(2); + jest.advanceTimersByTime(1); + expect(FakeXhr.instances.length).toBe(3); + }); + + it('uses the Retry-After header when the server sends one', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 100 }); + + uploader.uploadAll(); + last().responseHeaders = 'Retry-After: 3'; + last().respond(429); + jest.advanceTimersByTime(2999); + expect(FakeXhr.instances.length).toBe(1); + jest.advanceTimersByTime(1); + expect(FakeXhr.instances.length).toBe(2); + }); + + it('cancel during the delay reports cancel right away and never retries', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); + const item = uploader.queue[ 0 ]; + const cancel = jest.spyOn(uploader, 'onCancelItem'); + + uploader.uploadAll(); + last().respond(503); + item.cancel(); + expect(cancel).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(10000); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(FakeXhr.instances.length).toBe(1); + expect(item.isCancel).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + }); }); From 9af8c4846544eddd50d5ecbe2f8c77e682b524bd Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 17:46:05 +0300 Subject: [PATCH 4/9] fix(uploader): address second chunked upload review - restore the original order for non-chunked uploads: the request is created before onBeforeUploadItem again; chunk requests are created before onBeforeUploadChunk - cancel from any hook, including onBuildItemForm, stops before the chunk is sent; a failure after cancel is reported as cancel - rename onCompleteChunk to onSuccessChunk, it only runs for successful chunks - re-uploading an item starts from the url/method/headers it had before hooks changed them, unless the app changed them in between - a throwing app hook while the item is already reported is rethrown instead of reporting the item twice - keep a Content-Range header set by the app - cap retry delays at 30 seconds - remove excessive comments, update docs --- README.md | 6 +- apps/demo/src/doc.md | 6 +- .../file-upload/file-item.class.ts | 1 + .../file-upload/file-uploader.class.ts | 118 +++++--- .../testing/spec/file-uploader-chunk.spec.ts | 276 +++++++++++++++--- 5 files changed, 320 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 5b714e84..96805c2d 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,12 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g 5. `formatDataFunction` - Function to modify the request body. 'DisableMultipart' must be 'true' for this function to be called. 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. - 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). + 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields (and any `additionalParameter`); with `disableMultipart` the raw chunk is sent with a `Content-Range` header unless you set that header yourself (`formatDataFunction` is not used for chunks). 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. - 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. A `Retry-After` response header takes precedence. Defaults to 1000; 0 retries immediately. + 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry and capped at 30 seconds. A `Retry-After` response header takes precedence (cross-origin servers must list it in `Access-Control-Expose-Headers`). Defaults to 1000; 0 retries immediately. 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. Retries call `onBeforeUploadChunk` again with `chunk.retry` increased. `item.cancel()` stops the remaining chunks, also when called from these callbacks or while waiting to retry. + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server; uploading the item again starts from the values it had before. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index e7806b90..d4a426de 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -29,12 +29,12 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u 5. `formatDataFunction` - Function to modify the request body. 'DisableMultipart' must be 'true' for this function to be called. 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. - 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used for chunks). + 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields (and any `additionalParameter`); with `disableMultipart` the raw chunk is sent with a `Content-Range` header unless you set that header yourself (`formatDataFunction` is not used for chunks). 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. - 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. A `Retry-After` response header takes precedence. Defaults to 1000; 0 retries immediately. + 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry and capped at 30 seconds. A `Retry-After` response header takes precedence (cross-origin servers must list it in `Access-Control-Expose-Headers`). Defaults to 1000; 0 retries immediately. 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` and `onCompleteChunk(item, chunk, response, status, headers)`. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server. Retries call `onBeforeUploadChunk` again with `chunk.retry` increased. `item.cancel()` stops the remaining chunks, also when called from these callbacks or while waiting to retry. + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server; uploading the item again starts from the values it had before. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. diff --git a/libs/ng2-file-upload/file-upload/file-item.class.ts b/libs/ng2-file-upload/file-upload/file-item.class.ts index 9f015bae..c2c4de07 100644 --- a/libs/ng2-file-upload/file-upload/file-item.class.ts +++ b/libs/ng2-file-upload/file-upload/file-item.class.ts @@ -23,6 +23,7 @@ export class FileItem { _form: any; _cancelRequested = false; _chunkRetryTimer?: ReturnType; + _chunkTarget?: { url: string; method?: string; headers: string; end?: string }; protected uploader: FileUploader; protected some: File; diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index a709cac2..76603fad 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -3,6 +3,8 @@ import { FileLikeObject } from './file-like-object.class'; import { FileItem } from './file-item.class'; import { FileType } from './file-type.class'; +const MAX_CHUNK_RETRY_DELAY = 30000; + function isFile(value: any): boolean { return (File && value instanceof File); } @@ -181,13 +183,11 @@ export class FileUploader { const item = this.queue[ index ]; const prop = this.options.isHTML5 ? item._xhr : item._form; if (item && item.isUploading) { - // between chunks there is no request in flight to abort, so the next chunk checks this flag item._cancelRequested = true; if (item._chunkRetryTimer !== undefined) { clearTimeout(item._chunkRetryTimer); item._chunkRetryTimer = undefined; - this._onCancelItem(item, '', 0, {}); - this._onCompleteItem(item, '', 0, {}); + this._cancelChunkedItem(item, '', 0, {}); return; } @@ -283,7 +283,7 @@ export class FileUploader { return { item, chunk }; } - onCompleteChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): any { + onSuccessChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): any { return { item, chunk, response, status, headers }; } @@ -306,6 +306,9 @@ export class FileUploader { } _onCompleteItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { + if (item._chunkTarget) { + item._chunkTarget.end = this._chunkTargetKey(item); + } item._onComplete(response, status, headers); this.onCompleteItem(item, response, status, headers); const nextItem = this.getReadyItems()[ 0 ]; @@ -331,12 +334,8 @@ export class FileUploader { } protected _xhrTransport(item: FileItem): any { - this._onBeforeUploadItem(item); - - if (typeof item._file.size !== 'number') { - throw new TypeError('The file specified is no longer valid'); - } if (this._getChunkSize()) { + this._restoreChunkTarget(item); this._runChunkStep(item, '', 0, {}, () => this._uploadChunk(item, 0)); return; @@ -344,45 +343,50 @@ export class FileUploader { this._sendXhr(item); } - // whole bytes only, so slices and Content-Range stay valid; 0 disables chunking protected _getChunkSize(): number { return Math.max(0, Math.floor(this.options.chunkSize || 0)); } + // undo url/method/headers changes made by chunk hooks during a previous upload of this item + protected _restoreChunkTarget(item: FileItem): void { + const target = item._chunkTarget; + if (target && target.end && this._chunkTargetKey(item) === target.end) { + item.url = target.url; + item.method = target.method; + item.headers = JSON.parse(target.headers); + } + item._chunkTarget = { url: item.url, method: item.method, headers: JSON.stringify(item.headers) }; + } + + protected _chunkTargetKey(item: FileItem): string { + return JSON.stringify([ item.url, item.method, item.headers ]); + } + protected _uploadChunk(item: FileItem, index: number, retry = 0): void { const chunkSize = this._getChunkSize(); const size = item._file.size; const start = index * chunkSize; const end = Math.min(start + chunkSize, size); - const chunk: FileChunk = { + this._sendXhr(item, { index, total: Math.max(1, Math.ceil(size / chunkSize)), start, end, - // keep the file's MIME type so servers checking the part's content type accept it blob: item._file.slice(start, end, item._file.type), retry - }; - item.chunk = chunk; - this.onBeforeUploadChunk(item, chunk); - if (item._cancelRequested) { - this._onCancelItem(item, '', 0, {}); - this._onCompleteItem(item, '', 0, {}); - - return; - } - this._sendXhr(item, chunk); + }); } - /** - * Handles a finished chunk request. - * Returns true when the upload continues (next chunk, retry or cancel), - * false when the result should be reported as the item's final outcome. - */ + // true when the chunk flow handled the result, false to report it as the item's outcome protected _onChunkDone(item: FileItem, chunk: FileChunk, isSuccess: boolean, response: string, status: number, headers: ParsedResponseHeaders): boolean { if (!isSuccess) { - if (this._isRetryableChunkStatus(status) && chunk.retry < (this.options.chunkRetries || 0) && !item._cancelRequested) { + if (item._cancelRequested) { + this._cancelChunkedItem(item, response, status, headers); + + return true; + } + if (this._isRetryableChunkStatus(status) && chunk.retry < (this.options.chunkRetries || 0)) { this._retryChunk(item, chunk, response, status, headers); return true; @@ -390,12 +394,11 @@ export class FileUploader { return false; } - if (!this._runChunkStep(item, response, status, headers, () => this.onCompleteChunk(item, chunk, response, status, headers))) { + if (!this._runChunkStep(item, response, status, headers, () => this.onSuccessChunk(item, chunk, response, status, headers))) { return true; } if (item._cancelRequested) { - this._onCancelItem(item, response, status, headers); - this._onCompleteItem(item, response, status, headers); + this._cancelChunkedItem(item, response, status, headers); return true; } @@ -407,6 +410,11 @@ export class FileUploader { return true; } + protected _cancelChunkedItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { + this._onCancelItem(item, response, status, headers); + this._onCompleteItem(item, response, status, headers); + } + protected _retryChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): void { const retry = () => this._runChunkStep(item, response, status, headers, () => this._uploadChunk(item, chunk.index, chunk.retry + 1)); const delay = this._getChunkRetryDelay(chunk.retry, headers); @@ -421,29 +429,23 @@ export class FileUploader { }, delay); } - // Retry-After from the server wins, otherwise chunkRetryDelay doubled on every retry protected _getChunkRetryDelay(retry: number, headers: ParsedResponseHeaders): number { const retryAfter = headers[ 'retry-after' ]; + let delay = Math.max(0, this.options.chunkRetryDelay ?? 1000) * Math.pow(2, retry); if (retryAfter) { const seconds = Number(retryAfter); - if (!isNaN(seconds)) { - return Math.max(0, seconds * 1000); - } const date = Date.parse(retryAfter); - if (!isNaN(date)) { - return Math.max(0, date - Date.now()); + if (!isNaN(seconds)) { + delay = seconds * 1000; + } else if (!isNaN(date)) { + delay = date - Date.now(); } } - const delay = this.options.chunkRetryDelay ?? 1000; - return Math.max(0, delay) * Math.pow(2, retry); + return Math.min(MAX_CHUNK_RETRY_DELAY, Math.max(0, delay)); } - /** - * Chunk steps run inside XHR callbacks, outside FileItem.upload()'s try/catch, - * so a throwing hook or request fails the item here instead of leaving the upload stuck. - * Returns false when the step failed and the item was reported as an error. - */ + // chunk steps run in XHR callbacks, outside FileItem.upload()'s try/catch protected _runChunkStep(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders, step: () => void): boolean { try { @@ -451,7 +453,9 @@ export class FileUploader { return true; } catch (e) { - // a request may already be in flight; detach it so it can't report the item a second time + if (!item.isUploading) { + throw e; + } const xhr = item._xhr; if (xhr) { xhr.onload = xhr.onerror = xhr.onabort = null; @@ -464,7 +468,11 @@ export class FileUploader { } } - // network errors (status 0), timeouts, rate limits and temporary server errors may succeed on retry + protected _hasRequestHeader(item: FileItem, name: string): boolean { + return [ ...(this.options.headers || []), ...item.headers ] + .some((header: Headers) => header.name.toLowerCase() === name); + } + protected _isRetryableChunkStatus(status: number): boolean { return status === 0 || status === 408 || status === 429 || (status >= 500 && status !== 501 && status !== 505); } @@ -475,7 +483,17 @@ export class FileUploader { const that = this; const xhr = item._xhr = new XMLHttpRequest(); let sendable: any; + if (!chunk || (chunk.index === 0 && chunk.retry === 0)) { + this._onBeforeUploadItem(item); + } + if (typeof item._file.size !== 'number') { + throw new TypeError('The file specified is no longer valid'); + } + if (chunk) { + item.chunk = chunk; + this.onBeforeUploadChunk(item, chunk); + } if (!this.options.disableMultipart) { sendable = new FormData(); this._onBuildItemForm(item, sendable); @@ -511,6 +529,11 @@ export class FileUploader { sendable = this.options.formatDataFunction(item); } } + if (chunk && item._cancelRequested) { + this._cancelChunkedItem(item, '', 0, {}); + + return; + } xhr.upload.onprogress = (event: any) => { const ratio = event.lengthComputable ? event.loaded / event.total : 0; @@ -564,8 +587,7 @@ export class FileUploader { if (this.authToken && this.authTokenHeader) { xhr.setRequestHeader(this.authTokenHeader, this.authToken); } - if (chunk && this.options.disableMultipart) { - // an empty file has no byte range, only its length + if (chunk && this.options.disableMultipart && !this._hasRequestHeader(item, 'content-range')) { const range = chunk.end > chunk.start ? `${chunk.start}-${chunk.end - 1}` : '*'; xhr.setRequestHeader('Content-Range', `bytes ${range}/${item._file.size}`); } diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts index dc7fefc2..85469255 100644 --- a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts +++ b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts @@ -13,6 +13,7 @@ class FakeXhr { body: any; requestHeaders: { [ name: string ]: string } = {}; aborted = false; + sent = false; onload?: () => void; onerror?: () => void; onabort?: () => void; @@ -33,11 +34,11 @@ class FakeXhr { send(body: any): void { this.body = body; + this.sent = true; } abort(): void { - // like a real XHR, aborting a finished request fires no events - if (this.readyState === 4) { + if (!this.sent || this.readyState === 4) { return; } this.aborted = true; @@ -67,8 +68,14 @@ function createUploader(options: Partial): FileUploader { return uploader; } +function sent(): FakeXhr[] { + return FakeXhr.instances.filter(xhr => xhr.sent); +} + function last(): FakeXhr { - return FakeXhr.instances[ FakeXhr.instances.length - 1 ]; + const requests = sent(); + + return requests[ requests.length - 1 ]; } describe('FileUploader: chunked upload', () => { @@ -91,7 +98,7 @@ describe('FileUploader: chunked upload', () => { uploader.uploadAll(); last().respond(200); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); expect((last().body as FormData).get('chunkIndex')).toBeNull(); expect(success).toHaveBeenCalledTimes(1); }); @@ -100,7 +107,7 @@ describe('FileUploader: chunked upload', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; const beforeChunk = jest.spyOn(uploader, 'onBeforeUploadChunk'); - const completeChunk = jest.spyOn(uploader, 'onCompleteChunk'); + const completeChunk = jest.spyOn(uploader, 'onSuccessChunk'); const success = jest.spyOn(uploader, 'onSuccessItem'); const complete = jest.spyOn(uploader, 'onCompleteItem'); @@ -113,7 +120,7 @@ describe('FileUploader: chunked upload', () => { last().respond(200, `chunk-${i}`); } - expect(FakeXhr.instances.length).toBe(3); + expect(sent().length).toBe(3); expect(beforeChunk).toHaveBeenCalledTimes(3); expect(completeChunk).toHaveBeenCalledTimes(3); expect(success).toHaveBeenCalledTimes(1); @@ -133,9 +140,9 @@ describe('FileUploader: chunked upload', () => { expect(form.get('total_chunks')).toBe('3'); }); - it('lets onCompleteChunk change the url and method of the next chunk', () => { + it('lets onSuccessChunk change the url and method of the next chunk', () => { const uploader = createUploader({ chunkSize: 4 * KB }); - uploader.onCompleteChunk = (item, chunk, response) => { + uploader.onSuccessChunk = (item, chunk, response) => { item.url = `/upload/${response}`; item.method = 'PUT'; }; @@ -181,7 +188,7 @@ describe('FileUploader: chunked upload', () => { last().respond(200); last().respond(200); - expect(FakeXhr.instances.length).toBe(5); + expect(sent().length).toBe(5); expect(error).not.toHaveBeenCalled(); expect(success).toHaveBeenCalledTimes(1); }); @@ -196,7 +203,7 @@ describe('FileUploader: chunked upload', () => { last().respond(500); last().respond(500); - expect(FakeXhr.instances.length).toBe(3); + expect(sent().length).toBe(3); expect(error).toHaveBeenCalledTimes(1); expect(item.isError).toBe(true); expect(uploader.isUploading).toBe(false); @@ -210,7 +217,7 @@ describe('FileUploader: chunked upload', () => { uploader.uploadAll(); last().respond(404); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); expect(error).toHaveBeenCalledTimes(1); expect(item.isError).toBe(true); }); @@ -226,7 +233,7 @@ describe('FileUploader: chunked upload', () => { last().respond(200); last().respond(200); - expect(FakeXhr.instances.length).toBe(5); + expect(sent().length).toBe(5); expect(success).toHaveBeenCalledTimes(1); }); @@ -244,7 +251,7 @@ describe('FileUploader: chunked upload', () => { uploader.uploadAll(); expect(() => last().respond(200)).not.toThrow(); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); expect(error).toHaveBeenCalledTimes(1); expect(complete).toHaveBeenCalledTimes(1); expect(item.isError).toBe(true); @@ -252,18 +259,18 @@ describe('FileUploader: chunked upload', () => { expect(uploader.isUploading).toBe(false); }); - it('fails the item when onCompleteChunk throws', () => { + it('fails the item when onSuccessChunk throws', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; const error = jest.spyOn(uploader, 'onErrorItem'); - uploader.onCompleteChunk = () => { + uploader.onSuccessChunk = () => { throw new Error('hook failed'); }; uploader.uploadAll(); expect(() => last().respond(200)).not.toThrow(); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); expect(error).toHaveBeenCalledTimes(1); expect(item.isError).toBe(true); expect(uploader.isUploading).toBe(false); @@ -279,21 +286,21 @@ describe('FileUploader: chunked upload', () => { item.cancel(); expect(last().aborted).toBe(true); - expect(FakeXhr.instances.length).toBe(2); + expect(sent().length).toBe(2); expect(cancel).toHaveBeenCalledTimes(1); expect(item.isCancel).toBe(true); }); - it('cancel from onCompleteChunk stops before the next chunk', () => { + it('cancel from onSuccessChunk stops before the next chunk', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; const cancel = jest.spyOn(uploader, 'onCancelItem'); - uploader.onCompleteChunk = (fileItem) => fileItem.cancel(); + uploader.onSuccessChunk = (fileItem) => fileItem.cancel(); uploader.uploadAll(); last().respond(200); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); expect(cancel).toHaveBeenCalledTimes(1); expect(item.isCancel).toBe(true); expect(uploader.isUploading).toBe(false); @@ -373,7 +380,7 @@ describe('FileUploader: chunked upload', () => { uploader.uploadAll(); - expect(FakeXhr.instances.length).toBe(0); + expect(sent().length).toBe(0); expect(cancel).toHaveBeenCalledTimes(1); expect(error).not.toHaveBeenCalled(); expect(item.isCancel).toBe(true); @@ -394,17 +401,17 @@ describe('FileUploader: chunked upload', () => { last().respond(200); last().respond(200); - expect(FakeXhr.instances.length).toBe(2); + expect(sent().length).toBe(2); expect(success).not.toHaveBeenCalled(); expect(item.isCancel).toBe(true); expect(uploader.isUploading).toBe(false); }); - it('cancel from onCompleteChunk on the last chunk reports cancel', () => { + it('cancel from onSuccessChunk on the last chunk reports cancel', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; const success = jest.spyOn(uploader, 'onSuccessItem'); - uploader.onCompleteChunk = (fileItem, chunk) => { + uploader.onSuccessChunk = (fileItem, chunk) => { if (chunk.index === chunk.total - 1) { fileItem.cancel(); } @@ -436,7 +443,7 @@ describe('FileUploader: chunked upload', () => { uploader.uploadAll(); last().respond(501); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); expect(uploader.queue[ 0 ].isError).toBe(true); }); @@ -479,7 +486,7 @@ describe('FileUploader: chunked upload', () => { last().respond(200); last().respond(200); - expect(FakeXhr.instances.length).toBe(3); + expect(sent().length).toBe(3); expect(uploader.queue.length).toBe(0); expect(completeAll).toHaveBeenCalledTimes(1); }); @@ -492,6 +499,167 @@ describe('FileUploader: chunked upload', () => { expect((last().body as FormData).get('chunkIndex')).toBe('0'); }); + it('creates the request before onBeforeUploadItem without chunking', () => { + const uploader = createUploader({}); + let xhrInHook: unknown; + uploader.onBeforeUploadItem = (fileItem) => xhrInHook = fileItem._xhr; + + uploader.uploadAll(); + + expect(xhrInHook).toBe(last()); + }); + + it('creates each chunk request before onBeforeUploadChunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const seen: unknown[] = []; + uploader.onBeforeUploadChunk = (fileItem) => seen.push(fileItem._xhr); + + uploader.uploadAll(); + last().respond(200); + + expect(seen).toEqual(sent()); + }); + + it('cancel from onBuildItemForm stops before the chunk is sent', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onBuildItemForm = (fileItem) => { + if (fileItem.chunk?.index === 1) { + fileItem.cancel(); + } + }; + + uploader.uploadAll(); + last().respond(200); + + expect(sent().length).toBe(1); + expect(error).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('reports cancel, not error, when a cancelled chunk then fails', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + + uploader.uploadAll(); + item._cancelRequested = true; + last().respond(500); + + expect(sent().length).toBe(1); + expect(error).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + }); + + it('does not call onSuccessChunk for a failed chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const successChunk = jest.spyOn(uploader, 'onSuccessChunk'); + + uploader.uploadAll(); + last().respond(400); + + expect(successChunk).not.toHaveBeenCalled(); + }); + + it('starts a re-upload at the original url after hooks changed it', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + uploader.onSuccessChunk = (fileItem) => { + fileItem.url = '/upload/session-1'; + fileItem.method = 'PUT'; + }; + + uploader.uploadAll(); + last().respond(200); + last().respond(404); + item.upload(); + + expect(last().url).toBe('/upload'); + expect(last().method).toBe('POST'); + }); + + it('keeps a url set by the app between uploads', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + uploader.onSuccessChunk = (fileItem) => fileItem.url = '/upload/session-1'; + + uploader.uploadAll(); + last().respond(200); + last().respond(404); + item.url = '/other'; + item.upload(); + + expect(last().url).toBe('/other'); + }); + + it('does not report the item twice when onCompleteItem throws during a cancel', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onBeforeUploadChunk = (fileItem, chunk) => { + if (chunk.index === 1) { + fileItem.cancel(); + } + }; + uploader.onCompleteItem = () => { + throw new Error('app failed'); + }; + + uploader.uploadAll(); + + expect(() => last().respond(200)).toThrow('app failed'); + expect(error).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + }); + + it('keeps a Content-Range header set by the app', () => { + const uploader = createUploader({ + chunkSize: 4 * KB, + disableMultipart: true, + headers: [ { name: 'Content-Range', value: 'custom' } ] + }); + + uploader.uploadAll(); + + expect(last().requestHeaders[ 'Content-Range' ]).toBe('custom'); + }); + + it('sends headers and the auth token with every chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB, authToken: 'Bearer t', headers: [ { name: 'X-App', value: '1' } ] }); + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + + expect(sent().map(xhr => [ xhr.requestHeaders[ 'Authorization' ], xhr.requestHeaders[ 'X-App' ] ])) + .toEqual([ [ 'Bearer t', '1' ], [ 'Bearer t', '1' ], [ 'Bearer t', '1' ] ]); + }); + + it('ignores formatDataFunction for chunks', () => { + const formatDataFunction = jest.fn(() => Promise.resolve({ name: 'x' })); + const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true, formatDataFunction, formatDataFunctionIsAsync: true }); + + uploader.uploadAll(); + + expect(formatDataFunction).not.toHaveBeenCalled(); + expect(last().body).toBeInstanceOf(Blob); + }); + + it('still uses formatDataFunction without chunking', async () => { + const uploader = createUploader({ + disableMultipart: true, + formatDataFunction: () => Promise.resolve({ name: 'x' }), + formatDataFunctionIsAsync: true + }); + + uploader.uploadAll(); + await Promise.resolve(); + + expect(last().body).toBe('{"name":"x"}'); + }); + describe('retry delay', () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); @@ -502,15 +670,15 @@ describe('FileUploader: chunked upload', () => { uploader.uploadAll(); last().respond(503); jest.advanceTimersByTime(999); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); jest.advanceTimersByTime(1); - expect(FakeXhr.instances.length).toBe(2); + expect(sent().length).toBe(2); last().respond(503); jest.advanceTimersByTime(1999); - expect(FakeXhr.instances.length).toBe(2); + expect(sent().length).toBe(2); jest.advanceTimersByTime(1); - expect(FakeXhr.instances.length).toBe(3); + expect(sent().length).toBe(3); }); it('uses the Retry-After header when the server sends one', () => { @@ -520,9 +688,9 @@ describe('FileUploader: chunked upload', () => { last().responseHeaders = 'Retry-After: 3'; last().respond(429); jest.advanceTimersByTime(2999); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); jest.advanceTimersByTime(1); - expect(FakeXhr.instances.length).toBe(2); + expect(sent().length).toBe(2); }); it('cancel during the delay reports cancel right away and never retries', () => { @@ -537,7 +705,49 @@ describe('FileUploader: chunked upload', () => { jest.advanceTimersByTime(10000); expect(cancel).toHaveBeenCalledTimes(1); - expect(FakeXhr.instances.length).toBe(1); + expect(sent().length).toBe(1); + expect(item.isCancel).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('caps the delay at 30 seconds', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); + + uploader.uploadAll(); + last().responseHeaders = 'Retry-After: 3600'; + last().respond(503); + jest.advanceTimersByTime(30000); + + expect(sent().length).toBe(2); + }); + + it('accepts Retry-After as an HTTP date', () => { + jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 0 }); + + uploader.uploadAll(); + last().responseHeaders = 'Retry-After: Thu, 01 Jan 2026 00:00:05 GMT'; + last().respond(503); + jest.advanceTimersByTime(4999); + expect(sent().length).toBe(1); + jest.advanceTimersByTime(1); + expect(sent().length).toBe(2); + }); + + it.each([ + [ 'removeFromQueue', (uploader: FileUploader) => uploader.removeFromQueue(uploader.queue[ 0 ]) ], + [ 'clearQueue', (uploader: FileUploader) => uploader.clearQueue() ], + [ 'cancelAll', (uploader: FileUploader) => uploader.cancelAll() ] + ])('%s during the delay stops the upload', (_name, action) => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); + const item = uploader.queue[ 0 ]; + + uploader.uploadAll(); + last().respond(503); + action(uploader); + jest.advanceTimersByTime(10000); + + expect(sent().length).toBe(1); expect(item.isCancel).toBe(true); expect(uploader.isUploading).toBe(false); }); From 96a84c08a50b9ae0775c8d1859cfd1d031b83586 Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 17:56:14 +0300 Subject: [PATCH 5/9] fix(uploader): address third chunked upload review - keep the original progress rounding for non-chunked uploads - errors thrown by chunk callbacks fail the item with status 0 and are rethrown asynchronously so the app's error handler sees them; an error thrown while reporting the item no longer reports it twice - replace the re-upload url logic with a plain snapshot: url, method and headers changed by chunk hooks are reset before onSuccessItem, onErrorItem or onCancelItem run, so values the app sets there survive - read the chunk size once per upload so setOptions mid-upload can't skip bytes - removeFromQueue ignores items no longer in the queue, so remove() from a chunk hook with removeAfterUpload doesn't leave the uploader stuck --- README.md | 2 +- apps/demo/src/doc.md | 2 +- .../file-upload/file-item.class.ts | 2 +- .../file-upload/file-uploader.class.ts | 102 +++---- .../testing/spec/file-uploader-chunk.spec.ts | 248 ++++++++++++------ 5 files changed, 228 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 96805c2d..1af4ae09 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry and capped at 30 seconds. A `Retry-After` response header takes precedence (cross-origin servers must list it in `Access-Control-Expose-Headers`). Defaults to 1000; 0 retries immediately. 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server; uploading the item again starts from the values it had before. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Changing `item.url`, `item.method` or `item.headers` in `onBeforeUploadChunk` applies to the request about to be sent, in `onSuccessChunk` to the next one, e.g. with an upload id returned by your server. When the upload finishes they are reset to the values they had when it started, before `onSuccessItem`, `onErrorItem` or `onCancelItem` run. An error thrown by a chunk callback fails the item and is rethrown asynchronously, so it still reaches your error handler. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index d4a426de..8a5b9e41 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -34,7 +34,7 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry and capped at 30 seconds. A `Retry-After` response header takes precedence (cross-origin servers must list it in `Access-Control-Expose-Headers`). Defaults to 1000; 0 retries immediately. 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Change `item.url`, `item.method` or `item.headers` in them to target the next chunk request, e.g. with an upload id returned by your server; uploading the item again starts from the values it had before. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). + Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Changing `item.url`, `item.method` or `item.headers` in `onBeforeUploadChunk` applies to the request about to be sent, in `onSuccessChunk` to the next one, e.g. with an upload id returned by your server. When the upload finishes they are reset to the values they had when it started, before `onSuccessItem`, `onErrorItem` or `onCancelItem` run. An error thrown by a chunk callback fails the item and is rethrown asynchronously, so it still reaches your error handler. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. diff --git a/libs/ng2-file-upload/file-upload/file-item.class.ts b/libs/ng2-file-upload/file-upload/file-item.class.ts index c2c4de07..f494d411 100644 --- a/libs/ng2-file-upload/file-upload/file-item.class.ts +++ b/libs/ng2-file-upload/file-upload/file-item.class.ts @@ -23,7 +23,7 @@ export class FileItem { _form: any; _cancelRequested = false; _chunkRetryTimer?: ReturnType; - _chunkTarget?: { url: string; method?: string; headers: string; end?: string }; + _chunkTarget?: { url: string; method?: string; headers: any }; protected uploader: FileUploader; protected some: File; diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index 76603fad..2be95c66 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -152,6 +152,9 @@ export class FileUploader { removeFromQueue(value: FileItem): void { const index = this.getIndexOfItem(value); const item = this.queue[ index ]; + if (!item) { + return; + } if (item.isUploading) { item.cancel(); } @@ -301,14 +304,12 @@ export class FileUploader { } _onErrorItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { + this._restoreChunkTarget(item); item._onError(response, status, headers); this.onErrorItem(item, response, status, headers); } _onCompleteItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { - if (item._chunkTarget) { - item._chunkTarget.end = this._chunkTargetKey(item); - } item._onComplete(response, status, headers); this.onCompleteItem(item, response, status, headers); const nextItem = this.getReadyItems()[ 0 ]; @@ -334,47 +335,34 @@ export class FileUploader { } protected _xhrTransport(item: FileItem): any { - if (this._getChunkSize()) { - this._restoreChunkTarget(item); - this._runChunkStep(item, '', 0, {}, () => this._uploadChunk(item, 0)); + const chunkSize = Math.max(0, Math.floor(this.options.chunkSize || 0)); + if (chunkSize) { + item._chunkTarget = { url: item.url, method: item.method, headers: [ ...item.headers ] }; + this._runChunkStep(item, () => { + const total = Math.max(1, Math.ceil(item._file.size / chunkSize)); + this._sendXhr(item, this._getChunk(item, 0, 0, chunkSize, total)); + }); return; } this._sendXhr(item); } - protected _getChunkSize(): number { - return Math.max(0, Math.floor(this.options.chunkSize || 0)); - } - - // undo url/method/headers changes made by chunk hooks during a previous upload of this item + // hooks may retarget chunk requests; the item's own callbacks see its original url/method/headers protected _restoreChunkTarget(item: FileItem): void { const target = item._chunkTarget; - if (target && target.end && this._chunkTargetKey(item) === target.end) { + if (target) { item.url = target.url; item.method = target.method; - item.headers = JSON.parse(target.headers); + item.headers = target.headers; + item._chunkTarget = undefined; } - item._chunkTarget = { url: item.url, method: item.method, headers: JSON.stringify(item.headers) }; } - protected _chunkTargetKey(item: FileItem): string { - return JSON.stringify([ item.url, item.method, item.headers ]); - } + protected _getChunk(item: FileItem, index: number, start: number, length: number, total: number): FileChunk { + const end = Math.min(start + length, item._file.size); - protected _uploadChunk(item: FileItem, index: number, retry = 0): void { - const chunkSize = this._getChunkSize(); - const size = item._file.size; - const start = index * chunkSize; - const end = Math.min(start + chunkSize, size); - this._sendXhr(item, { - index, - total: Math.max(1, Math.ceil(size / chunkSize)), - start, - end, - blob: item._file.slice(start, end, item._file.type), - retry - }); + return { index, total, start, end, blob: item._file.slice(start, end, item._file.type), retry: 0 }; } // true when the chunk flow handled the result, false to report it as the item's outcome @@ -394,7 +382,7 @@ export class FileUploader { return false; } - if (!this._runChunkStep(item, response, status, headers, () => this.onSuccessChunk(item, chunk, response, status, headers))) { + if (!this._runChunkStep(item, () => this.onSuccessChunk(item, chunk, response, status, headers))) { return true; } if (item._cancelRequested) { @@ -405,7 +393,9 @@ export class FileUploader { if (chunk.index + 1 >= chunk.total) { return false; } - this._runChunkStep(item, response, status, headers, () => this._uploadChunk(item, chunk.index + 1)); + this._runChunkStep(item, () => { + this._sendXhr(item, this._getChunk(item, chunk.index + 1, chunk.end, chunk.end - chunk.start, chunk.total)); + }); return true; } @@ -416,7 +406,7 @@ export class FileUploader { } protected _retryChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): void { - const retry = () => this._runChunkStep(item, response, status, headers, () => this._uploadChunk(item, chunk.index, chunk.retry + 1)); + const retry = () => this._runChunkStep(item, () => this._sendXhr(item, { ...chunk, retry: chunk.retry + 1 })); const delay = this._getChunkRetryDelay(chunk.retry, headers); if (!delay) { retry(); @@ -445,29 +435,39 @@ export class FileUploader { return Math.min(MAX_CHUNK_RETRY_DELAY, Math.max(0, delay)); } - // chunk steps run in XHR callbacks, outside FileItem.upload()'s try/catch - protected _runChunkStep(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders, - step: () => void): boolean { + // chunk steps run in XHR callbacks: fail the item, then rethrow so the app's error handler sees it + protected _runChunkStep(item: FileItem, step: () => void): boolean { try { step(); return true; } catch (e) { - if (!item.isUploading) { - throw e; - } - const xhr = item._xhr; - if (xhr) { - xhr.onload = xhr.onerror = xhr.onabort = null; - xhr.abort(); + this._throwLater(e); + if (item.isUploading) { + const xhr = item._xhr; + if (xhr) { + xhr.onload = xhr.onerror = xhr.onabort = null; + xhr.abort(); + } + for (const report of [ () => this._onErrorItem(item, '', 0, {}), () => this._onCompleteItem(item, '', 0, {}) ]) { + try { + report(); + } catch (reportError) { + this._throwLater(reportError); + } + } } - this._onErrorItem(item, response, status, headers); - this._onCompleteItem(item, response, status, headers); return false; } } + protected _throwLater(error: unknown): void { + setTimeout(() => { + throw error; + }); + } + protected _hasRequestHeader(item: FileItem, name: string): boolean { return [ ...(this.options.headers || []), ...item.headers ] .some((header: Headers) => header.name.toLowerCase() === name); @@ -536,11 +536,11 @@ export class FileUploader { } xhr.upload.onprogress = (event: any) => { - const ratio = event.lengthComputable ? event.loaded / event.total : 0; - const size = item._file.size; - const progress = chunk && size - ? Math.round((chunk.start + ratio * (chunk.end - chunk.start)) * 100 / size) - : Math.round(ratio * 100); + let progress = Math.round(event.lengthComputable ? event.loaded * 100 / event.total : 0); + if (chunk && item._file.size) { + const sent = event.lengthComputable ? event.loaded / event.total * (chunk.end - chunk.start) : 0; + progress = Math.round((chunk.start + sent) * 100 / item._file.size); + } this._onProgressItem(item, progress); }; xhr.onload = () => { @@ -714,11 +714,13 @@ export class FileUploader { } protected _onSuccessItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { + this._restoreChunkTarget(item); item._onSuccess(response, status, headers); this.onSuccessItem(item, response, status, headers); } protected _onCancelItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { + this._restoreChunkTarget(item); item._onCancel(response, status, headers); this.onCancelItem(item, response, status, headers); } diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts index 85469255..dcbe0302 100644 --- a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts +++ b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts @@ -237,43 +237,119 @@ describe('FileUploader: chunked upload', () => { expect(success).toHaveBeenCalledTimes(1); }); - it('fails the item when a chunk hook throws instead of leaving it stuck', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - const complete = jest.spyOn(uploader, 'onCompleteItem'); - uploader.onBeforeUploadChunk = (_item, chunk) => { - if (chunk.index === 1) { + describe('errors thrown by callbacks', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('fail the item when a chunk hook throws and are rethrown asynchronously', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + const complete = jest.spyOn(uploader, 'onCompleteItem'); + uploader.onBeforeUploadChunk = (_item, chunk) => { + if (chunk.index === 1) { + throw new Error('hook failed'); + } + }; + + uploader.uploadAll(); + expect(() => last().respond(200)).not.toThrow(); + + expect(sent().length).toBe(1); + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls[ 0 ].slice(1)).toEqual([ '', 0, {} ]); + expect(complete).toHaveBeenCalledTimes(1); + expect(item.isError).toBe(true); + expect(uploader.isUploading).toBe(false); + expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); + }); + + it('fail the item when onSuccessChunk throws', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + uploader.onSuccessChunk = () => { throw new Error('hook failed'); - } - }; + }; - uploader.uploadAll(); - expect(() => last().respond(200)).not.toThrow(); + uploader.uploadAll(); + last().respond(200); - expect(sent().length).toBe(1); - expect(error).toHaveBeenCalledTimes(1); - expect(complete).toHaveBeenCalledTimes(1); - expect(item.isError).toBe(true); - expect(item.isUploading).toBe(false); - expect(uploader.isUploading).toBe(false); - }); + expect(sent().length).toBe(1); + expect(item.isError).toBe(true); + expect(uploader.isUploading).toBe(false); + expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); + }); - it('fails the item when onSuccessChunk throws', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - uploader.onSuccessChunk = () => { - throw new Error('hook failed'); - }; + it('report error before complete when a hook throws on the first chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const order: string[] = []; + uploader.onErrorItem = () => order.push('error'); + uploader.onCompleteItem = () => order.push('complete'); + uploader.onBeforeUploadChunk = () => { + throw new Error('hook failed'); + }; - uploader.uploadAll(); - expect(() => last().respond(200)).not.toThrow(); + uploader.uploadAll(); - expect(sent().length).toBe(1); - expect(error).toHaveBeenCalledTimes(1); - expect(item.isError).toBe(true); - expect(uploader.isUploading).toBe(false); + expect(order).toEqual([ 'error', 'complete' ]); + expect(uploader.isUploading).toBe(false); + expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); + }); + + it('report the item once when thrown after the chunk was sent', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const complete = jest.spyOn(uploader, 'onCompleteItem'); + jest.spyOn(uploader as any, '_render').mockImplementationOnce(() => { + throw new Error('render failed'); + }); + + uploader.uploadAll(); + last().respond(200); + + expect(complete).toHaveBeenCalledTimes(1); + expect(uploader.queue[ 0 ].isError).toBe(true); + expect(() => jest.runOnlyPendingTimers()).toThrow('render failed'); + }); + + it('do not report the item twice when onCompleteItem throws during a cancel', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onBeforeUploadChunk = (fileItem, chunk) => { + if (chunk.index === 1) { + fileItem.cancel(); + } + }; + uploader.onCompleteItem = () => { + throw new Error('app failed'); + }; + + uploader.uploadAll(); + last().respond(200); + + expect(error).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + expect(() => jest.runOnlyPendingTimers()).toThrow('app failed'); + }); + + it('do not report the item twice when onErrorItem throws on the first chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const errors: string[] = []; + uploader.onBeforeUploadChunk = () => { + throw new Error('hook failed'); + }; + uploader.onErrorItem = () => { + errors.push('error'); + throw new Error('app failed'); + }; + const complete = jest.spyOn(uploader, 'onCompleteItem'); + + uploader.uploadAll(); + + expect(errors).toEqual([ 'error' ]); + expect(complete).toHaveBeenCalledTimes(1); + expect(uploader.isUploading).toBe(false); + }); }); it('cancel aborts the chunk in flight and stops the upload', () => { @@ -447,35 +523,6 @@ describe('FileUploader: chunked upload', () => { expect(uploader.queue[ 0 ].isError).toBe(true); }); - it('reports error before complete when a hook throws on the first chunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const order: string[] = []; - uploader.onErrorItem = () => order.push('error'); - uploader.onCompleteItem = () => order.push('complete'); - uploader.onBeforeUploadChunk = () => { - throw new Error('hook failed'); - }; - - uploader.uploadAll(); - - expect(order).toEqual([ 'error', 'complete' ]); - expect(uploader.isUploading).toBe(false); - }); - - it('reports the item once when an error is thrown after the chunk was sent', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const complete = jest.spyOn(uploader, 'onCompleteItem'); - jest.spyOn(uploader as any, '_render').mockImplementationOnce(() => { - throw new Error('render failed'); - }); - - uploader.uploadAll(); - last().respond(200); - - expect(complete).toHaveBeenCalledTimes(1); - expect(uploader.queue[ 0 ].isError).toBe(true); - }); - it('continues with the next queued item and removes uploaded items', () => { const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, removeAfterUpload: true }); uploader.addToQueue([ new File([ 'x'.repeat(6 * KB) ], 'a.bin'), new File([ 'y'.repeat(2 * KB) ], 'b.bin') ]); @@ -580,38 +627,89 @@ describe('FileUploader: chunked upload', () => { expect(last().method).toBe('POST'); }); - it('keeps a url set by the app between uploads', () => { + it('keeps a url set by the app in onErrorItem for the re-upload', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; uploader.onSuccessChunk = (fileItem) => fileItem.url = '/upload/session-1'; + uploader.onErrorItem = (fileItem) => fileItem.url = '/fallback'; uploader.uploadAll(); last().respond(200); last().respond(404); - item.url = '/other'; item.upload(); - expect(last().url).toBe('/other'); + expect(last().url).toBe('/fallback'); }); - it('does not report the item twice when onCompleteItem throws during a cancel', () => { + it('gives per-file callbacks the item url from before the upload', () => { const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - uploader.onBeforeUploadChunk = (fileItem, chunk) => { - if (chunk.index === 1) { - fileItem.cancel(); + let urlInSuccess = ''; + uploader.onSuccessChunk = (fileItem) => fileItem.url = '/upload/session-1'; + uploader.onSuccessItem = (fileItem) => urlInSuccess = fileItem.url; + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + last().respond(200); + + expect(urlInSuccess).toBe('/upload'); + }); + + it('keeps the chunk size of a running upload when options change', () => { + const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true }); + + uploader.uploadAll(); + last().respond(200); + uploader.setOptions({ url: '/upload', chunkSize: 3 * KB }); + last().respond(200); + + expect(sent().map(xhr => xhr.requestHeaders[ 'Content-Range' ])).toEqual([ + `bytes 0-${4 * KB - 1}/${10 * KB}`, + `bytes ${4 * KB}-${8 * KB - 1}/${10 * KB}`, + `bytes ${8 * KB}-${10 * KB - 1}/${10 * KB}` + ]); + }); + + it('moves on to the next item when a hook removes the uploading item', () => { + const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, removeAfterUpload: true }); + uploader.addToQueue([ new File([ 'x'.repeat(6 * KB) ], 'a.bin'), new File([ 'y'.repeat(2 * KB) ], 'b.bin') ]); + uploader.onSuccessChunk = (fileItem) => { + if (fileItem.file.name === 'a.bin') { + fileItem.remove(); } }; - uploader.onCompleteItem = () => { - throw new Error('app failed'); - }; uploader.uploadAll(); + expect(() => last().respond(200)).not.toThrow(); + last().respond(200); - expect(() => last().respond(200)).toThrow('app failed'); - expect(error).not.toHaveBeenCalled(); - expect(item.isCancel).toBe(true); + expect(sent().length).toBe(2); + expect(uploader.queue.length).toBe(0); + expect(uploader.isUploading).toBe(false); + }); + + it('keeps the original progress rounding without chunking', () => { + const uploader = createUploader({}); + const item = uploader.queue[ 0 ]; + + uploader.uploadAll(); + last().upload.onprogress({ lengthComputable: true, loaded: 23, total: 40 }); + + expect(item.progress).toBe(58); + }); + + it('keeps a url set by the app between uploads', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + uploader.onSuccessChunk = (fileItem) => fileItem.url = '/upload/session-1'; + + uploader.uploadAll(); + last().respond(200); + last().respond(404); + item.url = '/other'; + item.upload(); + + expect(last().url).toBe('/other'); }); it('keeps a Content-Range header set by the app', () => { From 315816b3afb9bf0fd7dc8badf919f2e6db1b9f7f Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 18:09:01 +0300 Subject: [PATCH 6/9] refactor(uploader): drop Retry-After, make the retry delay cap configurable - chunk retries no longer read the Retry-After header; they wait chunkRetryDelay, doubled per retry - the 30 second cap is now the chunkMaxRetryDelay option (default 30000) --- README.md | 5 ++-- apps/demo/src/doc.md | 5 ++-- .../file-upload/file-uploader.class.ts | 28 +++++-------------- .../testing/spec/file-uploader-chunk.spec.ts | 25 +++++++++-------- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1af4ae09..e09dce26 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields (and any `additionalParameter`); with `disableMultipart` the raw chunk is sent with a `Content-Range` header unless you set that header yourself (`formatDataFunction` is not used for chunks). 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. - 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry and capped at 30 seconds. A `Retry-After` response header takes precedence (cross-origin servers must list it in `Access-Control-Expose-Headers`). Defaults to 1000; 0 retries immediately. - 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. + 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. Defaults to 1000; 0 retries immediately. + 11. `chunkMaxRetryDelay` - Longest wait in milliseconds before a retry, however often `chunkRetryDelay` has doubled. Defaults to 30000 (30 seconds). + 12. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Changing `item.url`, `item.method` or `item.headers` in `onBeforeUploadChunk` applies to the request about to be sent, in `onSuccessChunk` to the next one, e.g. with an upload id returned by your server. When the upload finishes they are reset to the values they had when it started, before `onSuccessItem`, `onErrorItem` or `onCancelItem` run. An error thrown by a chunk callback fails the item and is rethrown asynchronously, so it still reaches your error handler. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index 8a5b9e41..e2149189 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -31,8 +31,9 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields (and any `additionalParameter`); with `disableMultipart` the raw chunk is sent with a `Content-Range` header unless you set that header yourself (`formatDataFunction` is not used for chunks). 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. - 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry and capped at 30 seconds. A `Retry-After` response header takes precedence (cross-origin servers must list it in `Access-Control-Expose-Headers`). Defaults to 1000; 0 retries immediately. - 11. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. + 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. Defaults to 1000; 0 retries immediately. + 11. `chunkMaxRetryDelay` - Longest wait in milliseconds before a retry, however often `chunkRetryDelay` has doubled. Defaults to 30000 (30 seconds). + 12. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Changing `item.url`, `item.method` or `item.headers` in `onBeforeUploadChunk` applies to the request about to be sent, in `onSuccessChunk` to the next one, e.g. with an upload id returned by your server. When the upload finishes they are reset to the values they had when it started, before `onSuccessItem`, `onErrorItem` or `onCancelItem` run. An error thrown by a chunk callback fails the item and is rethrown asynchronously, so it still reaches your error handler. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index 2be95c66..acc4fa07 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -3,8 +3,6 @@ import { FileLikeObject } from './file-like-object.class'; import { FileItem } from './file-item.class'; import { FileType } from './file-type.class'; -const MAX_CHUNK_RETRY_DELAY = 30000; - function isFile(value: any): boolean { return (File && value instanceof File); } @@ -45,6 +43,7 @@ export interface FileUploaderOptions { chunkSize?: number; chunkRetries?: number; chunkRetryDelay?: number; + chunkMaxRetryDelay?: number; chunkIndexParam?: string; totalChunksParam?: string; } @@ -375,7 +374,7 @@ export class FileUploader { return true; } if (this._isRetryableChunkStatus(status) && chunk.retry < (this.options.chunkRetries || 0)) { - this._retryChunk(item, chunk, response, status, headers); + this._retryChunk(item, chunk); return true; } @@ -405,9 +404,12 @@ export class FileUploader { this._onCompleteItem(item, response, status, headers); } - protected _retryChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): void { + protected _retryChunk(item: FileItem, chunk: FileChunk): void { const retry = () => this._runChunkStep(item, () => this._sendXhr(item, { ...chunk, retry: chunk.retry + 1 })); - const delay = this._getChunkRetryDelay(chunk.retry, headers); + const delay = Math.min( + this.options.chunkMaxRetryDelay ?? 30000, + Math.max(0, this.options.chunkRetryDelay ?? 1000) * Math.pow(2, chunk.retry) + ); if (!delay) { retry(); @@ -419,22 +421,6 @@ export class FileUploader { }, delay); } - protected _getChunkRetryDelay(retry: number, headers: ParsedResponseHeaders): number { - const retryAfter = headers[ 'retry-after' ]; - let delay = Math.max(0, this.options.chunkRetryDelay ?? 1000) * Math.pow(2, retry); - if (retryAfter) { - const seconds = Number(retryAfter); - const date = Date.parse(retryAfter); - if (!isNaN(seconds)) { - delay = seconds * 1000; - } else if (!isNaN(date)) { - delay = date - Date.now(); - } - } - - return Math.min(MAX_CHUNK_RETRY_DELAY, Math.max(0, delay)); - } - // chunk steps run in XHR callbacks: fail the item, then rethrow so the app's error handler sees it protected _runChunkStep(item: FileItem, step: () => void): boolean { try { diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts index dcbe0302..7f0c7529 100644 --- a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts +++ b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts @@ -779,15 +779,14 @@ describe('FileUploader: chunked upload', () => { expect(sent().length).toBe(3); }); - it('uses the Retry-After header when the server sends one', () => { + it('ignores the Retry-After header', () => { const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 100 }); uploader.uploadAll(); last().responseHeaders = 'Retry-After: 3'; last().respond(429); - jest.advanceTimersByTime(2999); - expect(sent().length).toBe(1); - jest.advanceTimersByTime(1); + jest.advanceTimersByTime(100); + expect(sent().length).toBe(2); }); @@ -808,23 +807,25 @@ describe('FileUploader: chunked upload', () => { expect(uploader.isUploading).toBe(false); }); - it('caps the delay at 30 seconds', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); + it('caps the doubled delay at 30 seconds by default', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 20000 }); uploader.uploadAll(); - last().responseHeaders = 'Retry-After: 3600'; last().respond(503); - jest.advanceTimersByTime(30000); + jest.advanceTimersByTime(20000); + expect(sent().length).toBe(2); + last().respond(503); + jest.advanceTimersByTime(29999); expect(sent().length).toBe(2); + jest.advanceTimersByTime(1); + expect(sent().length).toBe(3); }); - it('accepts Retry-After as an HTTP date', () => { - jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 0 }); + it('uses chunkMaxRetryDelay as the cap', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 10000, chunkMaxRetryDelay: 5000 }); uploader.uploadAll(); - last().responseHeaders = 'Retry-After: Thu, 01 Jan 2026 00:00:05 GMT'; last().respond(503); jest.advanceTimersByTime(4999); expect(sent().length).toBe(1); From 3c793105d6b54a030ae0d950ee229794a249348b Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 18:21:33 +0300 Subject: [PATCH 7/9] refactor(uploader): move chunked upload into ChunkedFileUploader FileUploader and FileItem are back to their previous code, so apps that don't opt in are unaffected. Chunking lives in a ChunkedFileUploader subclass that works with the existing directives. The library no longer retries, delays or restores item fields itself; it reports what happened and lets the app decide: - onBeforeUploadChunk / onSuccessChunk / onErrorChunk hooks - resumeItem(item) continues a failed or cancelled upload from the chunk that didn't complete, so apps can implement their own retries - getChunk(item) returns the current chunk Removed: chunkRetries, chunkRetryDelay, chunkMaxRetryDelay, the retryable status list, restoring url/method/headers, and the special handling of errors thrown by callbacks. --- README.md | 42 +- apps/demo/src/doc.md | 42 +- .../chunked-file-uploader.class.ts | 192 ++++ .../file-upload/file-item.class.ts | 9 +- .../file-upload/file-uploader.class.ts | 212 +---- libs/ng2-file-upload/index.ts | 1 + .../spec/chunked-file-uploader.class.spec.ts | 493 ++++++++++ .../testing/spec/file-uploader-chunk.spec.ts | 854 ------------------ 8 files changed, 759 insertions(+), 1086 deletions(-) create mode 100644 libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts create mode 100644 libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts delete mode 100644 libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts diff --git a/README.md b/README.md index e09dce26..b9722ae5 100644 --- a/README.md +++ b/README.md @@ -82,15 +82,6 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g 5. `formatDataFunction` - Function to modify the request body. 'DisableMultipart' must be 'true' for this function to be called. 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. - 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields (and any `additionalParameter`); with `disableMultipart` the raw chunk is sent with a `Content-Range` header unless you set that header yourself (`formatDataFunction` is not used for chunks). - 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. - 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. Defaults to 1000; 0 retries immediately. - 11. `chunkMaxRetryDelay` - Longest wait in milliseconds before a retry, however often `chunkRetryDelay` has doubled. Defaults to 30000 (30 seconds). - 12. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Changing `item.url`, `item.method` or `item.headers` in `onBeforeUploadChunk` applies to the request about to be sent, in `onSuccessChunk` to the next one, e.g. with an upload id returned by your server. When the upload finishes they are reset to the values they had when it started, before `onSuccessItem`, `onErrorItem` or `onCancelItem` run. An error thrown by a chunk callback fails the item and is rethrown asynchronously, so it still reaches your error handler. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). - - With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. ### Events @@ -99,6 +90,39 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g [html demo](https://github.com/valor-software/ng2-file-upload/blob/master/demo/components/file-upload/simple-demo.html) - `onFileDrop` - it fires after a file has been dropped on a Drop Area; you can pass in `$event` to get the list of files that were dropped. i.e. `(onFileDrop)="dropped($event)"` +## Chunked uploads + + Use `ChunkedFileUploader` instead of `FileUploader` to send large files in several requests. It works with the same directives, options and callbacks; without `chunkSize` it behaves exactly like `FileUploader`. + + ```typescript + uploader = new ChunkedFileUploader({ url: URL, chunkSize: 2 * 1024 * 1024 }); + ``` + + Additional options: + + 1. `chunkSize` - Bytes per request. Multipart requests send the chunk as the file field plus `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used). + 2. `chunkIndexParam` / `totalChunksParam` - Names of those form fields. Default to `chunkIndex` and `totalChunks`. + + Additional callbacks and methods: + + - `onBeforeUploadChunk(item, chunk)` - before each chunk request. + - `onSuccessChunk(item, chunk, response, status, headers)` - after each successful chunk. Change `item.url`, `item.method` or `item.headers` here to target the next chunk, e.g. with an upload id from your server. + - `onErrorChunk(item, chunk, response, status, headers)` - when a chunk fails; the item then fails as usual (`onErrorItem`, `onCompleteItem`) and no more chunks are sent. + - `resumeItem(item)` - uploads a failed or cancelled item again, starting from the chunk that did not complete. Use it to retry, e.g. from `onErrorItem`: + + ```typescript + let retries = 0; + uploader.onErrorItem = (item, response, status) => { + if (status >= 500 && retries++ < 3) { + setTimeout(() => uploader.resumeItem(item), 1000); + } + }; + ``` + + - `getChunk(item)` - the chunk being sent, or the last one sent. + + `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks. + # Troubleshooting Please follow these guidelines when reporting bugs and feature requests: diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index e2149189..c3c41d63 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -29,15 +29,6 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u 5. `formatDataFunction` - Function to modify the request body. 'DisableMultipart' must be 'true' for this function to be called. 6. `formatDataFunctionIsAsync` - Informs if the function sent in 'formatDataFunction' is asynchronous. Defaults to false. 7. `parametersBeforeFiles` - States if additional parameters should be appended before or after the file. Defaults to false. - 8. `chunkSize` - Upload files in chunks of this many bytes, one request per chunk. Disabled by default. In multipart mode each chunk is sent as the file field together with `chunkIndex` and `totalChunks` fields (and any `additionalParameter`); with `disableMultipart` the raw chunk is sent with a `Content-Range` header unless you set that header yourself (`formatDataFunction` is not used for chunks). - 9. `chunkRetries` - How many times a failed chunk is retried before the item fails. Only network errors and 408, 429 and 5xx (except 501 and 505) responses are retried. Defaults to 0. - 10. `chunkRetryDelay` - Milliseconds to wait before the first retry, doubled for every further retry. Defaults to 1000; 0 retries immediately. - 11. `chunkMaxRetryDelay` - Longest wait in milliseconds before a retry, however often `chunkRetryDelay` has doubled. Defaults to 30000 (30 seconds). - 12. `chunkIndexParam` / `totalChunksParam` - Form field names for the chunk index and total chunk count. Default to `chunkIndex` and `totalChunks`. - - Chunk callbacks on `FileUploader`: `onBeforeUploadChunk(item, chunk)` runs before every chunk request (retries included, with `chunk.retry` increased) and `onSuccessChunk(item, chunk, response, status, headers)` after every successful one. Changing `item.url`, `item.method` or `item.headers` in `onBeforeUploadChunk` applies to the request about to be sent, in `onSuccessChunk` to the next one, e.g. with an upload id returned by your server. When the upload finishes they are reset to the values they had when it started, before `onSuccessItem`, `onErrorItem` or `onCancelItem` run. An error thrown by a chunk callback fails the item and is rethrown asynchronously, so it still reaches your error handler. Callbacks are synchronous, so protocol steps that need their own requests (creating or completing an S3 multipart upload, committing Azure blocks) belong before `upload()` and in `onSuccessItem`. `item.cancel()` stops the remaining chunks, also when called from any callback or while waiting to retry. Chunking needs `isHTML5` (the default). - - With `chunkSize` set, `onBuildItemForm` and the `response` emitter fire once per chunk request (retries included), while `onSuccessItem`, `onErrorItem` and `onCompleteItem` fire once per file. `item.chunk` holds the current chunk, so `onBuildItemForm` can add per-chunk fields, e.g. a file id, byte offset or total size if your server needs to tell parallel uploads apart. ### Events - `onFileSelected` - fires when files are selected and added to the uploader queue @@ -54,3 +45,36 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u See using in [ts demo](https://github.com/valor-software/ng2-file-upload/blob/master/demo/components/file-upload/simple-demo.ts) and [html demo](https://github.com/valor-software/ng2-file-upload/blob/master/demo/components/file-upload/simple-demo.html) - `onFileDrop` - it fires after a file has been dropped on a Drop Area; you can pass in `$event` to get the list of files that were dropped. i.e. `(onFileDrop)="dropped($event)"` + +## Chunked uploads + + Use `ChunkedFileUploader` instead of `FileUploader` to send large files in several requests. It works with the same directives, options and callbacks; without `chunkSize` it behaves exactly like `FileUploader`. + + ```typescript + uploader = new ChunkedFileUploader({ url: URL, chunkSize: 2 * 1024 * 1024 }); + ``` + + Additional options: + + 1. `chunkSize` - Bytes per request. Multipart requests send the chunk as the file field plus `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used). + 2. `chunkIndexParam` / `totalChunksParam` - Names of those form fields. Default to `chunkIndex` and `totalChunks`. + + Additional callbacks and methods: + + - `onBeforeUploadChunk(item, chunk)` - before each chunk request. + - `onSuccessChunk(item, chunk, response, status, headers)` - after each successful chunk. Change `item.url`, `item.method` or `item.headers` here to target the next chunk, e.g. with an upload id from your server. + - `onErrorChunk(item, chunk, response, status, headers)` - when a chunk fails; the item then fails as usual (`onErrorItem`, `onCompleteItem`) and no more chunks are sent. + - `resumeItem(item)` - uploads a failed or cancelled item again, starting from the chunk that did not complete. Use it to retry, e.g. from `onErrorItem`: + + ```typescript + let retries = 0; + uploader.onErrorItem = (item, response, status) => { + if (status >= 500 && retries++ < 3) { + setTimeout(() => uploader.resumeItem(item), 1000); + } + }; + ``` + + - `getChunk(item)` - the chunk being sent, or the last one sent. + + `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks. diff --git a/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts b/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts new file mode 100644 index 00000000..e41ce174 --- /dev/null +++ b/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts @@ -0,0 +1,192 @@ +import { FileItem } from './file-item.class'; +import { FileUploader, FileUploaderOptions, ParsedResponseHeaders } from './file-uploader.class'; + +export interface ChunkedFileUploaderOptions extends FileUploaderOptions { + chunkSize?: number; + chunkIndexParam?: string; + totalChunksParam?: string; +} + +export interface FileChunk { + index: number; + total: number; + start: number; + end: number; + blob: Blob; +} + +interface ChunkState { + chunk?: FileChunk; + resume?: FileChunk; + cancelled?: boolean; +} + +export class ChunkedFileUploader extends FileUploader { + declare options: ChunkedFileUploaderOptions; + + protected _chunks = new WeakMap(); + + constructor(options: ChunkedFileUploaderOptions) { + super(options); + } + + setOptions(options: ChunkedFileUploaderOptions): void { + super.setOptions(options); + } + + getChunk(item: FileItem): FileChunk | undefined { + return this._chunks.get(item)?.chunk; + } + + resumeItem(item: FileItem): void { + const state = this._chunks.get(item); + if (item.isUploading) { + return; + } + if (state?.chunk && !item.isSuccess) { + state.resume = state.chunk; + } + item.upload(); + } + + cancelItem(value: FileItem): void { + const item = this.queue[ this.getIndexOfItem(value) ]; + const state = item && this._chunks.get(item); + if (state && item.isUploading) { + state.cancelled = true; + item._xhr?.abort(); + + return; + } + super.cancelItem(value); + } + + onBeforeUploadChunk(item: FileItem, chunk: FileChunk): any { + return { item, chunk }; + } + + onSuccessChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): any { + return { item, chunk, response, status, headers }; + } + + onErrorChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): any { + return { item, chunk, response, status, headers }; + } + + protected _xhrTransport(item: FileItem): any { + const chunkSize = Math.floor(this.options.chunkSize || 0); + if (chunkSize <= 0) { + return super._xhrTransport(item); + } + const resume = this._chunks.get(item)?.resume; + this._chunks.set(item, {}); + this._onBeforeUploadItem(item); + + if (typeof item._file.size !== 'number') { + throw new TypeError('The file specified is no longer valid'); + } + const total = Math.max(1, Math.ceil(item._file.size / chunkSize)); + this._sendChunk(item, resume || this._getChunk(item, 0, 0, chunkSize, total)); + } + + protected _getChunk(item: FileItem, index: number, start: number, length: number, total: number): FileChunk { + const end = Math.min(start + length, item._file.size); + + return { index, total, start, end, blob: item._file.slice(start, end, item._file.type) }; + } + + protected _sendChunk(item: FileItem, chunk: FileChunk): void { + const state = this._chunks.get(item) as ChunkState; + const xhr = item._xhr = new XMLHttpRequest(); + let sendable: any = chunk.blob; + state.chunk = chunk; + this.onBeforeUploadChunk(item, chunk); + + if (!this.options.disableMultipart) { + sendable = new FormData(); + this._onBuildItemForm(item, sendable); + const appendFile = () => sendable.append(item.alias, chunk.blob, item.file.name); + if (!this.options.parametersBeforeFiles) { + appendFile(); + } + Object.keys(this.options.additionalParameter || {}).forEach((key: string) => { + const paramVal = this.options.additionalParameter?.[ key ]; + sendable.append(key, typeof paramVal === 'string' && item.file?.name ? paramVal.replace('{{file_name}}', item.file.name) : paramVal); + }); + sendable.append(this.options.chunkIndexParam || 'chunkIndex', chunk.index.toString()); + sendable.append(this.options.totalChunksParam || 'totalChunks', chunk.total.toString()); + if (this.options.parametersBeforeFiles) { + appendFile(); + } + } + if (state.cancelled) { + this._onCancelItem(item, '', 0, {}); + this._onCompleteItem(item, '', 0, {}); + + return; + } + + xhr.upload.onprogress = (event: any) => { + const sent = event.lengthComputable ? event.loaded / event.total * (chunk.end - chunk.start) : 0; + this._onProgressItem(item, item._file.size ? Math.round((chunk.start + sent) * 100 / item._file.size) : 0); + }; + xhr.onload = () => this._onChunkDone(item, chunk, xhr, this._isSuccessCode(xhr.status)); + xhr.onerror = () => this._onChunkDone(item, chunk, xhr, false); + xhr.onabort = () => { + state.cancelled = true; + this._onChunkDone(item, chunk, xhr, false); + }; + if (item.method && item.url) { + xhr.open(item.method, item.url, true); + } + xhr.withCredentials = item.withCredentials; + const headers = [ ...(this.options.headers || []), ...item.headers ]; + for (const header of headers) { + xhr.setRequestHeader(header.name, header.value); + } + if (this.authToken && this.authTokenHeader) { + xhr.setRequestHeader(this.authTokenHeader, this.authToken); + } + if (this.options.disableMultipart && !headers.some(header => header.name.toLowerCase() === 'content-range')) { + const range = chunk.end > chunk.start ? `${ chunk.start }-${ chunk.end - 1 }` : '*'; + xhr.setRequestHeader('Content-Range', `bytes ${ range }/${ item._file.size }`); + } + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.DONE) { + this.response.emit(xhr.responseText); + } + }; + xhr.send(sendable); + this._render(); + } + + protected _onChunkDone(item: FileItem, chunk: FileChunk, xhr: XMLHttpRequest, isSuccess: boolean): void { + const state = this._chunks.get(item) as ChunkState; + const headers = this._parseHeaders(xhr.getAllResponseHeaders()); + const response = this._transformResponse(xhr.response); + if (!state.cancelled && !isSuccess) { + this.onErrorChunk(item, chunk, response, xhr.status, headers); + this._onErrorItem(item, response, xhr.status, headers); + this._onCompleteItem(item, response, xhr.status, headers); + + return; + } + if (!state.cancelled) { + this.onSuccessChunk(item, chunk, response, xhr.status, headers); + } + // cancel() between chunks has no request to abort, so it is picked up here + if (state.cancelled) { + this._onCancelItem(item, response, xhr.status, headers); + this._onCompleteItem(item, response, xhr.status, headers); + + return; + } + if (chunk.index + 1 < chunk.total) { + this._sendChunk(item, this._getChunk(item, chunk.index + 1, chunk.end, chunk.end - chunk.start, chunk.total)); + + return; + } + this._onSuccessItem(item, response, xhr.status, headers); + this._onCompleteItem(item, response, xhr.status, headers); + } +} diff --git a/libs/ng2-file-upload/file-upload/file-item.class.ts b/libs/ng2-file-upload/file-upload/file-item.class.ts index f494d411..187bb2a6 100644 --- a/libs/ng2-file-upload/file-upload/file-item.class.ts +++ b/libs/ng2-file-upload/file-upload/file-item.class.ts @@ -1,5 +1,5 @@ import { FileLikeObject } from './file-like-object.class'; -import { FileUploader, ParsedResponseHeaders, FileUploaderOptions, FileChunk } from './file-uploader.class'; +import { FileUploader, ParsedResponseHeaders, FileUploaderOptions } from './file-uploader.class'; export class FileItem { file: FileLikeObject; @@ -18,12 +18,8 @@ export class FileItem { isError = false; progress = 0; index?: number; - chunk?: FileChunk; _xhr?: XMLHttpRequest; _form: any; - _cancelRequested = false; - _chunkRetryTimer?: ReturnType; - _chunkTarget?: { url: string; method?: string; headers: any }; protected uploader: FileUploader; protected some: File; @@ -95,9 +91,6 @@ export class FileItem { this.isCancel = false; this.isError = false; this.progress = 0; - this.chunk = undefined; - this._cancelRequested = false; - this._chunkRetryTimer = undefined; this.onBeforeUpload(); } diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index acc4fa07..c5f0e58a 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -40,21 +40,6 @@ export interface FileUploaderOptions { // eslint-disable-next-line formatDataFunction?: Function; formatDataFunctionIsAsync?: boolean; - chunkSize?: number; - chunkRetries?: number; - chunkRetryDelay?: number; - chunkMaxRetryDelay?: number; - chunkIndexParam?: string; - totalChunksParam?: string; -} - -export interface FileChunk { - index: number; - total: number; - start: number; - end: number; - blob: Blob; - retry: number; } export class FileUploader { @@ -151,9 +136,6 @@ export class FileUploader { removeFromQueue(value: FileItem): void { const index = this.getIndexOfItem(value); const item = this.queue[ index ]; - if (!item) { - return; - } if (item.isUploading) { item.cancel(); } @@ -185,15 +167,7 @@ export class FileUploader { const item = this.queue[ index ]; const prop = this.options.isHTML5 ? item._xhr : item._form; if (item && item.isUploading) { - item._cancelRequested = true; - if (item._chunkRetryTimer !== undefined) { - clearTimeout(item._chunkRetryTimer); - item._chunkRetryTimer = undefined; - this._cancelChunkedItem(item, '', 0, {}); - - return; - } - prop?.abort(); + prop.abort(); } } @@ -281,14 +255,6 @@ export class FileUploader { return void 0; } - onBeforeUploadChunk(item: FileItem, chunk: FileChunk): any { - return { item, chunk }; - } - - onSuccessChunk(item: FileItem, chunk: FileChunk, response: string, status: number, headers: ParsedResponseHeaders): any { - return { item, chunk, response, status, headers }; - } - _mimeTypeFilter(item: FileLikeObject): boolean { return !(item?.type && this.options.allowedMimeType && this.options.allowedMimeType?.indexOf(item.type) === -1); } @@ -303,7 +269,6 @@ export class FileUploader { } _onErrorItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { - this._restoreChunkTarget(item); item._onError(response, status, headers); this.onErrorItem(item, response, status, headers); } @@ -334,156 +299,20 @@ export class FileUploader { } protected _xhrTransport(item: FileItem): any { - const chunkSize = Math.max(0, Math.floor(this.options.chunkSize || 0)); - if (chunkSize) { - item._chunkTarget = { url: item.url, method: item.method, headers: [ ...item.headers ] }; - this._runChunkStep(item, () => { - const total = Math.max(1, Math.ceil(item._file.size / chunkSize)); - this._sendXhr(item, this._getChunk(item, 0, 0, chunkSize, total)); - }); - - return; - } - this._sendXhr(item); - } - - // hooks may retarget chunk requests; the item's own callbacks see its original url/method/headers - protected _restoreChunkTarget(item: FileItem): void { - const target = item._chunkTarget; - if (target) { - item.url = target.url; - item.method = target.method; - item.headers = target.headers; - item._chunkTarget = undefined; - } - } - - protected _getChunk(item: FileItem, index: number, start: number, length: number, total: number): FileChunk { - const end = Math.min(start + length, item._file.size); - - return { index, total, start, end, blob: item._file.slice(start, end, item._file.type), retry: 0 }; - } - - // true when the chunk flow handled the result, false to report it as the item's outcome - protected _onChunkDone(item: FileItem, chunk: FileChunk, isSuccess: boolean, response: string, status: number, - headers: ParsedResponseHeaders): boolean { - if (!isSuccess) { - if (item._cancelRequested) { - this._cancelChunkedItem(item, response, status, headers); - - return true; - } - if (this._isRetryableChunkStatus(status) && chunk.retry < (this.options.chunkRetries || 0)) { - this._retryChunk(item, chunk); - - return true; - } - - return false; - } - if (!this._runChunkStep(item, () => this.onSuccessChunk(item, chunk, response, status, headers))) { - return true; - } - if (item._cancelRequested) { - this._cancelChunkedItem(item, response, status, headers); - - return true; - } - if (chunk.index + 1 >= chunk.total) { - return false; - } - this._runChunkStep(item, () => { - this._sendXhr(item, this._getChunk(item, chunk.index + 1, chunk.end, chunk.end - chunk.start, chunk.total)); - }); - - return true; - } - - protected _cancelChunkedItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { - this._onCancelItem(item, response, status, headers); - this._onCompleteItem(item, response, status, headers); - } - - protected _retryChunk(item: FileItem, chunk: FileChunk): void { - const retry = () => this._runChunkStep(item, () => this._sendXhr(item, { ...chunk, retry: chunk.retry + 1 })); - const delay = Math.min( - this.options.chunkMaxRetryDelay ?? 30000, - Math.max(0, this.options.chunkRetryDelay ?? 1000) * Math.pow(2, chunk.retry) - ); - if (!delay) { - retry(); - - return; - } - item._chunkRetryTimer = setTimeout(() => { - item._chunkRetryTimer = undefined; - retry(); - }, delay); - } - - // chunk steps run in XHR callbacks: fail the item, then rethrow so the app's error handler sees it - protected _runChunkStep(item: FileItem, step: () => void): boolean { - try { - step(); - - return true; - } catch (e) { - this._throwLater(e); - if (item.isUploading) { - const xhr = item._xhr; - if (xhr) { - xhr.onload = xhr.onerror = xhr.onabort = null; - xhr.abort(); - } - for (const report of [ () => this._onErrorItem(item, '', 0, {}), () => this._onCompleteItem(item, '', 0, {}) ]) { - try { - report(); - } catch (reportError) { - this._throwLater(reportError); - } - } - } - - return false; - } - } - - protected _throwLater(error: unknown): void { - setTimeout(() => { - throw error; - }); - } - - protected _hasRequestHeader(item: FileItem, name: string): boolean { - return [ ...(this.options.headers || []), ...item.headers ] - .some((header: Headers) => header.name.toLowerCase() === name); - } - - protected _isRetryableChunkStatus(status: number): boolean { - return status === 0 || status === 408 || status === 429 || (status >= 500 && status !== 501 && status !== 505); - } - - protected _sendXhr(item: FileItem, chunk?: FileChunk): void { // tslint:disable-next-line:no-this-assignment // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this; const xhr = item._xhr = new XMLHttpRequest(); let sendable: any; - if (!chunk || (chunk.index === 0 && chunk.retry === 0)) { - this._onBeforeUploadItem(item); - } + this._onBeforeUploadItem(item); if (typeof item._file.size !== 'number') { throw new TypeError('The file specified is no longer valid'); } - if (chunk) { - item.chunk = chunk; - this.onBeforeUploadChunk(item, chunk); - } if (!this.options.disableMultipart) { sendable = new FormData(); this._onBuildItemForm(item, sendable); - const appendFile = () => sendable.append(item.alias, chunk ? chunk.blob : item._file, item.file.name); + const appendFile = () => sendable.append(item.alias, item._file, item.file.name); if (!this.options.parametersBeforeFiles) { appendFile(); } @@ -500,43 +329,23 @@ export class FileUploader { }); } - if (chunk) { - sendable.append(this.options.chunkIndexParam || 'chunkIndex', chunk.index.toString()); - sendable.append(this.options.totalChunksParam || 'totalChunks', chunk.total.toString()); - } - if (appendFile && this.options.parametersBeforeFiles) { appendFile(); } - } else if (chunk) { - sendable = chunk.blob; } else { if (this.options.formatDataFunction) { sendable = this.options.formatDataFunction(item); } } - if (chunk && item._cancelRequested) { - this._cancelChunkedItem(item, '', 0, {}); - - return; - } xhr.upload.onprogress = (event: any) => { - let progress = Math.round(event.lengthComputable ? event.loaded * 100 / event.total : 0); - if (chunk && item._file.size) { - const sent = event.lengthComputable ? event.loaded / event.total * (chunk.end - chunk.start) : 0; - progress = Math.round((chunk.start + sent) * 100 / item._file.size); - } + const progress = Math.round(event.lengthComputable ? event.loaded * 100 / event.total : 0); this._onProgressItem(item, progress); }; xhr.onload = () => { const headers = this._parseHeaders(xhr.getAllResponseHeaders()); const response = this._transformResponse(xhr.response); - const isSuccess = this._isSuccessCode(xhr.status); - if (chunk && this._onChunkDone(item, chunk, isSuccess, response, xhr.status, headers)) { - return; - } - const gist = isSuccess ? 'Success' : 'Error'; + const gist = this._isSuccessCode(xhr.status) ? 'Success' : 'Error'; const method = `_on${gist}Item`; (this as any)[ method ](item, response, xhr.status, headers); this._onCompleteItem(item, response, xhr.status, headers); @@ -544,9 +353,6 @@ export class FileUploader { xhr.onerror = () => { const headers = this._parseHeaders(xhr.getAllResponseHeaders()); const response = this._transformResponse(xhr.response); - if (chunk && this._onChunkDone(item, chunk, false, response, xhr.status, headers)) { - return; - } this._onErrorItem(item, response, xhr.status, headers); this._onCompleteItem(item, response, xhr.status, headers); }; @@ -573,16 +379,12 @@ export class FileUploader { if (this.authToken && this.authTokenHeader) { xhr.setRequestHeader(this.authTokenHeader, this.authToken); } - if (chunk && this.options.disableMultipart && !this._hasRequestHeader(item, 'content-range')) { - const range = chunk.end > chunk.start ? `${chunk.start}-${chunk.end - 1}` : '*'; - xhr.setRequestHeader('Content-Range', `bytes ${range}/${item._file.size}`); - } xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { that.response.emit(xhr.responseText); } }; - if (this.options.formatDataFunctionIsAsync && !chunk) { + if (this.options.formatDataFunctionIsAsync) { sendable.then( (result: any) => xhr.send(JSON.stringify(result)) ); @@ -700,13 +502,11 @@ export class FileUploader { } protected _onSuccessItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { - this._restoreChunkTarget(item); item._onSuccess(response, status, headers); this.onSuccessItem(item, response, status, headers); } protected _onCancelItem(item: FileItem, response: string, status: number, headers: ParsedResponseHeaders): void { - this._restoreChunkTarget(item); item._onCancel(response, status, headers); this.onCancelItem(item, response, status, headers); } diff --git a/libs/ng2-file-upload/index.ts b/libs/ng2-file-upload/index.ts index 3326c0df..0744dba2 100644 --- a/libs/ng2-file-upload/index.ts +++ b/libs/ng2-file-upload/index.ts @@ -1,4 +1,5 @@ export * from './file-upload/file-uploader.class'; +export * from './file-upload/chunked-file-uploader.class'; export * from './file-upload/file-item.class'; export * from './file-upload/file-like-object.class'; export * from './file-upload/file-like-object.class'; diff --git a/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts b/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts new file mode 100644 index 00000000..b1aa9727 --- /dev/null +++ b/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts @@ -0,0 +1,493 @@ +import { ChunkedFileUploader, ChunkedFileUploaderOptions } from '../../file-upload/chunked-file-uploader.class'; +import { FileItem } from '../../file-upload/file-item.class'; + +class FakeXhr { + static instances: FakeXhr[] = []; + upload: any = {}; + status = 0; + response = ''; + responseText = ''; + readyState = 0; + withCredentials = false; + method?: string; + url?: string; + body: any; + requestHeaders: { [ name: string ]: string } = {}; + sent = false; + aborted = false; + onload?: () => void; + onerror?: () => void; + onabort?: () => void; + onreadystatechange?: () => void; + + constructor() { + FakeXhr.instances.push(this); + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.requestHeaders[ name ] = value; + } + + send(body: any): void { + this.body = body; + this.sent = true; + } + + abort(): void { + if (!this.sent || this.readyState === 4) { + return; + } + this.aborted = true; + this.onabort?.(); + } + + getAllResponseHeaders(): string { + return ''; + } + + respond(status: number, response = ''): void { + this.status = status; + this.response = response; + this.responseText = response; + this.readyState = 4; + this.onload?.(); + this.onreadystatechange?.(); + } +} + +const KB = 1024; + +function createUploader(options: Partial, file = new File([ 'x'.repeat(10 * KB) ], 'file.bin')): ChunkedFileUploader { + const uploader = new ChunkedFileUploader({ url: '/upload', ...options }); + uploader.addToQueue([ file ]); + + return uploader; +} + +function sent(): FakeXhr[] { + return FakeXhr.instances.filter(xhr => xhr.sent); +} + +function last(): FakeXhr { + const requests = sent(); + + return requests[ requests.length - 1 ]; +} + +function form(xhr: FakeXhr = last()): FormData { + return xhr.body as FormData; +} + +describe('ChunkedFileUploader', () => { + const originalXhr = (globalThis as any).XMLHttpRequest; + + beforeEach(() => { + FakeXhr.instances = []; + (globalThis as any).XMLHttpRequest = FakeXhr; + (FakeXhr as any).DONE = 4; + }); + + afterEach(() => { + (globalThis as any).XMLHttpRequest = originalXhr; + }); + + it('uploads the whole file in one request without chunkSize', () => { + const uploader = createUploader({}); + const success = jest.spyOn(uploader, 'onSuccessItem'); + + uploader.uploadAll(); + last().respond(200); + + expect(sent().length).toBe(1); + expect(form().get('chunkIndex')).toBeNull(); + expect(success).toHaveBeenCalledTimes(1); + }); + + it('splits the file into chunks and reports the file once', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const beforeChunk = jest.spyOn(uploader, 'onBeforeUploadChunk'); + const successChunk = jest.spyOn(uploader, 'onSuccessChunk'); + const beforeItem = jest.spyOn(uploader, 'onBeforeUploadItem'); + const success = jest.spyOn(uploader, 'onSuccessItem'); + const complete = jest.spyOn(uploader, 'onCompleteItem'); + + uploader.uploadAll(); + for (let i = 0; i < 3; i++) { + expect(form().get('chunkIndex')).toBe(String(i)); + expect(form().get('totalChunks')).toBe('3'); + expect((form().get('file') as File).size).toBe(i < 2 ? 4 * KB : 2 * KB); + last().respond(200, `chunk-${ i }`); + } + + expect(sent().length).toBe(3); + expect(beforeItem).toHaveBeenCalledTimes(1); + expect(beforeChunk).toHaveBeenCalledTimes(3); + expect(successChunk).toHaveBeenCalledTimes(3); + expect(success).toHaveBeenCalledTimes(1); + expect(success.mock.calls[ 0 ][ 1 ]).toBe('chunk-2'); + expect(complete).toHaveBeenCalledTimes(1); + expect(item.isSuccess).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('exposes the current chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const seen: number[] = []; + uploader.onBuildItemForm = (fileItem) => seen.push(uploader.getChunk(fileItem)?.index as number); + + uploader.uploadAll(); + last().respond(200); + + expect(seen).toEqual([ 0, 1 ]); + expect(uploader.getChunk(item)?.index).toBe(1); + }); + + it('uses custom chunk field names', () => { + const uploader = createUploader({ chunkSize: 4 * KB, chunkIndexParam: 'current_chunk', totalChunksParam: 'total_chunks' }); + + uploader.uploadAll(); + + expect(form().get('current_chunk')).toBe('0'); + expect(form().get('total_chunks')).toBe('3'); + }); + + it('sends additional parameters with every chunk, before the file when asked', () => { + const uploader = createUploader({ + chunkSize: 4 * KB, + parametersBeforeFiles: true, + additionalParameter: { name: '{{file_name}}' } + }); + + uploader.uploadAll(); + + expect([ ...form().keys() ]).toEqual([ 'name', 'chunkIndex', 'totalChunks', 'file' ]); + expect(form().get('name')).toBe('file.bin'); + }); + + it('keeps the file MIME type on chunks', () => { + const uploader = createUploader({ chunkSize: 4 * KB }, new File([ 'x'.repeat(10 * KB) ], 'photo.png', { type: 'image/png' })); + + uploader.uploadAll(); + + expect(form().get('file')).toHaveProperty('type', 'image/png'); + }); + + it('sends raw chunks with Content-Range when multipart is disabled', () => { + const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true }); + + uploader.uploadAll(); + expect(last().body).toBeInstanceOf(Blob); + expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes 0-${ 4 * KB - 1 }/${ 10 * KB }`); + last().respond(200); + expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes ${ 4 * KB }-${ 8 * KB - 1 }/${ 10 * KB }`); + }); + + it('keeps a Content-Range header set by the app', () => { + const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true, headers: [ { name: 'Content-Range', value: 'custom' } ] }); + + uploader.uploadAll(); + + expect(last().requestHeaders[ 'Content-Range' ]).toBe('custom'); + }); + + it('uploads an empty file as one chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true }, new File([], 'empty.txt')); + const success = jest.spyOn(uploader, 'onSuccessItem'); + + uploader.uploadAll(); + expect(last().requestHeaders[ 'Content-Range' ]).toBe('bytes */0'); + last().respond(200); + + expect(success).toHaveBeenCalledTimes(1); + }); + + it('ignores the fractional part of chunkSize', () => { + const uploader = createUploader({ chunkSize: 4 * KB + 0.5, disableMultipart: true }); + + uploader.uploadAll(); + + expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes 0-${ 4 * KB - 1 }/${ 10 * KB }`); + }); + + it('keeps the chunk size of a running upload when options change', () => { + const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true }); + + uploader.uploadAll(); + last().respond(200); + uploader.setOptions({ url: '/upload', chunkSize: 3 * KB }); + last().respond(200); + + expect(sent().map(xhr => xhr.requestHeaders[ 'Content-Range' ])).toEqual([ + `bytes 0-${ 4 * KB - 1 }/${ 10 * KB }`, + `bytes ${ 4 * KB }-${ 8 * KB - 1 }/${ 10 * KB }`, + `bytes ${ 8 * KB }-${ 10 * KB - 1 }/${ 10 * KB }` + ]); + }); + + it('sends headers and the auth token with every chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB, authToken: 'Bearer t', headers: [ { name: 'X-App', value: '1' } ] }); + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + + expect(sent().map(xhr => [ xhr.requestHeaders[ 'Authorization' ], xhr.requestHeaders[ 'X-App' ] ])) + .toEqual([ [ 'Bearer t', '1' ], [ 'Bearer t', '1' ], [ 'Bearer t', '1' ] ]); + }); + + it('lets onSuccessChunk retarget the next chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + uploader.onSuccessChunk = (item, _chunk, response) => { + item.url = `/upload/${ response }`; + item.method = 'PUT'; + }; + + uploader.uploadAll(); + last().respond(200, 'abc'); + + expect(last().url).toBe('/upload/abc'); + expect(last().method).toBe('PUT'); + }); + + it('reports progress across the whole file', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + + uploader.uploadAll(); + last().respond(200); + last().upload.onprogress({ lengthComputable: true, loaded: 1, total: 2 }); + + expect(item.progress).toBe(60); + }); + + it('emits response and builds the form once per chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const buildForm = jest.spyOn(uploader, 'onBuildItemForm'); + const responses: string[] = []; + uploader.response.subscribe((res: string) => responses.push(res)); + + uploader.uploadAll(); + last().respond(200, 'r0'); + last().respond(200, 'r1'); + last().respond(200, 'r2'); + + expect(buildForm).toHaveBeenCalledTimes(3); + expect(responses).toEqual([ 'r0', 'r1', 'r2' ]); + }); + + describe('errors', () => { + it('report the failed chunk and fail the item without sending more', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const errorChunk = jest.spyOn(uploader, 'onErrorChunk'); + const successChunk = jest.spyOn(uploader, 'onSuccessChunk'); + const error = jest.spyOn(uploader, 'onErrorItem'); + + uploader.uploadAll(); + last().respond(200); + last().respond(503, 'busy'); + + expect(sent().length).toBe(2); + expect(errorChunk).toHaveBeenCalledTimes(1); + expect(errorChunk.mock.calls[ 0 ][ 1 ].index).toBe(1); + expect(errorChunk.mock.calls[ 0 ].slice(2, 4)).toEqual([ 'busy', 503 ]); + expect(successChunk).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledTimes(1); + expect(item.isError).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('report network errors the same way', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const errorChunk = jest.spyOn(uploader, 'onErrorChunk'); + + uploader.uploadAll(); + last().onerror?.(); + + expect(errorChunk).toHaveBeenCalledTimes(1); + expect(uploader.queue[ 0 ].isError).toBe(true); + }); + }); + + describe('resumeItem', () => { + it('continues from the failed chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const success = jest.spyOn(uploader, 'onSuccessItem'); + + uploader.uploadAll(); + last().respond(200); + last().respond(500); + uploader.resumeItem(item); + + expect(form().get('chunkIndex')).toBe('1'); + last().respond(200); + last().respond(200); + + expect(sent().map(xhr => (xhr.body as FormData).get('chunkIndex'))).toEqual([ '0', '1', '1', '2' ]); + expect(success).toHaveBeenCalledTimes(1); + expect(item.isSuccess).toBe(true); + }); + + it('lets the app retry from onErrorItem', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + let retries = 0; + uploader.onErrorItem = (item, _response, status) => { + if (status >= 500 && retries++ < 2) { + uploader.resumeItem(item); + } + }; + + uploader.uploadAll(); + last().respond(503); + last().respond(503); + last().respond(503); + + expect(sent().length).toBe(3); + expect(sent().every(xhr => (xhr.body as FormData).get('chunkIndex') === '0')).toBe(true); + expect(uploader.queue[ 0 ].isError).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('continues from the cancelled chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + + uploader.uploadAll(); + last().respond(200); + item.cancel(); + uploader.resumeItem(item); + + expect(form().get('chunkIndex')).toBe('1'); + }); + + it('starts over after a successful upload', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + last().respond(200); + uploader.resumeItem(item); + + expect(form().get('chunkIndex')).toBe('0'); + }); + + it('does nothing while the item is uploading', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + + uploader.uploadAll(); + uploader.resumeItem(uploader.queue[ 0 ]); + + expect(sent().length).toBe(1); + }); + }); + + describe('cancel', () => { + function expectCancelled(uploader: ChunkedFileUploader, item: FileItem): void { + expect(item.isCancel).toBe(true); + expect(item.isError).toBe(false); + expect(uploader.isUploading).toBe(false); + } + + it('aborts the chunk in flight', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const cancel = jest.spyOn(uploader, 'onCancelItem'); + + uploader.uploadAll(); + last().respond(200); + item.cancel(); + + expect(last().aborted).toBe(true); + expect(sent().length).toBe(2); + expect(cancel).toHaveBeenCalledTimes(1); + expectCancelled(uploader, item); + }); + + it.each([ + [ 'onBeforeUploadItem', (uploader: ChunkedFileUploader) => uploader.onBeforeUploadItem = (item: FileItem) => item.cancel(), 0 ], + [ 'onBeforeUploadChunk on the first chunk', (uploader: ChunkedFileUploader) => uploader.onBeforeUploadChunk = (item: FileItem) => item.cancel(), 0 ], + [ 'onBuildItemForm on a later chunk', (uploader: ChunkedFileUploader) => uploader.onBuildItemForm = (item: FileItem) => { + if (uploader.getChunk(item)?.index === 1) { + item.cancel(); + } + }, 1 ], + [ 'onSuccessChunk', (uploader: ChunkedFileUploader) => uploader.onSuccessChunk = (item: FileItem) => item.cancel(), 1 ], + ])('from %s stops before the next request', (_name, setup, expectedRequests) => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const cancel = jest.spyOn(uploader, 'onCancelItem'); + setup(uploader); + + uploader.uploadAll(); + last()?.respond(200); + + expect(sent().length).toBe(expectedRequests); + expect(cancel).toHaveBeenCalledTimes(1); + expectCancelled(uploader, item); + }); + + it('from onSuccessChunk on the last chunk reports cancel', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const success = jest.spyOn(uploader, 'onSuccessItem'); + uploader.onSuccessChunk = (fileItem, chunk) => { + if (chunk.index === chunk.total - 1) { + fileItem.cancel(); + } + }; + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + last().respond(200); + + expect(success).not.toHaveBeenCalled(); + expectCancelled(uploader, item); + }); + + it('through removeFromQueue stops the upload', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + + uploader.uploadAll(); + uploader.removeFromQueue(uploader.queue[ 0 ]); + + expect(last().aborted).toBe(true); + expect(uploader.queue.length).toBe(0); + expect(uploader.isUploading).toBe(false); + }); + }); + + describe('queue', () => { + it('continues with the next item and removes uploaded items', () => { + const uploader = new ChunkedFileUploader({ url: '/upload', chunkSize: 4 * KB, removeAfterUpload: true }); + uploader.addToQueue([ new File([ 'x'.repeat(6 * KB) ], 'a.bin'), new File([ 'y'.repeat(2 * KB) ], 'b.bin') ]); + const completeAll = jest.spyOn(uploader, 'onCompleteAll'); + + uploader.uploadAll(); + last().respond(200); + last().respond(200); + last().respond(200); + + expect(sent().length).toBe(3); + expect(uploader.queue.length).toBe(0); + expect(completeAll).toHaveBeenCalledTimes(1); + }); + + it('uploads chunks automatically with autoUpload', () => { + const uploader = createUploader({ chunkSize: 4 * KB, autoUpload: true }); + + expect(form().get('chunkIndex')).toBe('0'); + expect(uploader.isUploading).toBe(true); + }); + }); +}); diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts deleted file mode 100644 index 7f0c7529..00000000 --- a/libs/ng2-file-upload/testing/spec/file-uploader-chunk.spec.ts +++ /dev/null @@ -1,854 +0,0 @@ -import { FileUploader, FileUploaderOptions } from '../../file-upload/file-uploader.class'; - -class FakeXhr { - static instances: FakeXhr[] = []; - upload: any = {}; - status = 0; - response = ''; - responseText = ''; - readyState = 0; - withCredentials = false; - method?: string; - url?: string; - body: any; - requestHeaders: { [ name: string ]: string } = {}; - aborted = false; - sent = false; - onload?: () => void; - onerror?: () => void; - onabort?: () => void; - onreadystatechange?: () => void; - - constructor() { - FakeXhr.instances.push(this); - } - - open(method: string, url: string): void { - this.method = method; - this.url = url; - } - - setRequestHeader(name: string, value: string): void { - this.requestHeaders[ name ] = value; - } - - send(body: any): void { - this.body = body; - this.sent = true; - } - - abort(): void { - if (!this.sent || this.readyState === 4) { - return; - } - this.aborted = true; - this.onabort?.(); - } - - responseHeaders = ''; - - getAllResponseHeaders(): string { - return this.responseHeaders; - } - - respond(status: number, response = ''): void { - this.status = status; - this.response = response; - this.readyState = 4; - this.onload?.(); - } -} - -const KB = 1024; - -function createUploader(options: Partial): FileUploader { - const uploader = new FileUploader({ url: '/upload', ...options }); - uploader.addToQueue([ new File([ 'x'.repeat(10 * KB) ], 'file.bin') ]); - - return uploader; -} - -function sent(): FakeXhr[] { - return FakeXhr.instances.filter(xhr => xhr.sent); -} - -function last(): FakeXhr { - const requests = sent(); - - return requests[ requests.length - 1 ]; -} - -describe('FileUploader: chunked upload', () => { - const originalXhr = (globalThis as any).XMLHttpRequest; - - beforeEach(() => { - FakeXhr.instances = []; - (globalThis as any).XMLHttpRequest = FakeXhr; - (FakeXhr as any).DONE = 4; - }); - - afterEach(() => { - (globalThis as any).XMLHttpRequest = originalXhr; - }); - - it('sends the whole file in one request when chunkSize is not set', () => { - const uploader = createUploader({}); - const success = jest.spyOn(uploader, 'onSuccessItem'); - - uploader.uploadAll(); - last().respond(200); - - expect(sent().length).toBe(1); - expect((last().body as FormData).get('chunkIndex')).toBeNull(); - expect(success).toHaveBeenCalledTimes(1); - }); - - it('splits the file into chunks and reports success once', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const beforeChunk = jest.spyOn(uploader, 'onBeforeUploadChunk'); - const completeChunk = jest.spyOn(uploader, 'onSuccessChunk'); - const success = jest.spyOn(uploader, 'onSuccessItem'); - const complete = jest.spyOn(uploader, 'onCompleteItem'); - - uploader.uploadAll(); - for (let i = 0; i < 3; i++) { - const form = last().body as FormData; - expect(form.get('chunkIndex')).toBe(String(i)); - expect(form.get('totalChunks')).toBe('3'); - expect((form.get('file') as File).size).toBe(i < 2 ? 4 * KB : 2 * KB); - last().respond(200, `chunk-${i}`); - } - - expect(sent().length).toBe(3); - expect(beforeChunk).toHaveBeenCalledTimes(3); - expect(completeChunk).toHaveBeenCalledTimes(3); - expect(success).toHaveBeenCalledTimes(1); - expect(success.mock.calls[ 0 ][ 1 ]).toBe('chunk-2'); - expect(complete).toHaveBeenCalledTimes(1); - expect(item.isSuccess).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - - it('uses custom chunk param names', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkIndexParam: 'current_chunk', totalChunksParam: 'total_chunks' }); - - uploader.uploadAll(); - const form = last().body as FormData; - - expect(form.get('current_chunk')).toBe('0'); - expect(form.get('total_chunks')).toBe('3'); - }); - - it('lets onSuccessChunk change the url and method of the next chunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - uploader.onSuccessChunk = (item, chunk, response) => { - item.url = `/upload/${response}`; - item.method = 'PUT'; - }; - - uploader.uploadAll(); - last().respond(200, 'abc'); - - expect(last().url).toBe('/upload/abc'); - expect(last().method).toBe('PUT'); - }); - - it('sends raw slices with Content-Range when multipart is disabled', () => { - const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true }); - - uploader.uploadAll(); - expect(last().body).toBeInstanceOf(Blob); - expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes 0-${4 * KB - 1}/${10 * KB}`); - last().respond(200); - expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes ${4 * KB}-${8 * KB - 1}/${10 * KB}`); - }); - - it('reports overall progress across chunks', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - - uploader.uploadAll(); - last().respond(200); - last().upload.onprogress({ lengthComputable: true, loaded: 1, total: 2 }); - - expect(item.progress).toBe(60); - }); - - it('retries a failed chunk and then continues', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); - const error = jest.spyOn(uploader, 'onErrorItem'); - const success = jest.spyOn(uploader, 'onSuccessItem'); - - uploader.uploadAll(); - last().respond(500); - last().onerror?.(); - expect((last().body as FormData).get('chunkIndex')).toBe('0'); - last().respond(200); - last().respond(200); - last().respond(200); - - expect(sent().length).toBe(5); - expect(error).not.toHaveBeenCalled(); - expect(success).toHaveBeenCalledTimes(1); - }); - - it('fails the item once retries are used up', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 0 }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - - uploader.uploadAll(); - last().respond(200); - last().respond(500); - last().respond(500); - - expect(sent().length).toBe(3); - expect(error).toHaveBeenCalledTimes(1); - expect(item.isError).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - - it('does not retry client errors', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 3, chunkRetryDelay: 0 }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - - uploader.uploadAll(); - last().respond(404); - - expect(sent().length).toBe(1); - expect(error).toHaveBeenCalledTimes(1); - expect(item.isError).toBe(true); - }); - - it('retries timeouts and rate limits', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); - const success = jest.spyOn(uploader, 'onSuccessItem'); - - uploader.uploadAll(); - last().respond(408); - last().respond(429); - last().respond(200); - last().respond(200); - last().respond(200); - - expect(sent().length).toBe(5); - expect(success).toHaveBeenCalledTimes(1); - }); - - describe('errors thrown by callbacks', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it('fail the item when a chunk hook throws and are rethrown asynchronously', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - const complete = jest.spyOn(uploader, 'onCompleteItem'); - uploader.onBeforeUploadChunk = (_item, chunk) => { - if (chunk.index === 1) { - throw new Error('hook failed'); - } - }; - - uploader.uploadAll(); - expect(() => last().respond(200)).not.toThrow(); - - expect(sent().length).toBe(1); - expect(error).toHaveBeenCalledTimes(1); - expect(error.mock.calls[ 0 ].slice(1)).toEqual([ '', 0, {} ]); - expect(complete).toHaveBeenCalledTimes(1); - expect(item.isError).toBe(true); - expect(uploader.isUploading).toBe(false); - expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); - }); - - it('fail the item when onSuccessChunk throws', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - uploader.onSuccessChunk = () => { - throw new Error('hook failed'); - }; - - uploader.uploadAll(); - last().respond(200); - - expect(sent().length).toBe(1); - expect(item.isError).toBe(true); - expect(uploader.isUploading).toBe(false); - expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); - }); - - it('report error before complete when a hook throws on the first chunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const order: string[] = []; - uploader.onErrorItem = () => order.push('error'); - uploader.onCompleteItem = () => order.push('complete'); - uploader.onBeforeUploadChunk = () => { - throw new Error('hook failed'); - }; - - uploader.uploadAll(); - - expect(order).toEqual([ 'error', 'complete' ]); - expect(uploader.isUploading).toBe(false); - expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); - }); - - it('report the item once when thrown after the chunk was sent', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const complete = jest.spyOn(uploader, 'onCompleteItem'); - jest.spyOn(uploader as any, '_render').mockImplementationOnce(() => { - throw new Error('render failed'); - }); - - uploader.uploadAll(); - last().respond(200); - - expect(complete).toHaveBeenCalledTimes(1); - expect(uploader.queue[ 0 ].isError).toBe(true); - expect(() => jest.runOnlyPendingTimers()).toThrow('render failed'); - }); - - it('do not report the item twice when onCompleteItem throws during a cancel', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - uploader.onBeforeUploadChunk = (fileItem, chunk) => { - if (chunk.index === 1) { - fileItem.cancel(); - } - }; - uploader.onCompleteItem = () => { - throw new Error('app failed'); - }; - - uploader.uploadAll(); - last().respond(200); - - expect(error).not.toHaveBeenCalled(); - expect(item.isCancel).toBe(true); - expect(() => jest.runOnlyPendingTimers()).toThrow('app failed'); - }); - - it('do not report the item twice when onErrorItem throws on the first chunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const errors: string[] = []; - uploader.onBeforeUploadChunk = () => { - throw new Error('hook failed'); - }; - uploader.onErrorItem = () => { - errors.push('error'); - throw new Error('app failed'); - }; - const complete = jest.spyOn(uploader, 'onCompleteItem'); - - uploader.uploadAll(); - - expect(errors).toEqual([ 'error' ]); - expect(complete).toHaveBeenCalledTimes(1); - expect(uploader.isUploading).toBe(false); - }); - }); - - it('cancel aborts the chunk in flight and stops the upload', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const cancel = jest.spyOn(uploader, 'onCancelItem'); - - uploader.uploadAll(); - last().respond(200); - item.cancel(); - - expect(last().aborted).toBe(true); - expect(sent().length).toBe(2); - expect(cancel).toHaveBeenCalledTimes(1); - expect(item.isCancel).toBe(true); - }); - - it('cancel from onSuccessChunk stops before the next chunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const cancel = jest.spyOn(uploader, 'onCancelItem'); - uploader.onSuccessChunk = (fileItem) => fileItem.cancel(); - - uploader.uploadAll(); - last().respond(200); - - expect(sent().length).toBe(1); - expect(cancel).toHaveBeenCalledTimes(1); - expect(item.isCancel).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - - it('uploads an empty file as a single chunk', () => { - const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB }); - uploader.addToQueue([ new File([], 'empty.txt') ]); - const success = jest.spyOn(uploader, 'onSuccessItem'); - - uploader.uploadAll(); - expect((last().body as FormData).get('totalChunks')).toBe('1'); - last().respond(200); - - expect(success).toHaveBeenCalledTimes(1); - }); - - it('ignores the fractional part of chunkSize', () => { - const uploader = createUploader({ chunkSize: 4 * KB + 0.5, disableMultipart: true }); - - uploader.uploadAll(); - - expect(last().requestHeaders[ 'Content-Range' ]).toBe(`bytes 0-${4 * KB - 1}/${10 * KB}`); - }); - - it('keeps the file MIME type on chunks', () => { - const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB }); - uploader.addToQueue([ new File([ 'x'.repeat(10 * KB) ], 'photo.png', { type: 'image/png' }) ]); - - uploader.uploadAll(); - - expect((last().body as FormData).get('file')).toHaveProperty('type', 'image/png'); - }); - - it('sends Content-Range with only the length for an empty file in raw mode', () => { - const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, disableMultipart: true }); - uploader.addToQueue([ new File([], 'empty.txt') ]); - - uploader.uploadAll(); - - expect(last().requestHeaders[ 'Content-Range' ]).toBe('bytes */0'); - }); - - it('puts chunk fields before the file when parametersBeforeFiles is set', () => { - const uploader = createUploader({ chunkSize: 4 * KB, parametersBeforeFiles: true, additionalParameter: { id: '1' } }); - - uploader.uploadAll(); - const keys = [ ...(last().body as FormData).keys() ]; - - expect(keys).toEqual([ 'id', 'chunkIndex', 'totalChunks', 'file' ]); - }); - - it('emits response and builds the form once per chunk request', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const buildForm = jest.spyOn(uploader, 'onBuildItemForm'); - const responses: string[] = []; - uploader.response.subscribe((res: string) => responses.push(res)); - - uploader.uploadAll(); - for (let i = 0; i < 3; i++) { - const xhr = last(); - xhr.responseText = `r${ i }`; - xhr.respond(200); - xhr.onreadystatechange?.(); - } - - expect(buildForm).toHaveBeenCalledTimes(3); - expect(responses).toEqual([ 'r0', 'r1', 'r2' ]); - }); - - it('cancel from onBeforeUploadChunk on the first chunk stops before sending', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const cancel = jest.spyOn(uploader, 'onCancelItem'); - const error = jest.spyOn(uploader, 'onErrorItem'); - uploader.onBeforeUploadChunk = (fileItem) => fileItem.cancel(); - - uploader.uploadAll(); - - expect(sent().length).toBe(0); - expect(cancel).toHaveBeenCalledTimes(1); - expect(error).not.toHaveBeenCalled(); - expect(item.isCancel).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - - it('cancel from onBeforeUploadChunk on a later chunk stops before sending it', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const success = jest.spyOn(uploader, 'onSuccessItem'); - uploader.onBeforeUploadChunk = (fileItem, chunk) => { - if (chunk.index === 2) { - fileItem.cancel(); - } - }; - - uploader.uploadAll(); - last().respond(200); - last().respond(200); - - expect(sent().length).toBe(2); - expect(success).not.toHaveBeenCalled(); - expect(item.isCancel).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - - it('cancel from onSuccessChunk on the last chunk reports cancel', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const success = jest.spyOn(uploader, 'onSuccessItem'); - uploader.onSuccessChunk = (fileItem, chunk) => { - if (chunk.index === chunk.total - 1) { - fileItem.cancel(); - } - }; - - uploader.uploadAll(); - last().respond(200); - last().respond(200); - last().respond(200); - - expect(success).not.toHaveBeenCalled(); - expect(item.isCancel).toBe(true); - }); - - it('calls onBeforeUploadChunk again for retries', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 0 }); - const retries: number[] = []; - uploader.onBeforeUploadChunk = (_item, chunk) => retries.push(chunk.retry); - - uploader.uploadAll(); - last().respond(503); - - expect(retries).toEqual([ 0, 1 ]); - }); - - it('does not retry 501 Not Implemented', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); - - uploader.uploadAll(); - last().respond(501); - - expect(sent().length).toBe(1); - expect(uploader.queue[ 0 ].isError).toBe(true); - }); - - it('continues with the next queued item and removes uploaded items', () => { - const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, removeAfterUpload: true }); - uploader.addToQueue([ new File([ 'x'.repeat(6 * KB) ], 'a.bin'), new File([ 'y'.repeat(2 * KB) ], 'b.bin') ]); - const completeAll = jest.spyOn(uploader, 'onCompleteAll'); - - uploader.uploadAll(); - last().respond(200); - last().respond(200); - last().respond(200); - - expect(sent().length).toBe(3); - expect(uploader.queue.length).toBe(0); - expect(completeAll).toHaveBeenCalledTimes(1); - }); - - it('uploads chunks automatically with autoUpload', () => { - const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, autoUpload: true }); - - uploader.addToQueue([ new File([ 'x'.repeat(10 * KB) ], 'file.bin') ]); - - expect((last().body as FormData).get('chunkIndex')).toBe('0'); - }); - - it('creates the request before onBeforeUploadItem without chunking', () => { - const uploader = createUploader({}); - let xhrInHook: unknown; - uploader.onBeforeUploadItem = (fileItem) => xhrInHook = fileItem._xhr; - - uploader.uploadAll(); - - expect(xhrInHook).toBe(last()); - }); - - it('creates each chunk request before onBeforeUploadChunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const seen: unknown[] = []; - uploader.onBeforeUploadChunk = (fileItem) => seen.push(fileItem._xhr); - - uploader.uploadAll(); - last().respond(200); - - expect(seen).toEqual(sent()); - }); - - it('cancel from onBuildItemForm stops before the chunk is sent', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - uploader.onBuildItemForm = (fileItem) => { - if (fileItem.chunk?.index === 1) { - fileItem.cancel(); - } - }; - - uploader.uploadAll(); - last().respond(200); - - expect(sent().length).toBe(1); - expect(error).not.toHaveBeenCalled(); - expect(item.isCancel).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - - it('reports cancel, not error, when a cancelled chunk then fails', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 0 }); - const item = uploader.queue[ 0 ]; - const error = jest.spyOn(uploader, 'onErrorItem'); - - uploader.uploadAll(); - item._cancelRequested = true; - last().respond(500); - - expect(sent().length).toBe(1); - expect(error).not.toHaveBeenCalled(); - expect(item.isCancel).toBe(true); - }); - - it('does not call onSuccessChunk for a failed chunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const successChunk = jest.spyOn(uploader, 'onSuccessChunk'); - - uploader.uploadAll(); - last().respond(400); - - expect(successChunk).not.toHaveBeenCalled(); - }); - - it('starts a re-upload at the original url after hooks changed it', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - uploader.onSuccessChunk = (fileItem) => { - fileItem.url = '/upload/session-1'; - fileItem.method = 'PUT'; - }; - - uploader.uploadAll(); - last().respond(200); - last().respond(404); - item.upload(); - - expect(last().url).toBe('/upload'); - expect(last().method).toBe('POST'); - }); - - it('keeps a url set by the app in onErrorItem for the re-upload', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - uploader.onSuccessChunk = (fileItem) => fileItem.url = '/upload/session-1'; - uploader.onErrorItem = (fileItem) => fileItem.url = '/fallback'; - - uploader.uploadAll(); - last().respond(200); - last().respond(404); - item.upload(); - - expect(last().url).toBe('/fallback'); - }); - - it('gives per-file callbacks the item url from before the upload', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - let urlInSuccess = ''; - uploader.onSuccessChunk = (fileItem) => fileItem.url = '/upload/session-1'; - uploader.onSuccessItem = (fileItem) => urlInSuccess = fileItem.url; - - uploader.uploadAll(); - last().respond(200); - last().respond(200); - last().respond(200); - - expect(urlInSuccess).toBe('/upload'); - }); - - it('keeps the chunk size of a running upload when options change', () => { - const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true }); - - uploader.uploadAll(); - last().respond(200); - uploader.setOptions({ url: '/upload', chunkSize: 3 * KB }); - last().respond(200); - - expect(sent().map(xhr => xhr.requestHeaders[ 'Content-Range' ])).toEqual([ - `bytes 0-${4 * KB - 1}/${10 * KB}`, - `bytes ${4 * KB}-${8 * KB - 1}/${10 * KB}`, - `bytes ${8 * KB}-${10 * KB - 1}/${10 * KB}` - ]); - }); - - it('moves on to the next item when a hook removes the uploading item', () => { - const uploader = new FileUploader({ url: '/upload', chunkSize: 4 * KB, removeAfterUpload: true }); - uploader.addToQueue([ new File([ 'x'.repeat(6 * KB) ], 'a.bin'), new File([ 'y'.repeat(2 * KB) ], 'b.bin') ]); - uploader.onSuccessChunk = (fileItem) => { - if (fileItem.file.name === 'a.bin') { - fileItem.remove(); - } - }; - - uploader.uploadAll(); - expect(() => last().respond(200)).not.toThrow(); - last().respond(200); - - expect(sent().length).toBe(2); - expect(uploader.queue.length).toBe(0); - expect(uploader.isUploading).toBe(false); - }); - - it('keeps the original progress rounding without chunking', () => { - const uploader = createUploader({}); - const item = uploader.queue[ 0 ]; - - uploader.uploadAll(); - last().upload.onprogress({ lengthComputable: true, loaded: 23, total: 40 }); - - expect(item.progress).toBe(58); - }); - - it('keeps a url set by the app between uploads', () => { - const uploader = createUploader({ chunkSize: 4 * KB }); - const item = uploader.queue[ 0 ]; - uploader.onSuccessChunk = (fileItem) => fileItem.url = '/upload/session-1'; - - uploader.uploadAll(); - last().respond(200); - last().respond(404); - item.url = '/other'; - item.upload(); - - expect(last().url).toBe('/other'); - }); - - it('keeps a Content-Range header set by the app', () => { - const uploader = createUploader({ - chunkSize: 4 * KB, - disableMultipart: true, - headers: [ { name: 'Content-Range', value: 'custom' } ] - }); - - uploader.uploadAll(); - - expect(last().requestHeaders[ 'Content-Range' ]).toBe('custom'); - }); - - it('sends headers and the auth token with every chunk', () => { - const uploader = createUploader({ chunkSize: 4 * KB, authToken: 'Bearer t', headers: [ { name: 'X-App', value: '1' } ] }); - - uploader.uploadAll(); - last().respond(200); - last().respond(200); - - expect(sent().map(xhr => [ xhr.requestHeaders[ 'Authorization' ], xhr.requestHeaders[ 'X-App' ] ])) - .toEqual([ [ 'Bearer t', '1' ], [ 'Bearer t', '1' ], [ 'Bearer t', '1' ] ]); - }); - - it('ignores formatDataFunction for chunks', () => { - const formatDataFunction = jest.fn(() => Promise.resolve({ name: 'x' })); - const uploader = createUploader({ chunkSize: 4 * KB, disableMultipart: true, formatDataFunction, formatDataFunctionIsAsync: true }); - - uploader.uploadAll(); - - expect(formatDataFunction).not.toHaveBeenCalled(); - expect(last().body).toBeInstanceOf(Blob); - }); - - it('still uses formatDataFunction without chunking', async () => { - const uploader = createUploader({ - disableMultipart: true, - formatDataFunction: () => Promise.resolve({ name: 'x' }), - formatDataFunctionIsAsync: true - }); - - uploader.uploadAll(); - await Promise.resolve(); - - expect(last().body).toBe('{"name":"x"}'); - }); - - describe('retry delay', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it('waits 1s by default and doubles the delay on every retry', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2 }); - - uploader.uploadAll(); - last().respond(503); - jest.advanceTimersByTime(999); - expect(sent().length).toBe(1); - jest.advanceTimersByTime(1); - expect(sent().length).toBe(2); - - last().respond(503); - jest.advanceTimersByTime(1999); - expect(sent().length).toBe(2); - jest.advanceTimersByTime(1); - expect(sent().length).toBe(3); - }); - - it('ignores the Retry-After header', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 100 }); - - uploader.uploadAll(); - last().responseHeaders = 'Retry-After: 3'; - last().respond(429); - jest.advanceTimersByTime(100); - - expect(sent().length).toBe(2); - }); - - it('cancel during the delay reports cancel right away and never retries', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); - const item = uploader.queue[ 0 ]; - const cancel = jest.spyOn(uploader, 'onCancelItem'); - - uploader.uploadAll(); - last().respond(503); - item.cancel(); - expect(cancel).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(10000); - - expect(cancel).toHaveBeenCalledTimes(1); - expect(sent().length).toBe(1); - expect(item.isCancel).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - - it('caps the doubled delay at 30 seconds by default', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 2, chunkRetryDelay: 20000 }); - - uploader.uploadAll(); - last().respond(503); - jest.advanceTimersByTime(20000); - expect(sent().length).toBe(2); - - last().respond(503); - jest.advanceTimersByTime(29999); - expect(sent().length).toBe(2); - jest.advanceTimersByTime(1); - expect(sent().length).toBe(3); - }); - - it('uses chunkMaxRetryDelay as the cap', () => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1, chunkRetryDelay: 10000, chunkMaxRetryDelay: 5000 }); - - uploader.uploadAll(); - last().respond(503); - jest.advanceTimersByTime(4999); - expect(sent().length).toBe(1); - jest.advanceTimersByTime(1); - expect(sent().length).toBe(2); - }); - - it.each([ - [ 'removeFromQueue', (uploader: FileUploader) => uploader.removeFromQueue(uploader.queue[ 0 ]) ], - [ 'clearQueue', (uploader: FileUploader) => uploader.clearQueue() ], - [ 'cancelAll', (uploader: FileUploader) => uploader.cancelAll() ] - ])('%s during the delay stops the upload', (_name, action) => { - const uploader = createUploader({ chunkSize: 4 * KB, chunkRetries: 1 }); - const item = uploader.queue[ 0 ]; - - uploader.uploadAll(); - last().respond(503); - action(uploader); - jest.advanceTimersByTime(10000); - - expect(sent().length).toBe(1); - expect(item.isCancel).toBe(true); - expect(uploader.isUploading).toBe(false); - }); - }); -}); From cf4f4c3989fa0396eec3839a4fb9dd3bf8a6d405 Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 18:33:34 +0300 Subject: [PATCH 8/9] refactor(uploader): share request building through BaseFileUploader - FileUploader's implementation moves to BaseFileUploader, with form building and request setup extracted into _buildFormData and _openRequest; FileUploader extends it unchanged - ChunkedFileUploader extends BaseFileUploader and reuses both helpers instead of copying them Fixes from review: - resumeItem ignores items no longer in the queue (removeAfterUpload) - cancel from onSuccessChunk resumes at the next chunk; on the last chunk it has no effect since the file is already uploaded - a chunk callback that throws fails the item instead of stalling the queue - docs: per-item retry counter, removeAfterUpload note, cancel and error behaviour --- README.md | 10 +- apps/demo/src/doc.md | 10 +- .../chunked-file-uploader.class.ts | 107 ++++++++-------- .../file-upload/file-uploader.class.ts | 82 +++++++----- .../spec/chunked-file-uploader.class.spec.ts | 117 +++++++++++++++++- 5 files changed, 223 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index b9722ae5..2c612221 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,14 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g - `onBeforeUploadChunk(item, chunk)` - before each chunk request. - `onSuccessChunk(item, chunk, response, status, headers)` - after each successful chunk. Change `item.url`, `item.method` or `item.headers` here to target the next chunk, e.g. with an upload id from your server. - `onErrorChunk(item, chunk, response, status, headers)` - when a chunk fails; the item then fails as usual (`onErrorItem`, `onCompleteItem`) and no more chunks are sent. - - `resumeItem(item)` - uploads a failed or cancelled item again, starting from the chunk that did not complete. Use it to retry, e.g. from `onErrorItem`: + - `resumeItem(item)` - uploads a failed or cancelled item again, starting from the chunk that did not complete. Use it to retry, e.g. from `onErrorItem` (with `removeAfterUpload` off, so failed items stay in the queue): ```typescript - let retries = 0; + const retries = new Map(); uploader.onErrorItem = (item, response, status) => { - if (status >= 500 && retries++ < 3) { + const count = retries.get(item) ?? 0; + if (status >= 500 && count < 3) { + retries.set(item, count + 1); setTimeout(() => uploader.resumeItem(item), 1000); } }; @@ -121,7 +123,7 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g - `getChunk(item)` - the chunk being sent, or the last one sent. - `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks. + `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks; it has no effect once the last chunk is sent, and an item whose chunk failed stays failed. If a chunk callback throws, the item fails with status 0. # Troubleshooting diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index c3c41d63..d6203aea 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -64,12 +64,14 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u - `onBeforeUploadChunk(item, chunk)` - before each chunk request. - `onSuccessChunk(item, chunk, response, status, headers)` - after each successful chunk. Change `item.url`, `item.method` or `item.headers` here to target the next chunk, e.g. with an upload id from your server. - `onErrorChunk(item, chunk, response, status, headers)` - when a chunk fails; the item then fails as usual (`onErrorItem`, `onCompleteItem`) and no more chunks are sent. - - `resumeItem(item)` - uploads a failed or cancelled item again, starting from the chunk that did not complete. Use it to retry, e.g. from `onErrorItem`: + - `resumeItem(item)` - uploads a failed or cancelled item again, starting from the chunk that did not complete. Use it to retry, e.g. from `onErrorItem` (with `removeAfterUpload` off, so failed items stay in the queue): ```typescript - let retries = 0; + const retries = new Map(); uploader.onErrorItem = (item, response, status) => { - if (status >= 500 && retries++ < 3) { + const count = retries.get(item) ?? 0; + if (status >= 500 && count < 3) { + retries.set(item, count + 1); setTimeout(() => uploader.resumeItem(item), 1000); } }; @@ -77,4 +79,4 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u - `getChunk(item)` - the chunk being sent, or the last one sent. - `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks. + `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks; it has no effect once the last chunk is sent, and an item whose chunk failed stays failed. If a chunk callback throws, the item fails with status 0. diff --git a/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts b/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts index e41ce174..6a9e6bed 100644 --- a/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts @@ -1,5 +1,5 @@ import { FileItem } from './file-item.class'; -import { FileUploader, FileUploaderOptions, ParsedResponseHeaders } from './file-uploader.class'; +import { BaseFileUploader, FileUploaderOptions, ParsedResponseHeaders } from './file-uploader.class'; export interface ChunkedFileUploaderOptions extends FileUploaderOptions { chunkSize?: number; @@ -21,7 +21,7 @@ interface ChunkState { cancelled?: boolean; } -export class ChunkedFileUploader extends FileUploader { +export class ChunkedFileUploader extends BaseFileUploader { declare options: ChunkedFileUploaderOptions; protected _chunks = new WeakMap(); @@ -40,7 +40,7 @@ export class ChunkedFileUploader extends FileUploader { resumeItem(item: FileItem): void { const state = this._chunks.get(item); - if (item.isUploading) { + if (item.isUploading || this.getIndexOfItem(item) < 0) { return; } if (state?.chunk && !item.isSuccess) { @@ -86,10 +86,10 @@ export class ChunkedFileUploader extends FileUploader { throw new TypeError('The file specified is no longer valid'); } const total = Math.max(1, Math.ceil(item._file.size / chunkSize)); - this._sendChunk(item, resume || this._getChunk(item, 0, 0, chunkSize, total)); + this._sendChunk(item, resume || this._sliceChunk(item, 0, 0, chunkSize, total)); } - protected _getChunk(item: FileItem, index: number, start: number, length: number, total: number): FileChunk { + protected _sliceChunk(item: FileItem, index: number, start: number, length: number, total: number): FileChunk { const end = Math.min(start + length, item._file.size); return { index, total, start, end, blob: item._file.slice(start, end, item._file.type) }; @@ -98,30 +98,14 @@ export class ChunkedFileUploader extends FileUploader { protected _sendChunk(item: FileItem, chunk: FileChunk): void { const state = this._chunks.get(item) as ChunkState; const xhr = item._xhr = new XMLHttpRequest(); - let sendable: any = chunk.blob; state.chunk = chunk; this.onBeforeUploadChunk(item, chunk); - - if (!this.options.disableMultipart) { - sendable = new FormData(); - this._onBuildItemForm(item, sendable); - const appendFile = () => sendable.append(item.alias, chunk.blob, item.file.name); - if (!this.options.parametersBeforeFiles) { - appendFile(); - } - Object.keys(this.options.additionalParameter || {}).forEach((key: string) => { - const paramVal = this.options.additionalParameter?.[ key ]; - sendable.append(key, typeof paramVal === 'string' && item.file?.name ? paramVal.replace('{{file_name}}', item.file.name) : paramVal); - }); - sendable.append(this.options.chunkIndexParam || 'chunkIndex', chunk.index.toString()); - sendable.append(this.options.totalChunksParam || 'totalChunks', chunk.total.toString()); - if (this.options.parametersBeforeFiles) { - appendFile(); - } - } + const sendable = this.options.disableMultipart ? chunk.blob : this._buildFormData(item, chunk.blob, { + [ this.options.chunkIndexParam || 'chunkIndex' ]: chunk.index.toString(), + [ this.options.totalChunksParam || 'totalChunks' ]: chunk.total.toString() + }); if (state.cancelled) { - this._onCancelItem(item, '', 0, {}); - this._onCompleteItem(item, '', 0, {}); + this._finishItem(item, '_onCancelItem', '', 0, {}); return; } @@ -136,26 +120,12 @@ export class ChunkedFileUploader extends FileUploader { state.cancelled = true; this._onChunkDone(item, chunk, xhr, false); }; - if (item.method && item.url) { - xhr.open(item.method, item.url, true); - } - xhr.withCredentials = item.withCredentials; - const headers = [ ...(this.options.headers || []), ...item.headers ]; - for (const header of headers) { - xhr.setRequestHeader(header.name, header.value); - } - if (this.authToken && this.authTokenHeader) { - xhr.setRequestHeader(this.authTokenHeader, this.authToken); - } - if (this.options.disableMultipart && !headers.some(header => header.name.toLowerCase() === 'content-range')) { + this._openRequest(xhr, item); + const hasContentRange = [ ...(this.options.headers || []), ...item.headers ].some(header => header.name.toLowerCase() === 'content-range'); + if (this.options.disableMultipart && !hasContentRange) { const range = chunk.end > chunk.start ? `${ chunk.start }-${ chunk.end - 1 }` : '*'; xhr.setRequestHeader('Content-Range', `bytes ${ range }/${ item._file.size }`); } - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.DONE) { - this.response.emit(xhr.responseText); - } - }; xhr.send(sendable); this._render(); } @@ -164,29 +134,52 @@ export class ChunkedFileUploader extends FileUploader { const state = this._chunks.get(item) as ChunkState; const headers = this._parseHeaders(xhr.getAllResponseHeaders()); const response = this._transformResponse(xhr.response); - if (!state.cancelled && !isSuccess) { - this.onErrorChunk(item, chunk, response, xhr.status, headers); - this._onErrorItem(item, response, xhr.status, headers); - this._onCompleteItem(item, response, xhr.status, headers); + if (state.cancelled) { + this._finishItem(item, '_onCancelItem', response, xhr.status, headers); + + return; + } + if (!isSuccess) { + if (this._runChunkHook(item, () => this.onErrorChunk(item, chunk, response, xhr.status, headers))) { + this._finishItem(item, '_onErrorItem', response, xhr.status, headers); + } return; } - if (!state.cancelled) { - this.onSuccessChunk(item, chunk, response, xhr.status, headers); + if (!this._runChunkHook(item, () => this.onSuccessChunk(item, chunk, response, xhr.status, headers))) { + return; } - // cancel() between chunks has no request to abort, so it is picked up here - if (state.cancelled) { - this._onCancelItem(item, response, xhr.status, headers); - this._onCompleteItem(item, response, xhr.status, headers); + if (chunk.index + 1 >= chunk.total) { + this._finishItem(item, '_onSuccessItem', response, xhr.status, headers); return; } - if (chunk.index + 1 < chunk.total) { - this._sendChunk(item, this._getChunk(item, chunk.index + 1, chunk.end, chunk.end - chunk.start, chunk.total)); + const next = this._sliceChunk(item, chunk.index + 1, chunk.end, chunk.end - chunk.start, chunk.total); + if (state.cancelled) { + state.chunk = next; + this._finishItem(item, '_onCancelItem', response, xhr.status, headers); return; } - this._onSuccessItem(item, response, xhr.status, headers); - this._onCompleteItem(item, response, xhr.status, headers); + this._runChunkHook(item, () => this._sendChunk(item, next)); + } + + // hooks after the first chunk run in XHR callbacks; report a throw as an item error instead of stalling the queue + protected _runChunkHook(item: FileItem, hook: () => void): boolean { + try { + hook(); + + return true; + } catch (e) { + this._finishItem(item, '_onErrorItem', '', 0, {}); + + return false; + } + } + + protected _finishItem(item: FileItem, method: '_onSuccessItem' | '_onErrorItem' | '_onCancelItem', response: string, + status: number, headers: ParsedResponseHeaders): void { + this[ method ](item, response, status, headers); + this._onCompleteItem(item, response, status, headers); } } diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index c5f0e58a..78c679d1 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -42,7 +42,7 @@ export interface FileUploaderOptions { formatDataFunctionIsAsync?: boolean; } -export class FileUploader { +export class BaseFileUploader { authToken?: string; isUploading = false; @@ -299,9 +299,6 @@ export class FileUploader { } protected _xhrTransport(item: FileItem): any { - // tslint:disable-next-line:no-this-assignment - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; const xhr = item._xhr = new XMLHttpRequest(); let sendable: any; this._onBeforeUploadItem(item); @@ -310,28 +307,7 @@ export class FileUploader { throw new TypeError('The file specified is no longer valid'); } if (!this.options.disableMultipart) { - sendable = new FormData(); - this._onBuildItemForm(item, sendable); - const appendFile = () => sendable.append(item.alias, item._file, item.file.name); - if (!this.options.parametersBeforeFiles) { - appendFile(); - } - - // For AWS, Additional Parameters must come BEFORE Files - if (this.options.additionalParameter !== undefined) { - Object.keys(this.options.additionalParameter).forEach((key: string) => { - let paramVal = this.options.additionalParameter?.[ key ]; - // Allow an additional parameter to include the filename - if (typeof paramVal === 'string' && paramVal.indexOf('{{file_name}}') >= 0 && item.file?.name) { - paramVal = paramVal.replace('{{file_name}}', item.file.name); - } - sendable.append(key, paramVal); - }); - } - - if (appendFile && this.options.parametersBeforeFiles) { - appendFile(); - } + sendable = this._buildFormData(item, item._file); } else { if (this.options.formatDataFunction) { sendable = this.options.formatDataFunction(item); @@ -362,6 +338,49 @@ export class FileUploader { this._onCancelItem(item, response, xhr.status, headers); this._onCompleteItem(item, response, xhr.status, headers); }; + this._openRequest(xhr, item); + if (this.options.formatDataFunctionIsAsync) { + sendable.then( + (result: any) => xhr.send(JSON.stringify(result)) + ); + } else { + xhr.send(sendable); + } + this._render(); + } + + protected _buildFormData(item: FileItem, file: Blob, extraParameters: { [ key: string ]: string } = {}): FormData { + const sendable = new FormData(); + this._onBuildItemForm(item, sendable); + const appendFile = () => sendable.append(item.alias as string, file, item.file.name); + if (!this.options.parametersBeforeFiles) { + appendFile(); + } + + // For AWS, Additional Parameters must come BEFORE Files + if (this.options.additionalParameter !== undefined) { + Object.keys(this.options.additionalParameter).forEach((key: string) => { + let paramVal = this.options.additionalParameter?.[ key ]; + // Allow an additional parameter to include the filename + if (typeof paramVal === 'string' && paramVal.indexOf('{{file_name}}') >= 0 && item.file?.name) { + paramVal = paramVal.replace('{{file_name}}', item.file.name); + } + sendable.append(key, paramVal); + }); + } + Object.keys(extraParameters).forEach((key: string) => sendable.append(key, extraParameters[ key ])); + + if (appendFile && this.options.parametersBeforeFiles) { + appendFile(); + } + + return sendable; + } + + protected _openRequest(xhr: XMLHttpRequest, item: FileItem): void { + // tslint:disable-next-line:no-this-assignment + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this; if (item.method && item.url) { xhr.open(item.method, item.url, true); } @@ -384,14 +403,6 @@ export class FileUploader { that.response.emit(xhr.responseText); } }; - if (this.options.formatDataFunctionIsAsync) { - sendable.then( - (result: any) => xhr.send(JSON.stringify(result)) - ); - } else { - xhr.send(sendable); - } - this._render(); } protected _getTotalProgress(value = 0): number { @@ -511,3 +522,6 @@ export class FileUploader { this.onCancelItem(item, response, status, headers); } } + +export class FileUploader extends BaseFileUploader { +} diff --git a/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts b/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts index b1aa9727..ba7e7201 100644 --- a/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts +++ b/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts @@ -1,5 +1,6 @@ import { ChunkedFileUploader, ChunkedFileUploaderOptions } from '../../file-upload/chunked-file-uploader.class'; import { FileItem } from '../../file-upload/file-item.class'; +import { FileUploader } from '../../file-upload/file-uploader.class'; class FakeXhr { static instances: FakeXhr[] = []; @@ -96,6 +97,12 @@ describe('ChunkedFileUploader', () => { (globalThis as any).XMLHttpRequest = originalXhr; }); + it('can be passed where a FileUploader is expected', () => { + const uploader: FileUploader = new ChunkedFileUploader({ url: '/upload', chunkSize: 4 * KB }); + + expect(uploader.options.url).toBe('/upload'); + }); + it('uploads the whole file in one request without chunkSize', () => { const uploader = createUploader({}); const success = jest.spyOn(uploader, 'onSuccessItem'); @@ -316,6 +323,52 @@ describe('ChunkedFileUploader', () => { }); }); + describe('callbacks that throw', () => { + it('fail the item instead of stalling the queue', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onSuccessChunk = (_item, _chunk, response) => JSON.parse(response); + + uploader.uploadAll(); + expect(() => last().respond(200, 'not json')).not.toThrow(); + + expect(sent().length).toBe(1); + expect(error).toHaveBeenCalledTimes(1); + expect(item.isError).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('fail the item when onBeforeUploadChunk throws on a later chunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + uploader.onBeforeUploadChunk = (_item, chunk) => { + if (chunk.index === 1) { + throw new Error('hook failed'); + } + }; + + uploader.uploadAll(); + last().respond(200); + + expect(uploader.queue[ 0 ].isError).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('report the item once when onErrorChunk throws', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onErrorChunk = () => { + throw new Error('hook failed'); + }; + + uploader.uploadAll(); + last().respond(500); + + expect(error).toHaveBeenCalledTimes(1); + expect(uploader.isUploading).toBe(false); + }); + }); + describe('resumeItem', () => { it('continues from the failed chunk', () => { const uploader = createUploader({ chunkSize: 4 * KB }); @@ -356,6 +409,51 @@ describe('ChunkedFileUploader', () => { expect(uploader.isUploading).toBe(false); }); + it('continues after the last successful chunk when cancelled from onSuccessChunk', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + uploader.onSuccessChunk = (fileItem, chunk) => { + if (chunk.index === 0) { + fileItem.cancel(); + } + }; + + uploader.uploadAll(); + last().respond(200); + uploader.onSuccessChunk = () => undefined; + uploader.resumeItem(item); + + expect(form().get('chunkIndex')).toBe('1'); + }); + + it('does nothing for an item no longer in the queue', () => { + const uploader = createUploader({ chunkSize: 4 * KB, removeAfterUpload: true }); + const item = uploader.queue[ 0 ]; + + uploader.uploadAll(); + last().respond(500); + + expect(uploader.queue.length).toBe(0); + expect(() => uploader.resumeItem(item)).not.toThrow(); + expect(sent().length).toBe(1); + }); + + it('resumes an item behind another one in the queue', () => { + const uploader = new ChunkedFileUploader({ url: '/upload', chunkSize: 4 * KB }); + uploader.addToQueue([ new File([ 'x'.repeat(10 * KB) ], 'a.bin'), new File([ 'y'.repeat(2 * KB) ], 'b.bin') ]); + const [ a, b ] = uploader.queue; + + a.upload(); + last().respond(200); + last().respond(500); + b.upload(); + uploader.resumeItem(a); + last().respond(200); + + expect(sent().map(xhr => [ ((xhr.body as FormData).get('file') as File).name, (xhr.body as FormData).get('chunkIndex') ])) + .toEqual([ [ 'a.bin', '0' ], [ 'a.bin', '1' ], [ 'b.bin', '0' ], [ 'a.bin', '1' ] ]); + }); + it('continues from the cancelled chunk', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; @@ -436,10 +534,9 @@ describe('ChunkedFileUploader', () => { expectCancelled(uploader, item); }); - it('from onSuccessChunk on the last chunk reports cancel', () => { + it('from onSuccessChunk on the last chunk has no effect, the file is uploaded', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; - const success = jest.spyOn(uploader, 'onSuccessItem'); uploader.onSuccessChunk = (fileItem, chunk) => { if (chunk.index === chunk.total - 1) { fileItem.cancel(); @@ -451,8 +548,20 @@ describe('ChunkedFileUploader', () => { last().respond(200); last().respond(200); - expect(success).not.toHaveBeenCalled(); - expectCancelled(uploader, item); + expect(item.isSuccess).toBe(true); + expect(uploader.isUploading).toBe(false); + }); + + it('from onErrorChunk leaves the item failed', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + uploader.onErrorChunk = (fileItem) => fileItem.cancel(); + + uploader.uploadAll(); + last().respond(500); + + expect(item.isError).toBe(true); + expect(uploader.isUploading).toBe(false); }); it('through removeFromQueue stops the upload', () => { From ea468139430ff1c1444c7781d9489f7991eccd32 Mon Sep 17 00:00:00 2001 From: Alex Umanskiy Date: Thu, 1 Oct 2026 19:00:41 +0300 Subject: [PATCH 9/9] refactor(uploader): extend FileUploader instead of a base class - ChunkedFileUploader extends FileUploader again, so it is a real drop-in (instanceof FileUploader); FileUploader keeps the two extracted helpers _buildFormData and _openRequest and is otherwise unchanged - chunk fields are sent before the file so streaming servers can read them - a throwing chunk callback fails the item only if it is still uploading, and the error is rethrown instead of swallowed - tests: FileUploader request regression spec (passes on the previous FileUploader too), shared fake XHR, field order, rethrow and no-double-report cases - docs: cancel wording, repeated callbacks per resume attempt, rethrow --- README.md | 8 +- apps/demo/src/doc.md | 8 +- .../chunked-file-uploader.class.ts | 13 +- .../file-upload/file-uploader.class.ts | 7 +- .../spec/chunked-file-uploader.class.spec.ts | 134 ++++++------------ libs/ng2-file-upload/testing/spec/fake-xhr.ts | 82 +++++++++++ .../spec/file-uploader-request.spec.ts | 89 ++++++++++++ 7 files changed, 235 insertions(+), 106 deletions(-) create mode 100644 libs/ng2-file-upload/testing/spec/fake-xhr.ts create mode 100644 libs/ng2-file-upload/testing/spec/file-uploader-request.spec.ts diff --git a/README.md b/README.md index 2c612221..f1f8c785 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g ## Chunked uploads - Use `ChunkedFileUploader` instead of `FileUploader` to send large files in several requests. It works with the same directives, options and callbacks; without `chunkSize` it behaves exactly like `FileUploader`. + Use `ChunkedFileUploader` (a `FileUploader` subclass) instead of `FileUploader` to send large files in several requests. It works with the same directives, options and callbacks; without `chunkSize` it behaves exactly like `FileUploader`. ```typescript uploader = new ChunkedFileUploader({ url: URL, chunkSize: 2 * 1024 * 1024 }); @@ -100,7 +100,7 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g Additional options: - 1. `chunkSize` - Bytes per request. Multipart requests send the chunk as the file field plus `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used). + 1. `chunkSize` - Bytes per request. Multipart requests send the chunk as the file field, preceded by `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used). 2. `chunkIndexParam` / `totalChunksParam` - Names of those form fields. Default to `chunkIndex` and `totalChunks`. Additional callbacks and methods: @@ -121,9 +121,11 @@ Easy to use Angular2 directives for files upload ([demo](http://valor-software.g }; ``` + Each attempt reports the item again (`onErrorItem`, `onCompleteItem`, and `onCompleteAll` when the queue is empty) and restarts its progress. + - `getChunk(item)` - the chunk being sent, or the last one sent. - `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks; it has no effect once the last chunk is sent, and an item whose chunk failed stays failed. If a chunk callback throws, the item fails with status 0. + `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks; it has no effect once the last chunk has completed, and an item whose chunk failed stays failed. If a chunk callback throws, the item fails with status 0 and the error is rethrown; resuming then resends that chunk. # Troubleshooting diff --git a/apps/demo/src/doc.md b/apps/demo/src/doc.md index d6203aea..46759a87 100644 --- a/apps/demo/src/doc.md +++ b/apps/demo/src/doc.md @@ -48,7 +48,7 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u ## Chunked uploads - Use `ChunkedFileUploader` instead of `FileUploader` to send large files in several requests. It works with the same directives, options and callbacks; without `chunkSize` it behaves exactly like `FileUploader`. + Use `ChunkedFileUploader` (a `FileUploader` subclass) instead of `FileUploader` to send large files in several requests. It works with the same directives, options and callbacks; without `chunkSize` it behaves exactly like `FileUploader`. ```typescript uploader = new ChunkedFileUploader({ url: URL, chunkSize: 2 * 1024 * 1024 }); @@ -56,7 +56,7 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u Additional options: - 1. `chunkSize` - Bytes per request. Multipart requests send the chunk as the file field plus `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used). + 1. `chunkSize` - Bytes per request. Multipart requests send the chunk as the file field, preceded by `chunkIndex` and `totalChunks` fields; with `disableMultipart` the raw chunk is sent with a `Content-Range` header (`formatDataFunction` is not used). 2. `chunkIndexParam` / `totalChunksParam` - Names of those form fields. Default to `chunkIndex` and `totalChunks`. Additional callbacks and methods: @@ -77,6 +77,8 @@ import { FileSelectDirective, FileDropDirective, FileUploader } from 'ng2-file-u }; ``` + Each attempt reports the item again (`onErrorItem`, `onCompleteItem`, and `onCompleteAll` when the queue is empty) and restarts its progress. + - `getChunk(item)` - the chunk being sent, or the last one sent. - `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks; it has no effect once the last chunk is sent, and an item whose chunk failed stays failed. If a chunk callback throws, the item fails with status 0. + `onBuildItemForm` and the `response` emitter fire once per chunk; the other item callbacks once per file. `item.cancel()` stops the remaining chunks; it has no effect once the last chunk has completed, and an item whose chunk failed stays failed. If a chunk callback throws, the item fails with status 0 and the error is rethrown; resuming then resends that chunk. diff --git a/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts b/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts index 6a9e6bed..ab681360 100644 --- a/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/chunked-file-uploader.class.ts @@ -1,5 +1,5 @@ import { FileItem } from './file-item.class'; -import { BaseFileUploader, FileUploaderOptions, ParsedResponseHeaders } from './file-uploader.class'; +import { FileUploader, FileUploaderOptions, ParsedResponseHeaders } from './file-uploader.class'; export interface ChunkedFileUploaderOptions extends FileUploaderOptions { chunkSize?: number; @@ -21,7 +21,7 @@ interface ChunkState { cancelled?: boolean; } -export class ChunkedFileUploader extends BaseFileUploader { +export class ChunkedFileUploader extends FileUploader { declare options: ChunkedFileUploaderOptions; protected _chunks = new WeakMap(); @@ -164,14 +164,19 @@ export class ChunkedFileUploader extends BaseFileUploader { this._runChunkHook(item, () => this._sendChunk(item, next)); } - // hooks after the first chunk run in XHR callbacks; report a throw as an item error instead of stalling the queue + // hooks after the first chunk run in XHR callbacks: fail the item instead of stalling the queue, then rethrow protected _runChunkHook(item: FileItem, hook: () => void): boolean { try { hook(); return true; } catch (e) { - this._finishItem(item, '_onErrorItem', '', 0, {}); + if (item.isUploading) { + this._finishItem(item, '_onErrorItem', '', 0, {}); + } + setTimeout(() => { + throw e; + }); return false; } diff --git a/libs/ng2-file-upload/file-upload/file-uploader.class.ts b/libs/ng2-file-upload/file-upload/file-uploader.class.ts index 78c679d1..ff090e16 100644 --- a/libs/ng2-file-upload/file-upload/file-uploader.class.ts +++ b/libs/ng2-file-upload/file-upload/file-uploader.class.ts @@ -42,7 +42,7 @@ export interface FileUploaderOptions { formatDataFunctionIsAsync?: boolean; } -export class BaseFileUploader { +export class FileUploader { authToken?: string; isUploading = false; @@ -352,6 +352,7 @@ export class BaseFileUploader { protected _buildFormData(item: FileItem, file: Blob, extraParameters: { [ key: string ]: string } = {}): FormData { const sendable = new FormData(); this._onBuildItemForm(item, sendable); + Object.keys(extraParameters).forEach((key: string) => sendable.append(key, extraParameters[ key ])); const appendFile = () => sendable.append(item.alias as string, file, item.file.name); if (!this.options.parametersBeforeFiles) { appendFile(); @@ -368,7 +369,6 @@ export class BaseFileUploader { sendable.append(key, paramVal); }); } - Object.keys(extraParameters).forEach((key: string) => sendable.append(key, extraParameters[ key ])); if (appendFile && this.options.parametersBeforeFiles) { appendFile(); @@ -522,6 +522,3 @@ export class BaseFileUploader { this.onCancelItem(item, response, status, headers); } } - -export class FileUploader extends BaseFileUploader { -} diff --git a/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts b/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts index ba7e7201..3e989875 100644 --- a/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts +++ b/libs/ng2-file-upload/testing/spec/chunked-file-uploader.class.spec.ts @@ -1,65 +1,7 @@ import { ChunkedFileUploader, ChunkedFileUploaderOptions } from '../../file-upload/chunked-file-uploader.class'; import { FileItem } from '../../file-upload/file-item.class'; import { FileUploader } from '../../file-upload/file-uploader.class'; - -class FakeXhr { - static instances: FakeXhr[] = []; - upload: any = {}; - status = 0; - response = ''; - responseText = ''; - readyState = 0; - withCredentials = false; - method?: string; - url?: string; - body: any; - requestHeaders: { [ name: string ]: string } = {}; - sent = false; - aborted = false; - onload?: () => void; - onerror?: () => void; - onabort?: () => void; - onreadystatechange?: () => void; - - constructor() { - FakeXhr.instances.push(this); - } - - open(method: string, url: string): void { - this.method = method; - this.url = url; - } - - setRequestHeader(name: string, value: string): void { - this.requestHeaders[ name ] = value; - } - - send(body: any): void { - this.body = body; - this.sent = true; - } - - abort(): void { - if (!this.sent || this.readyState === 4) { - return; - } - this.aborted = true; - this.onabort?.(); - } - - getAllResponseHeaders(): string { - return ''; - } - - respond(status: number, response = ''): void { - this.status = status; - this.response = response; - this.responseText = response; - this.readyState = 4; - this.onload?.(); - this.onreadystatechange?.(); - } -} +import { FakeXhr, installFakeXhr, last, sent } from './fake-xhr'; const KB = 1024; @@ -70,37 +12,16 @@ function createUploader(options: Partial, file = new return uploader; } -function sent(): FakeXhr[] { - return FakeXhr.instances.filter(xhr => xhr.sent); -} - -function last(): FakeXhr { - const requests = sent(); - - return requests[ requests.length - 1 ]; -} function form(xhr: FakeXhr = last()): FormData { return xhr.body as FormData; } describe('ChunkedFileUploader', () => { - const originalXhr = (globalThis as any).XMLHttpRequest; - - beforeEach(() => { - FakeXhr.instances = []; - (globalThis as any).XMLHttpRequest = FakeXhr; - (FakeXhr as any).DONE = 4; - }); + installFakeXhr(); - afterEach(() => { - (globalThis as any).XMLHttpRequest = originalXhr; - }); - - it('can be passed where a FileUploader is expected', () => { - const uploader: FileUploader = new ChunkedFileUploader({ url: '/upload', chunkSize: 4 * KB }); - - expect(uploader.options.url).toBe('/upload'); + it('is a FileUploader', () => { + expect(new ChunkedFileUploader({ url: '/upload' })).toBeInstanceOf(FileUploader); }); it('uploads the whole file in one request without chunkSize', () => { @@ -165,19 +86,23 @@ describe('ChunkedFileUploader', () => { expect(form().get('total_chunks')).toBe('3'); }); - it('sends additional parameters with every chunk, before the file when asked', () => { - const uploader = createUploader({ - chunkSize: 4 * KB, - parametersBeforeFiles: true, - additionalParameter: { name: '{{file_name}}' } - }); + it('puts the chunk fields before the file', () => { + const uploader = createUploader({ chunkSize: 4 * KB, additionalParameter: { name: '{{file_name}}' } }); uploader.uploadAll(); - expect([ ...form().keys() ]).toEqual([ 'name', 'chunkIndex', 'totalChunks', 'file' ]); + expect([ ...form().keys() ]).toEqual([ 'chunkIndex', 'totalChunks', 'file', 'name' ]); expect(form().get('name')).toBe('file.bin'); }); + it('keeps parametersBeforeFiles for additional parameters', () => { + const uploader = createUploader({ chunkSize: 4 * KB, parametersBeforeFiles: true, additionalParameter: { name: 'x' } }); + + uploader.uploadAll(); + + expect([ ...form().keys() ]).toEqual([ 'chunkIndex', 'totalChunks', 'name', 'file' ]); + }); + it('keeps the file MIME type on chunks', () => { const uploader = createUploader({ chunkSize: 4 * KB }, new File([ 'x'.repeat(10 * KB) ], 'photo.png', { type: 'image/png' })); @@ -324,7 +249,10 @@ describe('ChunkedFileUploader', () => { }); describe('callbacks that throw', () => { - it('fail the item instead of stalling the queue', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('fail the item instead of stalling the queue, then rethrow', () => { const uploader = createUploader({ chunkSize: 4 * KB }); const item = uploader.queue[ 0 ]; const error = jest.spyOn(uploader, 'onErrorItem'); @@ -337,6 +265,7 @@ describe('ChunkedFileUploader', () => { expect(error).toHaveBeenCalledTimes(1); expect(item.isError).toBe(true); expect(uploader.isUploading).toBe(false); + expect(() => jest.runOnlyPendingTimers()).toThrow(SyntaxError); }); it('fail the item when onBeforeUploadChunk throws on a later chunk', () => { @@ -352,6 +281,7 @@ describe('ChunkedFileUploader', () => { expect(uploader.queue[ 0 ].isError).toBe(true); expect(uploader.isUploading).toBe(false); + expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); }); it('report the item once when onErrorChunk throws', () => { @@ -366,6 +296,28 @@ describe('ChunkedFileUploader', () => { expect(error).toHaveBeenCalledTimes(1); expect(uploader.isUploading).toBe(false); + expect(() => jest.runOnlyPendingTimers()).toThrow('hook failed'); + }); + + it('do not report an already finished item again', () => { + const uploader = createUploader({ chunkSize: 4 * KB }); + const item = uploader.queue[ 0 ]; + const error = jest.spyOn(uploader, 'onErrorItem'); + uploader.onBeforeUploadChunk = (fileItem, chunk) => { + if (chunk.index === 1) { + fileItem.cancel(); + } + }; + uploader.onCompleteItem = () => { + throw new Error('app failed'); + }; + + uploader.uploadAll(); + last().respond(200); + + expect(error).not.toHaveBeenCalled(); + expect(item.isCancel).toBe(true); + expect(() => jest.runOnlyPendingTimers()).toThrow('app failed'); }); }); diff --git a/libs/ng2-file-upload/testing/spec/fake-xhr.ts b/libs/ng2-file-upload/testing/spec/fake-xhr.ts new file mode 100644 index 00000000..70b82489 --- /dev/null +++ b/libs/ng2-file-upload/testing/spec/fake-xhr.ts @@ -0,0 +1,82 @@ +export class FakeXhr { + static instances: FakeXhr[] = []; + upload: any = {}; + status = 0; + response = ''; + responseText = ''; + readyState = 0; + withCredentials = false; + method?: string; + url?: string; + body: any; + requestHeaders: { [ name: string ]: string } = {}; + sent = false; + aborted = false; + onload?: () => void; + onerror?: () => void; + onabort?: () => void; + onreadystatechange?: () => void; + + constructor() { + FakeXhr.instances.push(this); + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.requestHeaders[ name ] = value; + } + + send(body: any): void { + this.body = body; + this.sent = true; + } + + abort(): void { + if (!this.sent || this.readyState === 4) { + return; + } + this.aborted = true; + this.onabort?.(); + } + + getAllResponseHeaders(): string { + return ''; + } + + respond(status: number, response = ''): void { + this.status = status; + this.response = response; + this.responseText = response; + this.readyState = 4; + this.onload?.(); + this.onreadystatechange?.(); + } +} + +export function sent(): FakeXhr[] { + return FakeXhr.instances.filter(xhr => xhr.sent); +} + +export function last(): FakeXhr { + const requests = sent(); + + return requests[ requests.length - 1 ]; +} + +export function installFakeXhr(): void { + const originalXhr = (globalThis as any).XMLHttpRequest; + + beforeEach(() => { + FakeXhr.instances = []; + (globalThis as any).XMLHttpRequest = FakeXhr; + (FakeXhr as any).DONE = 4; + }); + + afterEach(() => { + (globalThis as any).XMLHttpRequest = originalXhr; + }); +} diff --git a/libs/ng2-file-upload/testing/spec/file-uploader-request.spec.ts b/libs/ng2-file-upload/testing/spec/file-uploader-request.spec.ts new file mode 100644 index 00000000..d810edaf --- /dev/null +++ b/libs/ng2-file-upload/testing/spec/file-uploader-request.spec.ts @@ -0,0 +1,89 @@ +import { FileUploader, FileUploaderOptions } from '../../file-upload/file-uploader.class'; +import { installFakeXhr, last } from './fake-xhr'; + +function createUploader(options: Partial): FileUploader { + const uploader = new FileUploader({ url: '/upload', ...options }); + uploader.addToQueue([ new File([ 'hello' ], 'file.txt', { type: 'text/plain' }) ]); + + return uploader; +} + +describe('FileUploader: request', () => { + installFakeXhr(); + + it('sends the file first, then additional parameters', () => { + const uploader = createUploader({ additionalParameter: { name: '{{file_name}}', id: 1 } }); + + uploader.uploadAll(); + const form = last().body as FormData; + + expect([ ...form.keys() ]).toEqual([ 'file', 'name', 'id' ]); + expect(form.get('name')).toBe('file.txt'); + expect(form.get('file')).toHaveProperty('name', 'file.txt'); + }); + + it('sends additional parameters first with parametersBeforeFiles', () => { + const uploader = createUploader({ parametersBeforeFiles: true, additionalParameter: { name: 'x' } }); + + uploader.uploadAll(); + + expect([ ...(last().body as FormData).keys() ]).toEqual([ 'name', 'file' ]); + }); + + it('keeps fields added in onBuildItemForm before the file', () => { + const uploader = createUploader({}); + uploader.onBuildItemForm = (_item, form) => form.append('extra', '1'); + + uploader.uploadAll(); + + expect([ ...(last().body as FormData).keys() ]).toEqual([ 'extra', 'file' ]); + }); + + it('opens the request with the item url, method, headers and auth token', () => { + const uploader = createUploader({ method: 'PUT', authToken: 'Bearer t', headers: [ { name: 'X-App', value: '1' } ] }); + uploader.queue[ 0 ].headers.push({ name: 'X-Item', value: '2' }); + + uploader.uploadAll(); + + expect([ last().method, last().url, last().withCredentials ]).toEqual([ 'PUT', '/upload', true ]); + expect(last().requestHeaders).toEqual({ 'X-App': '1', 'X-Item': '2', Authorization: 'Bearer t' }); + }); + + it('creates the request before onBeforeUploadItem', () => { + const uploader = createUploader({}); + let xhrInHook: unknown; + uploader.onBeforeUploadItem = (item) => xhrInHook = item._xhr; + + uploader.uploadAll(); + + expect(xhrInHook).toBe(last()); + }); + + it('emits the response text and reports the item', () => { + const uploader = createUploader({}); + const responses: string[] = []; + const calls: string[] = []; + uploader.response.subscribe((res: string) => responses.push(res)); + uploader.onSuccessItem = (_item, response, status) => calls.push(`success ${ response } ${ status }`); + uploader.onCompleteItem = () => calls.push('complete'); + + uploader.uploadAll(); + last().respond(201, 'done'); + + expect(responses).toEqual([ 'done' ]); + expect(calls).toEqual([ 'success done 201', 'complete' ]); + }); + + it('sends the async formatDataFunction result as JSON without multipart', async () => { + const uploader = createUploader({ + disableMultipart: true, + formatDataFunctionIsAsync: true, + formatDataFunction: () => Promise.resolve({ name: 'x' }) + }); + + uploader.uploadAll(); + await Promise.resolve(); + + expect(last().body).toBe('{"name":"x"}'); + }); +});